commit 6bcf22458ab50674ae3f22e30102ce9260d88cd4 Author: zevaryx Date: Thu Feb 27 16:45:09 2025 -0700 Initial API mappings for cards and sets diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c8db82 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Pyfall + +An async Scryfall API wrapper written diff --git a/pyfall/__init__.py b/pyfall/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyfall/client/__init__.py b/pyfall/client/__init__.py new file mode 100644 index 0000000..6e906c2 --- /dev/null +++ b/pyfall/client/__init__.py @@ -0,0 +1,180 @@ +from typing import Any, Literal + +import httpx + +from pyfall.const import __version__ +from pyfall.models import * +from pyfall.utils import UUID_CHECK + +import asyncio +import logging +import time +from typing import Any + +from httpx import AsyncClient + +from pyfall.const import __version__, get_logger +from pyfall.client.error import * +from pyfall.client.http.http_requests.card import CardRequests +from pyfall.client.http.http_requests.set import SetRequests +from pyfall.client.route import Route + +class GlobalLock: + def __init__(self) -> None: + self._lock = asyncio.Lock() + self.max_requests = 10 + self._calls = self.max_requests + self._reset_time = 0 + + @property + def calls_remaining(self) -> int: + """Returns the amount of calls remaining.""" + return self.max_requests - self._calls + + def reset_calls(self) -> None: + """Resets the calls to the max amount.""" + self._calls = self.max_requests + self._reset_time = time.perf_counter() + 1 + + def set_reset_time(self, delta: float) -> None: + """ + Sets the reset time to the current time + delta. + + To be called if a 429 is received. + + Args: + delta: The time to wait before resetting the calls. + + """ + self._reset_time = time.perf_counter() + delta + self._calls = 0 + + async def wait(self) -> None: + """Throttles calls to prevent hitting the global rate limit.""" + async with self._lock: + if self._reset_time <= time.perf_counter(): + self.reset_calls() + elif self._calls <= 0: + await asyncio.sleep(self._reset_time - time.perf_counter()) + self.reset_calls() + self._calls -= 1 + +class Pyfall(CardRequests, SetRequests): + def __init__(self, logger: logging.Logger | None = None): + self.__headers = { + "Content-Type": "application/json", + "UserAgent": f"pyfall/{__version__}", + "Accept": "application/json" + } + self.__client: AsyncClient = None + self.global_lock: GlobalLock = GlobalLock() + self._max_attempts: int = 3 + + self.logger = logger + if self.logger is None: + self.logger = get_logger() + + async def request(self, route: Route, params: dict | None = None, **kwargs: dict) -> dict[str, Any]: + """Make a request to the Scryfall API. + + Args: + route: Route to take + params: Query string parameters. Defaults to None. + + Returns: + dict[str, Any]: Raw result + """ + if params is not None: + kwargs["params"] = params + if not self.__client: + self.__client = AsyncClient(headers=self.__headers) + for attempt in range(self._max_attempts): + if self.__client is None: + self.__client = AsyncClient() + + await self.global_lock.wait() + + response = await self.__client.request(route.method, route.url, **kwargs) + + if response.status_code == 429: + self.logger.warning("Too many requests, waiting 5 seconds") + self.global_lock.set_reset_time(5) + continue + + if response.status_code >= 500: + self.logger.warning(f"{route.resolved_endpoint} Received {response.status_code}... retrying in {1 + attempt * 2} seconds") + await asyncio.sleep(1 + attempt * 2) + continue + + result = response.json() + + if not 300 > response.status_code >= 200: + await self._raise_exception(response, route, result) + + + + return result + + async def _raise_exception(self, response, route, result) -> None: + self.logger.error(f"{route.method}::{route.url}: {response.status_code}") + + if response.status_code == 403: + raise Forbidden(response, route) + if response.status_code == 404: + raise NotFound(response, route) + if response.status_code >= 500: + raise ScryfallError(response, route) + + raise HTTPException(response, route) + + async def close(self) -> None: + """Close the session.""" + if self.__client is not None and not self.__client.is_closed: + await self.__client.aclose() + +# class Pyfall: +# """Scryfall API client.""" +# def __init__(self): +# self._headers = { +# "Content-Type": "application/json", +# "UserAgent": f"pyfall/{__version__}", +# "Accept": "application/json" +# } +# self.__httpx: httpx.AsyncClient = None + +# async def request(self, endpoint: str, method: str = "GET", **kwargs) -> dict[str, Any]: +# """Make a request to the API. + +# Args: +# endpoint: Endpoint to request from + +# Returns: +# Raw API result +# """ +# if not self.__httpx: +# self.__httpx = httpx.AsyncClient(headers=self._headers, base_url="https://api.scryfall.com") + +# args = {k: v for k, v in kwargs if v is not None} + +# if method not in ("POST", "PATCH", "PUT"): +# response: httpx.Response = await self.__httpx.request(method=method, url=endpoint, params=args) +# else: +# response: httpx.Response = await self.__httpx.request(method=method, url=endpoint, data=args) +# if response.status_code >= 500: +# response.raise_for_status() +# data = response.json() + # return data + + # async def get_card_by_id(self, id: str) -> Card: + # """Get a card by ID. + + # Args: + # id: UUID of card + # """ + # if not UUID_CHECK.match(id): + # raise ValueError("'id' must be a UUID") + # data = await self.request(f"cards/{id}") + # if data["object"] == "error": + # return APIError.model_validate(data) + # data["_client"] = self + # return Card(**data) \ No newline at end of file diff --git a/pyfall/client/error.py b/pyfall/client/error.py new file mode 100644 index 0000000..4fae0fb --- /dev/null +++ b/pyfall/client/error.py @@ -0,0 +1,54 @@ +from typing import TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from pyfall.client.route import Route + +class LibraryException(Exception): + """Base Exception of pyfall.""" + +class HTTPException(LibraryException): + """ + An HTTP request resulted in an exception. + + Attributes: + response httpx.Response: The response of the HTTP request + details str: The text of the exception, could be None + status int: The HTTP status code + route Route: The HTTP route that was used + """ + + def __init__(self, response: httpx.Response, route: "Route") -> None: + self.response: httpx.Response = response + self.route: "Route" = route + + data = response.json() + + self.status: int = data.get("status") + self.code: str = data.get("code") + self.details: str = data.get("details") + self.type: str | None = data.get("type", None) + self.warnings: list[str] | None = data.get("warnings", None) + + super().__init__(f"{self.status}|{self.code}: {f'({self.type})' if self.type else ''}{self.details}") + + def __str__(self) -> str: + if not self.warnings: + return f"HTTPException: {self.status}|{self.code} || {self.details}" + return f"HTTPException: {self.status}|{self.code}: " + "\n".join(self.warnings) + + def __repr__(self) -> str: + return str(self) + +class ScryfallError(HTTPException): + """An error occurred with Scryfall.""" + +class BadRequest(HTTPException): + """A bad request was made.""" + +class Forbidden(HTTPException): + """You do not have access to this.""" + +class NotFound(HTTPException): + """This resource could not be found.""" \ No newline at end of file diff --git a/pyfall/client/http/http_client.py b/pyfall/client/http/http_client.py new file mode 100644 index 0000000..88b5101 --- /dev/null +++ b/pyfall/client/http/http_client.py @@ -0,0 +1,125 @@ +import asyncio +import logging +import time +from typing import Any + +from httpx import AsyncClient + +from pyfall.const import __version__, get_logger +from pyfall.client.error import * +from pyfall.client.http.http_requests.card import CardRequests +from pyfall.client.http.http_requests.set import SetRequests +from pyfall.client.route import Route + +class GlobalLock: + def __init__(self) -> None: + self._lock = asyncio.Lock() + self.max_requests = 10 + self._calls = self.max_requests + self._reset_time = 0 + + @property + def calls_remaining(self) -> int: + """Returns the amount of calls remaining.""" + return self.max_requests - self._calls + + def reset_calls(self) -> None: + """Resets the calls to the max amount.""" + self._calls = self.max_requests + self._reset_time = time.perf_counter() + 1 + + def set_reset_time(self, delta: float) -> None: + """ + Sets the reset time to the current time + delta. + + To be called if a 429 is received. + + Args: + delta: The time to wait before resetting the calls. + + """ + self._reset_time = time.perf_counter() + delta + self._calls = 0 + + async def wait(self) -> None: + """Throttles calls to prevent hitting the global rate limit.""" + async with self._lock: + if self._reset_time <= time.perf_counter(): + self.reset_calls() + elif self._calls <= 0: + await asyncio.sleep(self._reset_time - time.perf_counter()) + self.reset_calls() + self._calls -= 1 + +class HTTPClient(CardRequests, SetRequests): + def __init__(self, logger: logging.Logger | None = None): + self.__headers = { + "Content-Type": "application/json", + "UserAgent": f"pyfall/{__version__}", + "Accept": "application/json" + } + self.__client: AsyncClient = None + self.global_lock: GlobalLock = GlobalLock() + self._max_attempts: int = 3 + + self.logger = logger + if self.logger is None: + self.logger = get_logger() + + async def request(self, route: Route, params: dict | None = None, **kwargs: dict) -> dict[str, Any]: + """Make a request to the Scryfall API. + + Args: + route: Route to take + params: Query string parameters. Defaults to None. + + Returns: + dict[str, Any]: Raw result + """ + if params is not None: + kwargs["params"] = params + if not self.__client: + self.__client = AsyncClient(headers=self.__headers) + for attempt in range(self._max_attempts): + if self.__client is None: + self.__client = AsyncClient() + + await self.global_lock.wait() + + response = await self.__client.request(route.method, route.url, **kwargs) + + if response.status_code == 429: + self.logger.warning("Too many requests, waiting 5 seconds") + self.global_lock.set_reset_time(5) + continue + + if response.status_code >= 500: + self.logger.warning(f"{route.resolved_endpoint} Received {response.status_code}... retrying in {1 + attempt * 2} seconds") + await asyncio.sleep(1 + attempt * 2) + continue + + result = response.json() + + if not 300 > response.status_code >= 200: + await self._raise_exception(response, route, result) + + + + return result + + async def _raise_exception(self, response, route, result) -> None: + self.logger.error(f"{route.method}::{route.url}: {response.status_code}") + + if response.status_code == 403: + raise Forbidden(response, route) + if response.status_code == 404: + raise NotFound(response, route) + if response.status_code >= 500: + raise ScryfallError(response, route) + + raise HTTPException(response, route) + + async def close(self) -> None: + """Close the session.""" + if self.__client is not None and not self.__client.is_closed: + await self.__client.aclose() \ No newline at end of file diff --git a/pyfall/client/http/http_requests/card.py b/pyfall/client/http/http_requests/card.py new file mode 100644 index 0000000..baf88ef --- /dev/null +++ b/pyfall/client/http/http_requests/card.py @@ -0,0 +1,213 @@ +from typing import Any, Literal + +from pyfall.models.catalogs import Catalog +from pyfall.client.route import Route +from pyfall.models.cards import Card +from pyfall.models.api import APIList +from pyfall.models.rulings import Ruling +from pyfall.models.internal.protocols import CanRequest + +class CardRequests(CanRequest): + async def get_card_by_id(self, id: str) -> Card: + """Get a card by ID. + + Args: + id: UUID of card + """ + result = await self.request(Route("GET", f"/cards/{id}")) + result["_client"] = self + return Card(**result) + + async def get_card_by_tcgplayer_id(self, id: int) -> Card: + """Get card by TCGPlayer ID + + Args: + id: TCGPlayer ID + """ + result = await self.request(Route("GET", f"/cards/tcgplayer/{id}")) + result["_client"] = self + return Card(**result) + + async def get_card_by_multiverse_id(self, id: int) -> Card: + """Get card by Multiverse ID + + Args: + id: Multiverse ID + """ + result = await self.request(Route("GET", f"/cards/multiverse/{id}")) + result["_client"] = self + return Card(**result) + + async def get_card_by_mtgo_id(self, id: int) -> Card: + """Get card by MTGO ID + + Args: + id: MTGO ID + """ + result = await self.request(Route("GET", f"/cards/mtgo/{id}")) + result["_client"] = self + return Card(**result) + + async def get_card_by_arena_id(self, id: int) -> Card: + """Get card by MTG Arena ID + + Args: + id: MTG Arena ID + """ + result = await self.request(Route("GET", f"/cards/arena/{id}")) + result["_client"] = self + return Card(**result) + + async def get_card_by_cardmarket_id(self, id: int) -> Card: + """Get card by Cardmarket ID + + Args: + id: Cardmarket ID + """ + result = await self.request(Route("GET", f"/cards/cardmarket/{id}")) + result["_client"] = self + return Card(**result) + + async def get_rulings_by_card_id(self, id: str) -> list[Ruling]: + """Get card rulings by card ID. + + Args: + id: UUID of card + """ + result = await self.request(Route("GET", f"/cards/{id}/rulings")) + return [Ruling(**x) for x in result.get("data")] + + async def search_cards( + self, + q: str, + unique: Literal["cards", "art", "prints"] = "cards", + order: Literal["name", "set", "released", "rarity", "color", "usd", "tix", "eur", "cmc", "power", "toughness", "edhrec", "penny", "artist", "review"] = "name", + dir: Literal["auto", "asc", "desc"] = "auto", + include_extras: bool = False, + include_multilingual: bool = False, + include_variations: bool = False, + page: int = 1, + ) -> APIList: + """Search for a card using a fulltext string search. + + Args: + q: A fulltext search query. Max length: 1000 Unicode characters + unique: The strategy for omitting cards. Default `cards` + order: The method to sort returned cards. Default `name` + dir: Direction to sort cards. Default `auto` + include_extras: If true, extra cards (tokens, planes, etc) will be included. Equivalent to adding `include:extras` to the fulltext search. Default `false` + include_multilingual: If true, cards in every language supported by Scryfall will be included. Default `false` + include_variations: If true, rare card variants will by included. Default `false` + page: Page number to return. Default `1` + """ + if len(q) > 1000: + raise ValueError("Query can only be max of 1000 Unicode characters") + + params = dict( + q=q, + unique=unique, + order=order, + dir=dir, + include_extras=include_extras, + include_multilingual=include_multilingual, + include_variations=include_variations, + page=page + ) + + result = await self.request( + Route( + "GET", + "/cards/search", + ), + params=params + ) + + result["_client"] = self + return APIList(**result) + + async def search_cards_named( + self, + exact: str | None = None, + fuzzy: str | None = None, + set: str | None = None + ) -> Card: + """Search for a card using name search. + + Args: + exact: Exact string to search for + fuzzy: Fuzzy string to search for + set: Set to search in + """ + if (not exact and not fuzzy) or (exact and fuzzy): + raise ValueError("Either exact or fuzzy needs provided") + + params = dict(set=set) + if exact: + params["exact"] = exact + elif fuzzy: + params["fuzzy"] = fuzzy + + result = await self.request( + Route( + "GET", + "/cards/named", + ), + params=params, + ) + + result["_client"] = self + return Card(**result) + + async def cards_autocomplete( + self, + q: str, + include_extras: bool = False + ) -> Catalog: + """Returns a Catalog containing up to 20 full English card names for autocomplete purposes. + + Args: + q: The string to autocomplete + include_extras: If true, extra cards (tokens, planes, vanguards, etc) will be included. Default False + """ + params = dict(q=q, include_extras=include_extras) + + result = await self.request( + Route( + "GET", + "/cards/autocomplete" + ), + params=params, + ) + + return Catalog(**result) + + async def get_random_card( + self, + q: str | None = None, + ) -> Card: + params = dict(q=q) + + result = await self.request( + Route( + "GET", + "/cards/random" + ), + params=params, + ) + + result["_client"] = self + return Card(**result) + + async def get_card_collection(self, identifiers: list[dict[str, Any]]): + raise NotImplementedError("This endpoint has not been implemented") + + async def get_card_by_set_code_and_collector_number(self, code: str, number: str, lang: str | None = None) -> Card: + result = await self.request( + Route( + "GET", + f"/cards/{code}/{number}{f'/{lang}' if lang else ''}" + ) + ) + + result["_client"] = self + return Card(**result) \ No newline at end of file diff --git a/pyfall/client/http/http_requests/set.py b/pyfall/client/http/http_requests/set.py new file mode 100644 index 0000000..dfd31fd --- /dev/null +++ b/pyfall/client/http/http_requests/set.py @@ -0,0 +1,50 @@ +from typing import Any, Literal + +from pyfall.models.catalogs import Catalog +from pyfall.client.route import Route +from pyfall.models.cards import Card +from pyfall.models.api import APIList +from pyfall.models.rulings import Ruling +from pyfall.models.sets import Set +from pyfall.models.internal.protocols import CanRequest + +class SetRequests(CanRequest): + async def get_all_sets(self) -> APIList: + """Get all MTG sets.""" + result = await self.request(Route("GET", "/sets")) + + result["_client"] = self + return APIList(**result) + + async def get_set_by_id(self, id: str) -> Set: + """Get MTG set by ID. + + Args: + id: UUID of set + """ + result = await self.request(Route("GET", f"/sets/{id}")) + + result["_client"] = self + return Set(**result) + + async def get_set_by_code(self, code: str) -> Set: + """Get MTG set by set code. + + Args: + code: Set code + """ + result = await self.request(Route("GET", f"/sets/{code}")) + + result["_client"] = self + return Set(**result) + + async def get_set_by_tcgplayer_id(self, id: str) -> Set: + """Get MTG set by TCGPlayer ID. + + Args: + id: TCGPlayer ID of set + """ + result = await self.request(Route("GET", f"/sets/tcgplayer/{id}")) + + result["_client"] = self + return Set(**result) \ No newline at end of file diff --git a/pyfall/client/route.py b/pyfall/client/route.py new file mode 100644 index 0000000..cb62eef --- /dev/null +++ b/pyfall/client/route.py @@ -0,0 +1,44 @@ +from typing import Any, ClassVar +from urllib.parse import quote as _uriquote + +PAYLOAD_TYPE = dict[str, int | str | bool | list | None] + +class Route: + BASE: ClassVar[str] = "https://api.scryfall.com" + path: str + params: dict[str, str | int | bool] + + def __init__(self, method: str, path: str, **params: Any) -> None: + self.path: str = path + self.method: str = method + self.params = params + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self.endpoint + + @property + def resolved_path(self) -> str: + """The endpoint for this route, with all parameters resolved""" + return self.path.format_map({k: _uriquote(v) if isinstance(v, str) else v for k, v in self.params.items()}) + + @property + def endpoint(self) -> str: + """The endpoint for this route""" + return f"{self.method} {self.path}" + + @property + def resolved_endpoint(self) -> str: + """The endpoint for this route, with all major parameters resolved""" + path = self.path + for key, value in self.major_params.items(): + path = path.replace(f"{{{key}}}", str(value)) + + return f"{self.method} {path}" + + @property + def url(self) -> str: + """The full url for this route""" + return f"{self.BASE}{self.resolved_path}" \ No newline at end of file diff --git a/pyfall/const.py b/pyfall/const.py new file mode 100644 index 0000000..385d7b4 --- /dev/null +++ b/pyfall/const.py @@ -0,0 +1,11 @@ +import logging + +__version__ = "0.1.0" + +logger_name = "pyfall" + +_logger = logging.getLogger(logger_name) + +def get_logger() -> logging.Logger: + global _logger + return _logger \ No newline at end of file diff --git a/pyfall/models/__init__.py b/pyfall/models/__init__.py new file mode 100644 index 0000000..6446de9 --- /dev/null +++ b/pyfall/models/__init__.py @@ -0,0 +1,9 @@ +from .api import APIError, APIList +from .bulk import BulkData +from .cards import Card +from .catalogs import Catalog +from .rulings import Ruling +from .sets import Set +from .symbols import CardSymbol + +__all__ = ["APIError", "APIList", "BulkData", "Card", "Catalog", "Ruling", "Set", "CardSymbol"] \ No newline at end of file diff --git a/pyfall/models/api.py b/pyfall/models/api.py new file mode 100644 index 0000000..21f32c6 --- /dev/null +++ b/pyfall/models/api.py @@ -0,0 +1,47 @@ +from typing import Any, Literal, Self + +from pydantic import BaseModel, HttpUrl, ValidationError, model_validator + +from pyfall.models.base import BaseAPIModel +from pyfall.models.cards import Card +from pyfall.models.rulings import Ruling +from pyfall.models.sets import Set +from pyfall.models.symbols import CardSymbol + +CLASS_LOOKUP = { + "card": Card, + "card_symbol": CardSymbol, + "ruling": Ruling, + "set": Set +} + +class APIError(BaseModel): + status: int + code: str + details: str + type: str | None = None + warnings: list[str] | None = None + +class APIList(BaseAPIModel): + object: Literal["list"] + data: list[Card | CardSymbol | Ruling | Set] + has_more: bool + next_page: HttpUrl | None = None + total_cards: int | None = None + warnings: list[str] | None = None + + @model_validator(mode="before") + @classmethod + def validate_data(cls, data: Any) -> Any: + if data.get("object") == "list": + for item in data.get("data"): + item["_client"] = data["_client"] + item = CLASS_LOOKUP.get(item.get("object"))(**item) + return data + + async def get_next_page(self) -> Self | None: + if self.has_more: + params = dict(self.next_page.query_params()) + params.pop("format", None) + return await self._client.cards_search(**params) + return None \ No newline at end of file diff --git a/pyfall/models/base.py b/pyfall/models/base.py new file mode 100644 index 0000000..ac573d7 --- /dev/null +++ b/pyfall/models/base.py @@ -0,0 +1,15 @@ +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +if TYPE_CHECKING: + from pyfall.client import Pyfall + +class BaseAPIModel(BaseModel): + """Base API model for base API calls.""" + _client: "Pyfall" + + def __init__(self, **data): + client = data.get("_client") + super().__init__(**data) + self._client = client \ No newline at end of file diff --git a/pyfall/models/bulk.py b/pyfall/models/bulk.py new file mode 100644 index 0000000..352536f --- /dev/null +++ b/pyfall/models/bulk.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from pydantic import BaseModel, HttpUrl +from pydantic.types import UUID + +class BulkData(BaseModel): + id: UUID + uri: HttpUrl + type: str + name: str + description: str + download_uri: HttpUrl + updated_at: datetime + size: int + content_type: str + content_encoding: str \ No newline at end of file diff --git a/pyfall/models/cards.py b/pyfall/models/cards.py new file mode 100644 index 0000000..e37250c --- /dev/null +++ b/pyfall/models/cards.py @@ -0,0 +1,161 @@ +from datetime import date +from typing import Any, Literal, TYPE_CHECKING + +from pydantic import BaseModel, HttpUrl, field_validator +from pydantic.types import UUID + +# from pyfall.models.api import APIList +from pyfall.models.base import BaseAPIModel +from pyfall.models.enums import Color + +if TYPE_CHECKING: + from pyfall.client import Pyfall + from pyfall.models.rulings import Ruling + from pyfall.models.sets import Set + +class RelatedCard(BaseModel): + id: UUID + object: Literal["related_card"] + component: Literal["token", "meld_part", "meld_result", "combo_piece"] + name: str + type_line: str + uri: HttpUrl + +class CardFace(BaseModel): + artist: str | None = None + artist_id: str | None = None + cmc: float | None = None + color_indicator: list[Color] | None = None + colors: list[Color] | None = None + defense: str | None = None + flavor_text: str | None = None + illustration_id: UUID | None = None + image_uris: dict[Literal["small", "normal", "large", "png", "art_crop", "border_crop"], HttpUrl] | None = None + layout: str | None = None + loyalty: str | None = None + mana_cost: str + name: str + object: Literal["card_face"] + oracle_id: UUID | None = None + oracle_text: str | None = None + power: str | None = None + printed_name: str | None = None + printed_text: str | None = None + printed_type_line: str | None = None + toughness: str | None = None + type_line: str | None = None + watermark: str | None = None + +class Preview(BaseModel): + previewed_at: date | None = None + source_uri: HttpUrl | None = None + source: str | None = None + + @field_validator("source_uri", mode="before") + @classmethod + def validate_source_uri(cls, value: Any) -> Any: + if isinstance(value, str): + if len(value) > 0: + return HttpUrl(value) + return None + +class Card(BaseAPIModel): + # Core fields + arena_id: int | None = None + id: UUID + lang: str + mtgo_id: int | None = None + mtgo_foil_id: int | None = None + multiverse_ids: list[int] | None = None + tcgplayer_id: int | None = None + tcgplayer_etched_id: int | None = None + cardmarket_id: int | None = None + object: Literal["card"] + layout: str + oracle_id: UUID | None = None + prints_search_uri: HttpUrl + rulings_uri: HttpUrl + scryfall_uri: HttpUrl + uri: HttpUrl + + # Gameplay fields + all_parts: list[RelatedCard] | None = None + card_faces: list[CardFace] | None = None + cmc: float + color_identity: list[Color] + color_indicator: list[Color] | None = None + colors: list[Color] | None = None + defense: str | None = None + edhrec_rank: int | None = None + game_changer: bool | None = None + hand_modifier: str | None = None + keywords: list[str] + legalities: dict[str, Literal["legal", "not_legal", "restricted", "banned"]] + life_modifier: str | None = None + loyalty: str | None = None + mana_cost: str | None = None + name: str + oracle_text: str | None = None + penny_rank: int | None = None + power: str | None = None + produced_mana: list[Color] | None = None + reserved: bool + toughness: str | None = None + type_line: str + + # Print fields + artist: str | None = None + artist_ids: list[UUID] | None = None + attraction_lights: list[int] | None = None + booster: bool + border_color: Literal["black", "white", "borderless", "yellow", "silver", "gold"] + card_back_id: UUID | None = None + collector_number: str + content_warning: bool | None = None + digital: bool + finishes: list[Literal["foil", "nonfoil", "etched"]] + flavor_name: str | None = None + flavor_text: str | None = None + frame_effects: list[str] | None = None + frame: str + full_art: bool + games: list[Literal["paper", "arena", "mtgo"]] + highres_image: bool + illustration_id: UUID | None = None + image_status: Literal["missing", "placeholder", "lowres", "highres_scan"] + image_uris: dict[Literal["small", "normal", "large", "png", "art_crop", "border_crop"], HttpUrl] | None = None + oversized: bool + prices: dict[str, str | None] + printed_name: str | None = None + printed_text: str | None = None + printed_type_line: str | None = None + promo: bool + promo_types: list[str] | None = None + purchase_uris: dict[str, HttpUrl] | None = None + rarity: Literal["common", "uncommon", "rare", "special", "mythic", "bonus"] + related_uris: dict[str, HttpUrl] + released_at: date + reprint: bool + scryfall_set_uri: HttpUrl + set_name: str + set_search_uri: HttpUrl + set_type: str + set_uri: HttpUrl + set: str + set_id: UUID + story_spotlight: bool + textless: bool + variation: bool + variation_of: UUID | None = None + security_stamp: Literal["oval", "triangle", "acorn", "circle", "arena", "heart"] | None = None + watermark: str | None = None + preview: Preview | None = None + + async def get_set(self) -> "Set": + """Get set card is a part of.""" + return await self._client.get_set_by_id(self.set_id) + + async def get_rulings(self) -> "Ruling": + """Get rulings for card.""" + return await self._client.get_rulings_by_card_id(self.id) + \ No newline at end of file diff --git a/pyfall/models/catalogs.py b/pyfall/models/catalogs.py new file mode 100644 index 0000000..3043696 --- /dev/null +++ b/pyfall/models/catalogs.py @@ -0,0 +1,9 @@ +from typing import Literal + +from pydantic import BaseModel, HttpUrl + +class Catalog(BaseModel): + object: Literal["catalog"] + uri: HttpUrl | None = None + total_values: int + data: list[str] \ No newline at end of file diff --git a/pyfall/models/enums.py b/pyfall/models/enums.py new file mode 100644 index 0000000..3350cfd --- /dev/null +++ b/pyfall/models/enums.py @@ -0,0 +1,9 @@ +from enum import Enum + +class Color(Enum): + White = "W" + Blue = "U" + Black = "B" + Red = "R" + Green = "G" + Colorless = "C" \ No newline at end of file diff --git a/pyfall/models/internal/protocols.py b/pyfall/models/internal/protocols.py new file mode 100644 index 0000000..7414f7f --- /dev/null +++ b/pyfall/models/internal/protocols.py @@ -0,0 +1,16 @@ +import typing +from typing import Protocol, Any, TypeVar + +from pyfall.client.route import Route + +T_co = TypeVar("T", covariant=True) + +@typing.runtime_checkable +class CanRequest(Protocol[T_co]): + async def request( + self, + route: Route, + params: dict | None = None, + **kwargs: dict, + ) -> dict[str, Any]: + raise NotImplementedError("Derived classes need to implement this.") \ No newline at end of file diff --git a/pyfall/models/rulings.py b/pyfall/models/rulings.py new file mode 100644 index 0000000..4ac66e4 --- /dev/null +++ b/pyfall/models/rulings.py @@ -0,0 +1,12 @@ +from datetime import date +from typing import Literal + +from pydantic import BaseModel +from pydantic.types import UUID + +class Ruling(BaseModel): + object: Literal["ruling"] + oracle_id: UUID + source: Literal["wotc", "scryfall"] + published_at: date + comment: str \ No newline at end of file diff --git a/pyfall/models/sets.py b/pyfall/models/sets.py new file mode 100644 index 0000000..142e9a2 --- /dev/null +++ b/pyfall/models/sets.py @@ -0,0 +1,39 @@ +from datetime import date +from typing import Literal, TYPE_CHECKING + +from pydantic import HttpUrl +from pydantic.types import UUID + +from pyfall.models.base import BaseAPIModel + +if TYPE_CHECKING: + from pyfall.client import Pyfall + from pyfall.models.api import APIList + +class Set(BaseAPIModel): + object: Literal["set"] + id: UUID + code: str + mtgo_code: str | None = None + arena_code: str | None = None + tcgplayer_id: int | None = None + name: str + set_type: str + released_at: date | None = None + block_code: str | None = None + block: str | None = None + parent_set_code: str | None = None + card_count: int + printed_size: int | None = None + digital: bool + foil_only: bool + nonfoil_only: bool + scryfall_uri: HttpUrl + uri: HttpUrl + icon_svg_uri: HttpUrl + search_uri: HttpUrl + + async def get_cards(self) -> "APIList": + """Get a list of cards from the set.""" + params = dict(self.search_uri.query_params()) + return await self._client.search_cards(**params) \ No newline at end of file diff --git a/pyfall/models/symbols.py b/pyfall/models/symbols.py new file mode 100644 index 0000000..9873f60 --- /dev/null +++ b/pyfall/models/symbols.py @@ -0,0 +1,21 @@ +from typing import Literal + +from pydantic import BaseModel, HttpUrl + +from pyfall.models.enums import Color + +class CardSymbol(BaseModel): + object: Literal["card_symbol"] + symbol: str + loose_variant: str | None = None + english: str + transposable: bool + represents_mana: bool + mana_value: float | None = None + appears_in_mana_costs: bool + funny: bool + colors: list[Color] + hybrid: bool + phyrexian: bool + gatherer_alternatives: str | None = None + svg_uri: HttpUrl | None = None \ No newline at end of file diff --git a/pyfall/utils.py b/pyfall/utils.py new file mode 100644 index 0000000..7be4ed1 --- /dev/null +++ b/pyfall/utils.py @@ -0,0 +1,3 @@ +import re + +UUID_CHECK = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", flags=re.I) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..77cfa5d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "pyfall" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "httpx>=0.28.1", + "pydantic>=2.10.6", +] + +[dependency-groups] +dev = [ + "black>=25.1.0", + "ipython>=8.32.0", + "rich>=13.9.4", + "ruff>=0.9.7", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bfc228f --- /dev/null +++ b/uv.lock @@ -0,0 +1,467 @@ +version = 1 +revision = 1 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "decorator" +version = "5.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/0c/8d907af351aa16b42caae42f9d6aa37b900c67308052d10fdce809f8d952/decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", size = 35016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "ipython" +version = "8.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/80/4d2a072e0db7d250f134bc11676517299264ebe16d62a8619d49a78ced73/ipython-8.32.0.tar.gz", hash = "sha256:be2c91895b0b9ea7ba49d33b23e2040c352b33eb6a519cca7ce6e0c743444251", size = 5507441 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e1/f4474a7ecdb7745a820f6f6039dc43c66add40f1bcc66485607d93571af6/ipython-8.32.0-py3-none-any.whl", hash = "sha256:cae85b0c61eff1fc48b0a8002de5958b6528fa9c8defb1894da63f42613708aa", size = 825524 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, +] + +[[package]] +name = "pyfall" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "ipython" }, + { name = "rich" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic", specifier = ">=2.10.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "ipython", specifier = ">=8.32.0" }, + { name = "rich", specifier = ">=13.9.4" }, + { name = "ruff", specifier = ">=0.9.7" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "ruff" +version = "0.9.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/8b/a86c300359861b186f18359adf4437ac8e4c52e42daa9eedc731ef9d5b53/ruff-0.9.7.tar.gz", hash = "sha256:643757633417907510157b206e490c3aa11cab0c087c912f60e07fbafa87a4c6", size = 3669813 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/f3/3a1d22973291226df4b4e2ff70196b926b6f910c488479adb0eeb42a0d7f/ruff-0.9.7-py3-none-linux_armv6l.whl", hash = "sha256:99d50def47305fe6f233eb8dabfd60047578ca87c9dcb235c9723ab1175180f4", size = 11774588 }, + { url = "https://files.pythonhosted.org/packages/8e/c9/b881f4157b9b884f2994fd08ee92ae3663fb24e34b0372ac3af999aa7fc6/ruff-0.9.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d59105ae9c44152c3d40a9c40d6331a7acd1cdf5ef404fbe31178a77b174ea66", size = 11746848 }, + { url = "https://files.pythonhosted.org/packages/14/89/2f546c133f73886ed50a3d449e6bf4af27d92d2f960a43a93d89353f0945/ruff-0.9.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f313b5800483770bd540cddac7c90fc46f895f427b7820f18fe1822697f1fec9", size = 11177525 }, + { url = "https://files.pythonhosted.org/packages/d7/93/6b98f2c12bf28ab9def59c50c9c49508519c5b5cfecca6de871cf01237f6/ruff-0.9.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042ae32b41343888f59c0a4148f103208bf6b21c90118d51dc93a68366f4e903", size = 11996580 }, + { url = "https://files.pythonhosted.org/packages/8e/3f/b3fcaf4f6d875e679ac2b71a72f6691a8128ea3cb7be07cbb249f477c061/ruff-0.9.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87862589373b33cc484b10831004e5e5ec47dc10d2b41ba770e837d4f429d721", size = 11525674 }, + { url = "https://files.pythonhosted.org/packages/f0/48/33fbf18defb74d624535d5d22adcb09a64c9bbabfa755bc666189a6b2210/ruff-0.9.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a17e1e01bee0926d351a1ee9bc15c445beae888f90069a6192a07a84af544b6b", size = 12739151 }, + { url = "https://files.pythonhosted.org/packages/63/b5/7e161080c5e19fa69495cbab7c00975ef8a90f3679caa6164921d7f52f4a/ruff-0.9.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c1f880ac5b2cbebd58b8ebde57069a374865c73f3bf41f05fe7a179c1c8ef22", size = 13416128 }, + { url = "https://files.pythonhosted.org/packages/4e/c8/b5e7d61fb1c1b26f271ac301ff6d9de5e4d9a9a63f67d732fa8f200f0c88/ruff-0.9.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e63fc20143c291cab2841dbb8260e96bafbe1ba13fd3d60d28be2c71e312da49", size = 12870858 }, + { url = "https://files.pythonhosted.org/packages/da/cb/2a1a8e4e291a54d28259f8fc6a674cd5b8833e93852c7ef5de436d6ed729/ruff-0.9.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91ff963baed3e9a6a4eba2a02f4ca8eaa6eba1cc0521aec0987da8d62f53cbef", size = 14786046 }, + { url = "https://files.pythonhosted.org/packages/ca/6c/c8f8a313be1943f333f376d79724260da5701426c0905762e3ddb389e3f4/ruff-0.9.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88362e3227c82f63eaebf0b2eff5b88990280fb1ecf7105523883ba8c3aaf6fb", size = 12550834 }, + { url = "https://files.pythonhosted.org/packages/9d/ad/f70cf5e8e7c52a25e166bdc84c082163c9c6f82a073f654c321b4dff9660/ruff-0.9.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0372c5a90349f00212270421fe91874b866fd3626eb3b397ede06cd385f6f7e0", size = 11961307 }, + { url = "https://files.pythonhosted.org/packages/52/d5/4f303ea94a5f4f454daf4d02671b1fbfe2a318b5fcd009f957466f936c50/ruff-0.9.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d76b8ab60e99e6424cd9d3d923274a1324aefce04f8ea537136b8398bbae0a62", size = 11612039 }, + { url = "https://files.pythonhosted.org/packages/eb/c8/bd12a23a75603c704ce86723be0648ba3d4ecc2af07eecd2e9fa112f7e19/ruff-0.9.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c439bdfc8983e1336577f00e09a4e7a78944fe01e4ea7fe616d00c3ec69a3d0", size = 12168177 }, + { url = "https://files.pythonhosted.org/packages/cc/57/d648d4f73400fef047d62d464d1a14591f2e6b3d4a15e93e23a53c20705d/ruff-0.9.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:115d1f15e8fdd445a7b4dc9a30abae22de3f6bcabeb503964904471691ef7606", size = 12610122 }, + { url = "https://files.pythonhosted.org/packages/49/79/acbc1edd03ac0e2a04ae2593555dbc9990b34090a9729a0c4c0cf20fb595/ruff-0.9.7-py3-none-win32.whl", hash = "sha256:e9ece95b7de5923cbf38893f066ed2872be2f2f477ba94f826c8defdd6ec6b7d", size = 9988751 }, + { url = "https://files.pythonhosted.org/packages/6d/95/67153a838c6b6ba7a2401241fd8a00cd8c627a8e4a0491b8d853dedeffe0/ruff-0.9.7-py3-none-win_amd64.whl", hash = "sha256:3770fe52b9d691a15f0b87ada29c45324b2ace8f01200fb0c14845e499eb0c2c", size = 11002987 }, + { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +]