diff --git a/src/pyanilist/__init__.py b/src/pyanilist/__init__.py index 1f39b2c..0d8e12f 100644 --- a/src/pyanilist/__init__.py +++ b/src/pyanilist/__init__.py @@ -11,6 +11,7 @@ MediaRankType, MediaRelation, MediaSeason, + MediaSort, MediaSource, MediaStatus, MediaType, @@ -36,10 +37,10 @@ StaffName, Studio, ) -from ._types import YearsActive +from ._types import FuzzyDateInt, YearsActive from ._version import Version, _get_version -__version__ = _get_version() +__version__ = _get_version() __version_tuple__ = Version(*map(int, __version__.split("."))) __all__ = [ @@ -53,11 +54,13 @@ "MediaRankType", "MediaRelation", "MediaSeason", + "MediaSort", "MediaSource", "MediaStatus", "MediaType", # Types "YearsActive", + "FuzzyDateInt", # Models "AiringSchedule", "Character", diff --git a/src/pyanilist/_clients/_async.py b/src/pyanilist/_clients/_async.py index bc8b6df..daf9821 100644 --- a/src/pyanilist/_clients/_async.py +++ b/src/pyanilist/_clients/_async.py @@ -5,7 +5,6 @@ from httpx import AsyncClient, HTTPError, Response from pydantic import PositiveInt, validate_call -from pydantic_extra_types.country import CountryAlpha2 as CountryCode from stamina import retry_context from .._enums import MediaFormat, MediaSeason, MediaSort, MediaSource, MediaStatus, MediaType @@ -13,7 +12,7 @@ from .._parser import post_process_response from .._query import query_string from .._types import FuzzyDateInt -from .._utils import query_variables_constructor +from .._utils import to_anilist_case class AsyncAniList: @@ -60,7 +59,7 @@ async def _post_request(self, **kwargs: Any) -> Response: payload = { "query": query_string, - "variables": query_variables_constructor(kwargs), + "variables": {to_anilist_case(key): value for key, value in kwargs.items() if value is not None}, } async for attempt in retry_context(on=HTTPError, attempts=self.retries): @@ -99,7 +98,7 @@ async def get( average_score: int | None = None, popularity: int | None = None, source: MediaSource | None = None, - country_of_origin: CountryCode | str | None = None, + country_of_origin: str | None = None, is_licensed: bool | None = None, id_not: int | None = None, id_in: Iterable[int] | None = None, diff --git a/src/pyanilist/_clients/_sync.py b/src/pyanilist/_clients/_sync.py index bfcf918..0427e39 100644 --- a/src/pyanilist/_clients/_sync.py +++ b/src/pyanilist/_clients/_sync.py @@ -1,19 +1,17 @@ from __future__ import annotations -from collections.abc import Iterable from typing import Any from httpx import Client, HTTPError, Response from pydantic import PositiveInt, validate_call -from pydantic_extra_types.country import CountryAlpha2 as CountryCode from stamina import retry_context from .._enums import MediaFormat, MediaSeason, MediaSort, MediaSource, MediaStatus, MediaType from .._models import Media from .._parser import post_process_response from .._query import query_string -from .._types import FuzzyDateInt -from .._utils import query_variables_constructor +from .._types import FuzzyDateInt, Iterable +from .._utils import to_anilist_case class AniList: @@ -60,7 +58,7 @@ def _post_request(self, **kwargs: Any) -> Response: payload = { "query": query_string, - "variables": query_variables_constructor(kwargs), + "variables": {to_anilist_case(key): value for key, value in kwargs.items() if value is not None}, } for attempt in retry_context(on=HTTPError, attempts=self.retries): @@ -98,7 +96,7 @@ def get( average_score: int | None = None, popularity: int | None = None, source: MediaSource | None = None, - country_of_origin: CountryCode | str | None = None, + country_of_origin: str | None = None, is_licensed: bool | None = None, id_not: int | None = None, id_in: Iterable[int] | None = None, diff --git a/src/pyanilist/_types.py b/src/pyanilist/_types.py index e627b28..f4602d5 100644 --- a/src/pyanilist/_types.py +++ b/src/pyanilist/_types.py @@ -1,16 +1,15 @@ -""" -Aside from providing it's own types, this module also re-exports the following from pydantic for convenience: -- [HttpUrl](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.HttpUrl) -- [Color](https://docs.pydantic.dev/latest/api/pydantic_extra_types_color/#pydantic_extra_types.color.Color) -- [CountryAlpha2 as CountryCoude](https://docs.pydantic.dev/latest/api/pydantic_extra_types_country/#pydantic_extra_types.country.CountryAlpha2) -""" - from __future__ import annotations from typing import Annotated from pydantic import Field -from typing_extensions import NamedTuple +from typing_extensions import NamedTuple, TypeAlias, TypeVar, Union + +# A simpler Iterable type instead of collections.abc.Iterable +# to stop pydantic from converting them to ValidatorIterator +# https://github.com/pydantic/pydantic/issues/9541 +T = TypeVar("T") +Iterable: TypeAlias = Union[set[T], tuple[T, ...], list[T]] class YearsActive(NamedTuple): @@ -31,8 +30,3 @@ class YearsActive(NamedTuple): description="8 digit long date integer (YYYYMMDD). Unknown dates represented by 0. E.g. 2016: 20160000, May 1976: 19760500", ), ] - - -__all__ = [ - "YearsActive", -] diff --git a/src/pyanilist/_utils.py b/src/pyanilist/_utils.py index 6f209d1..94f9d6d 100644 --- a/src/pyanilist/_utils.py +++ b/src/pyanilist/_utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import re -from collections.abc import Iterable from typing import Any import nh3 @@ -102,13 +101,23 @@ def remove_null_fields(dictionary: dict[str, Any]) -> dict[str, Any]: return remap(dictionary, lambda path, key, value: value not in [None, {}, []]) # type: ignore -def query_variables_constructor(vars: dict[str, Any]) -> dict[str, Any]: +def to_anilist_case(var: str) -> str: """ Anilist doesn't stick to a single casing. Most of it is camelCase but then there's some made up stuff in there too. So can do nothing but create a mapping from snake_case to anilistCase + + Parameters + ---------- + var : str + A snake_case variable. + + Returns + ------- + str + Same thing but in anilist's case. """ - casing = { + casemap = { "id": "id", "id_mal": "idMal", "start_date": "startDate", @@ -179,12 +188,4 @@ def query_variables_constructor(vars: dict[str, Any]) -> dict[str, Any]: "sort": "sort", } - query_vars = {} - - for key, value in vars.items(): - if value is not None: - if isinstance(value, Iterable) and not isinstance(value, str): - value = set(value) - query_vars[casing[key]] = value - - return query_vars + return casemap[var] \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index 914e26b..1e3332a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,9 +5,9 @@ def test_fuzzy_date() -> None: assert FuzzyDate(year=2023, month=1, day=4).iso_format() == "2023-01-04" assert FuzzyDate(year=2023, month=1).iso_format() == "2023-01" assert FuzzyDate(year=2023).iso_format() == "2023" + assert FuzzyDate().iso_format() == "" assert FuzzyDate(year=2023, month=1, day=4).as_int() == 20230104 assert FuzzyDate(year=2023, month=1).as_int() == 20230100 assert FuzzyDate(year=2023).as_int() == 20230000 - - assert FuzzyDate().iso_format() == "" + assert FuzzyDate().as_int() == 10000000 diff --git a/tests/test_utils.py b/tests/test_utils.py index a38ae85..13c2f24 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,11 +1,10 @@ -from pyanilist import MediaSource from pyanilist._utils import ( flatten, markdown_formatter, - query_variables_constructor, remove_null_fields, sanitize_description, text_formatter, + to_anilist_case, ) from .mock_descriptions import BloomIntoYouAnthologyDescriptions @@ -116,18 +115,77 @@ def test_formatters() -> None: def test_query_variables_constructor() -> None: - kwargs = { - "id": 123456, - "is_licensed": True, - "search": "Attack on Titan", - "id_not_in": [1, 2, 3, 4, 5, 5, 5], - "source_in": [MediaSource.ANIME, MediaSource.ANIME, MediaSource.MANGA], - } - assert query_variables_constructor(kwargs) == { - "id": 123456, - "isLicensed": True, - "search": "Attack on Titan", - "id_not_in": {1, 2, 3, 4, 5}, - "source_in": {MediaSource.ANIME, MediaSource.MANGA}, + casemap = { + "id": "id", + "id_mal": "idMal", + "start_date": "startDate", + "end_date": "endDate", + "season": "season", + "season_year": "seasonYear", + "type": "type", + "format": "format", + "status": "status", + "episodes": "episodes", + "chapters": "chapters", + "duration": "duration", + "volumes": "volumes", + "is_adult": "isAdult", + "genre": "genre", + "tag": "tag", + "minimum_tag_rank": "minimumTagRank", + "tag_category": "tagCategory", + "licensed_by": "licensedBy", + "licensed_by_id": "licensedById", + "average_score": "averageScore", + "popularity": "popularity", + "source": "source", + "country_of_origin": "countryOfOrigin", + "is_licensed": "isLicensed", + "search": "search", + "id_not": "id_not", + "id_in": "id_in", + "id_not_in": "id_not_in", + "id_mal_not": "idMal_not", + "id_mal_in": "idMal_in", + "id_mal_not_in": "idMal_not_in", + "start_date_greater": "startDate_greater", + "start_date_lesser": "startDate_lesser", + "start_date_like": "startDate_like", + "end_date_greater": "endDate_greater", + "end_date_lesser": "endDate_lesser", + "end_date_like": "endDate_like", + "format_in": "format_in", + "format_not": "format_not", + "format_not_in": "format_not_in", + "status_in": "status_in", + "status_not": "status_not", + "status_not_in": "status_not_in", + "episodes_greater": "episodes_greater", + "episodes_lesser": "episodes_lesser", + "duration_greater": "duration_greater", + "duration_lesser": "duration_lesser", + "chapters_greater": "chapters_greater", + "chapters_lesser": "chapters_lesser", + "volumes_greater": "volumes_greater", + "volumes_lesser": "volumes_lesser", + "genre_in": "genre_in", + "genre_not_in": "genre_not_in", + "tag_in": "tag_in", + "tag_not_in": "tag_not_in", + "tag_category_in": "tagCategory_in", + "tag_category_not_in": "tagCategory_not_in", + "licensed_by_in": "licensedBy_in", + "licensed_by_id_in": "licensedById_in", + "average_score_not": "averageScore_not", + "average_score_greater": "averageScore_greater", + "average_score_lesser": "averageScore_lesser", + "popularity_not": "popularity_not", + "popularity_greater": "popularity_greater", + "popularity_lesser": "popularity_lesser", + "source_in": "source_in", + "sort": "sort", } + + for key, value in casemap.items(): + assert to_anilist_case(key) == value