diff --git a/docs/api-reference/enums.md b/docs/api-reference/enums.md index 0376e8d..c2a331a 100644 --- a/docs/api-reference/enums.md +++ b/docs/api-reference/enums.md @@ -1,3 +1,4 @@ +::: pyanilist._enums.BaseStrEnum ::: pyanilist._enums.CharacterRole ::: pyanilist._enums.ExternalLinkType ::: pyanilist._enums.MediaFormat diff --git a/docs/examples.md b/docs/examples.md index f69d091..dd2f965 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -86,6 +86,29 @@ Violet Evergarden CM (ONA) - https://anilist.co/anime/154164 """ ``` +## Characters + +```py +from pyanilist import AniList, CharacterRole + +media = AniList().get(20954) + +all_characters = [character.name.full for character in media.characters] + +# Filter main characters +main_characters = [character.name.full for character in media.characters if character.role is CharacterRole.MAIN] + +print(all_characters) +""" +['Shouya Ishida', 'Shouko Nishimiya', 'Yuzuru Nishimiya', 'Naoka Ueno', 'Miyako Ishida', 'Maria Ishida', 'Miki Kawai', 'Satoshi Mashiba', 'Tomohiro Nagatsuka', 'Yaeko Nishimiya', 'Ito Nishimiya', 'Miyoko Sahara', 'Kazuki Shimada', 'Takeuchi', 'Pedro', 'Keisuke Hirose', 'Ishida no Ane', 'Kita'] +""" + +print(main_characters) +""" +['Shouya Ishida', 'Shouko Nishimiya'] +""" +``` + ## Retries AniList API is flaky, sometimes it might return an error for a perfectly valid request. `pyanilist` handles this by simply retrying failed requests a specified number of times (default is 5) before raising an error. Every subsequent retry also adds an additional one-second delay between requests. @@ -93,8 +116,8 @@ AniList API is flaky, sometimes it might return an error for a perfectly valid r ```py from pyanilist import AniList -# Configure the number of retries. Setting it to 1 basically disables retrying. -anilist = AniList(retries=1) +# Configure the number of retries. Setting it to 0 disables retrying. +anilist = AniList(retries=0) media = anilist.search("violet evergarden") diff --git a/src/pyanilist/__init__.py b/src/pyanilist/__init__.py index d24bfc0..3849f90 100644 --- a/src/pyanilist/__init__.py +++ b/src/pyanilist/__init__.py @@ -61,10 +61,10 @@ Studio, ) from ._types import AniListID, AniListTitle, AniListYear, Color, CountryCode, HttpUrl, YearsActive -from ._version import _get_version +from ._version import VersionInfo, _get_version __version__ = _get_version() -__version_tuple__ = tuple(__version__.split(".")) +__version_tuple__ = VersionInfo(*map(int, __version__.split("."))) __all__ = [ # Clients diff --git a/src/pyanilist/_clients/_async.py b/src/pyanilist/_clients/_async.py index 09c0d0a..1c24093 100644 --- a/src/pyanilist/_clients/_async.py +++ b/src/pyanilist/_clients/_async.py @@ -10,7 +10,7 @@ from .._models import Media from .._query import query_string from .._types import AniListID, AniListTitle, AniListYear, HTTPXAsyncClientKwargs -from .._utils import flatten, remove_null_fields +from .._utils import flatten, remove_null_fields, sanitize_description class AsyncAniList: @@ -105,7 +105,7 @@ async def _post_request( return response @staticmethod - def _post_process_response(response: httpx.Response) -> dict[str, Any]: + async def _post_process_response(response: httpx.Response) -> dict[str, Any]: """ Post-processes the response from AniList API. @@ -152,7 +152,16 @@ def _post_process_response(response: httpx.Response) -> dict[str, Any]: dictionary.pop("staff", None) flattened_staff = flatten(staff, "role") + # Remove the HTML tags from description + sanitized_description = sanitize_description(dictionary.get("description")) + + # Also remove them for the related media + for relation in flattened_relations: + description = relation.get("description") + relation["description"] = sanitize_description(description) + # replace the original + dictionary["description"] = sanitized_description dictionary["relations"] = flattened_relations dictionary["studios"] = flattened_studios dictionary["characters"] = flattened_characters @@ -204,7 +213,7 @@ async def search( """ return Media.model_validate( - self._post_process_response( + await self._post_process_response( await self._post_request( title=title, season=season, @@ -240,7 +249,7 @@ async def get(self, id: AniListID) -> Media: """ return Media.model_validate( - self._post_process_response( + await self._post_process_response( await self._post_request( id=id, ) diff --git a/src/pyanilist/_clients/_sync.py b/src/pyanilist/_clients/_sync.py index c35e37a..cd118c2 100644 --- a/src/pyanilist/_clients/_sync.py +++ b/src/pyanilist/_clients/_sync.py @@ -10,7 +10,7 @@ from .._models import Media from .._query import query_string from .._types import AniListID, AniListTitle, AniListYear, HTTPXClientKwargs -from .._utils import flatten, remove_null_fields +from .._utils import flatten, remove_null_fields, sanitize_description class AniList: @@ -151,7 +151,16 @@ def _post_process_response(response: httpx.Response) -> dict[str, Any]: dictionary.pop("staff", None) flattened_staff = flatten(staff, "role") + # Remove the HTML tags from description + sanitized_description = sanitize_description(dictionary.get("description")) + + # Also remove them for the related media + for relation in flattened_relations: + description = relation.get("description") + relation["description"] = sanitize_description(description) + # replace the original + dictionary["description"] = sanitized_description dictionary["relations"] = flattened_relations dictionary["studios"] = flattened_studios dictionary["characters"] = flattened_characters diff --git a/src/pyanilist/_enums.py b/src/pyanilist/_enums.py index c1f09c8..127be97 100644 --- a/src/pyanilist/_enums.py +++ b/src/pyanilist/_enums.py @@ -1,7 +1,29 @@ from ._compat import StrEnum -class MediaType(StrEnum): +class BaseStrEnum(StrEnum): + @property + def title(self) -> str: # type: ignore + """ + Title Cased value + """ + + # These don't get the desired results with .title() + # so we manually map them + exceptions = { + "TV": "TV", + "TV_SHORT": "TV Short", + "OVA": "OVA", + "ONA": "ONA", + } + + if self.value in exceptions: + return exceptions[self.value] + + return self.value.replace("_", " ").title() + + +class MediaType(BaseStrEnum): """The type of the media; anime or manga.""" ANIME = "ANIME" @@ -11,7 +33,7 @@ class MediaType(StrEnum): """Asian comic""" -class MediaFormat(StrEnum): +class MediaFormat(BaseStrEnum): """The format the media was released in.""" TV = "TV" @@ -27,10 +49,17 @@ class MediaFormat(StrEnum): """Special episodes that have been included in DVD/Blu-ray releases, picture dramas, pilots, etc""" OVA = "OVA" - """(Original Video Animation) Anime that have been released directly on DVD/Blu-ray without originally going through a theatrical release or television broadcast""" + """ + (Original Video Animation) + Anime that have been released directly on DVD/Blu-ray without + originally going through a theatrical release or television broadcast + """ ONA = "ONA" - """(Original Net Animation) Anime that have been originally released online or are only available through streaming services.""" + """ + (Original Net Animation) + Anime that have been originally released online or are only available through streaming services + """ MUSIC = "MUSIC" """Short anime released as a music video""" @@ -45,7 +74,7 @@ class MediaFormat(StrEnum): """Manga with just one chapter""" -class MediaStatus(StrEnum): +class MediaStatus(BaseStrEnum): """The current releasing status of the media.""" FINISHED = "FINISHED" @@ -64,7 +93,7 @@ class MediaStatus(StrEnum): """Version 2 only. Is currently paused from releasing and will resume at a later date""" -class MediaSeason(StrEnum): +class MediaSeason(BaseStrEnum): """The season the media was initially released in.""" WINTER = "WINTER" @@ -80,7 +109,7 @@ class MediaSeason(StrEnum): """Months September to November""" -class MediaSource(StrEnum): +class MediaSource(BaseStrEnum): """Source type the media was adapted from.""" ORIGINAL = "ORIGINAL" @@ -129,7 +158,7 @@ class MediaSource(StrEnum): """Version 3 only. Picture book""" -class MediaRelation(StrEnum): +class MediaRelation(BaseStrEnum): """Type of relation media has to its parent.""" ADAPTATION = "ADAPTATION" @@ -172,7 +201,7 @@ class MediaRelation(StrEnum): """Version 2 only.""" -class ExternalLinkType(StrEnum): +class ExternalLinkType(BaseStrEnum): """External Link Type""" INFO = "INFO" @@ -185,7 +214,7 @@ class ExternalLinkType(StrEnum): """Streaming site""" -class CharacterRole(StrEnum): +class CharacterRole(BaseStrEnum): """The role the character plays in the media""" MAIN = "MAIN" @@ -198,7 +227,7 @@ class CharacterRole(StrEnum): """A background character in the media""" -class MediaRankType(StrEnum): +class MediaRankType(BaseStrEnum): """The type of ranking""" RATED = "RATED" diff --git a/src/pyanilist/_utils.py b/src/pyanilist/_utils.py index b753b60..52e0bda 100644 --- a/src/pyanilist/_utils.py +++ b/src/pyanilist/_utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from typing import Any from boltons.iterutils import remap @@ -60,6 +61,18 @@ def flatten(nested: dict[str, list[dict[str, dict[str, Any]]]] | None, key: str return flattened +def sanitize_description(description: str | None) -> str | None: + """ + Despite the HTML parameter not being true in the query, the description + can still have HTML tags in it so we will strip them. + """ + + if description is None: + return description + else: + return re.sub(r"<.*?>", "", description) + + def remove_null_fields(dictionary: dict[str, Any]) -> dict[str, Any]: """ AniList's `null` return is inconsistent for fields that have their own subfields. diff --git a/src/pyanilist/_version.py b/src/pyanilist/_version.py index c38a00b..d991fd6 100644 --- a/src/pyanilist/_version.py +++ b/src/pyanilist/_version.py @@ -1,6 +1,19 @@ +from typing_extensions import NamedTuple + from ._compat import metadata +class VersionInfo(NamedTuple): + """Version tuple based on SemVer""" + + major: int + """Major version number""" + minor: int + """Minor version number""" + patch: int + """Patch version number""" + + def _get_version() -> str: """ Get the version of juicenet diff --git a/tests/test_anilist.py b/tests/test_anilist.py index 9419c4d..b4b1ff5 100644 --- a/tests/test_anilist.py +++ b/tests/test_anilist.py @@ -1,5 +1,6 @@ from pyanilist import ( AniList, + CharacterRole, HttpUrl, MediaFormat, MediaSeason, @@ -13,8 +14,8 @@ def test_anilist_anime() -> None: media = AniList().search("Attack on titan", type=MediaType.ANIME) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2013 - assert media.source == MediaSource.MANGA - assert media.type == MediaType.ANIME + assert media.source is MediaSource.MANGA + assert media.type is MediaType.ANIME assert media.site_url == HttpUrl("https://anilist.co/anime/16498") @@ -22,8 +23,8 @@ def test_anilist_manga() -> None: media = AniList().search("Attack on titan", type=MediaType.MANGA) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2009 - assert media.source == MediaSource.ORIGINAL - assert media.type == MediaType.MANGA + assert media.source is MediaSource.ORIGINAL + assert media.type is MediaType.MANGA assert media.site_url == HttpUrl("https://anilist.co/manga/53390") @@ -33,8 +34,8 @@ def test_anilist_with_some_constraints() -> None: ) assert media.title.romaji == "Violet Evergarden" assert media.start_date.year == 2015 - assert media.source == MediaSource.ORIGINAL - assert media.type == MediaType.MANGA + assert media.source is MediaSource.ORIGINAL + assert media.type is MediaType.MANGA assert media.site_url == HttpUrl("https://anilist.co/manga/97298") @@ -49,8 +50,8 @@ def test_anilist_with_all_constraints() -> None: ) assert media.title.romaji == "Boku no Hero Academia" assert media.start_date.year == 2016 - assert media.source == MediaSource.MANGA - assert media.type == MediaType.ANIME + assert media.source is MediaSource.MANGA + assert media.type is MediaType.ANIME assert media.site_url == HttpUrl("https://anilist.co/anime/21459") @@ -58,6 +59,38 @@ def test_anilist_id() -> None: media = AniList().get(16498) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2013 - assert media.source == MediaSource.MANGA - assert media.type == MediaType.ANIME + assert media.source is MediaSource.MANGA + assert media.type is MediaType.ANIME assert media.site_url == HttpUrl("https://anilist.co/anime/16498") + + +def test_anilist_description() -> None: + media = AniList().get(99426) + assert media.title.english == "A Place Further Than the Universe" + assert media.start_date.year == 2018 + assert media.source is MediaSource.ORIGINAL + assert media.type is MediaType.ANIME + assert media.description == ( + """Mari Tamaki is in her second year of high school and wants to start something. It's then that she meets Shirase, a girl with few friends who's considered weirdo by the rest of the class and nicknamed "Antarctic" since it's all she ever talks about. Unlike her peers, Mari is moved by Shirase's dedication and decides that even though it's unlikely that high school girls will ever go to Antarctica, she's going to try to go with Shirase. + +(Source: Anime News Network)""" + ) + assert media.relations[0].description == ( + """Mari Tamaki is in her second year of high school and wants to start something. It's then that she meets Shirase, a girl with few friends who's considered a weirdo by the rest of the class and nicknamed "Antarctica" since it's all she ever talks about. Unlike her peers, Mari is moved by Shirase's dedication and decides that even though it's unlikely that high school girls will ever go to Antarctica, she's going to try to go with Shirase. + +Note: Includes two extra chapters.""" + ) + assert media.site_url == HttpUrl("https://anilist.co/anime/99426") + + +def test_anilist_characters() -> None: + media = AniList().get(20954) + main_characters = [character.name.full for character in media.characters if character.role is CharacterRole.MAIN] + assert media.title.english == "A Silent Voice" + assert media.start_date.year == 2016 + assert media.source is MediaSource.MANGA + assert media.type is MediaType.ANIME + assert len(main_characters) == 2 + assert "Shouya Ishida" in main_characters + assert "Shouko Nishimiya" in main_characters + assert media.site_url == HttpUrl("https://anilist.co/anime/20954") diff --git a/tests/test_async_anilist.py b/tests/test_async_anilist.py index 8525730..e4c3d74 100644 --- a/tests/test_async_anilist.py +++ b/tests/test_async_anilist.py @@ -1,5 +1,6 @@ from pyanilist import ( AsyncAniList, + CharacterRole, HttpUrl, MediaFormat, MediaSeason, @@ -13,8 +14,8 @@ async def test_anilist_anime() -> None: media = await AsyncAniList().search("Attack on titan", type=MediaType.ANIME) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2013 - assert media.source == MediaSource.MANGA - assert media.type == MediaType.ANIME + assert media.source is MediaSource.MANGA + assert media.type is MediaType.ANIME assert media.site_url == HttpUrl("https://anilist.co/anime/16498") @@ -22,8 +23,8 @@ async def test_anilist_manga() -> None: media = await AsyncAniList().search("Attack on titan", type=MediaType.MANGA) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2009 - assert media.source == MediaSource.ORIGINAL - assert media.type == MediaType.MANGA + assert media.source is MediaSource.ORIGINAL + assert media.type is MediaType.MANGA assert media.site_url == HttpUrl("https://anilist.co/manga/53390") @@ -33,8 +34,8 @@ async def test_anilist_with_some_constraints() -> None: ) assert media.title.romaji == "Violet Evergarden" assert media.start_date.year == 2015 - assert media.source == MediaSource.ORIGINAL - assert media.type == MediaType.MANGA + assert media.source is MediaSource.ORIGINAL + assert media.type is MediaType.MANGA assert media.site_url == HttpUrl("https://anilist.co/manga/97298") @@ -49,8 +50,8 @@ async def test_anilist_with_all_constraints() -> None: ) assert media.title.romaji == "Boku no Hero Academia" assert media.start_date.year == 2016 - assert media.source == MediaSource.MANGA - assert media.type == MediaType.ANIME + assert media.source is MediaSource.MANGA + assert media.type is MediaType.ANIME assert media.site_url == HttpUrl("https://anilist.co/anime/21459") @@ -58,6 +59,38 @@ async def test_anilist_id() -> None: media = await AsyncAniList().get(16498) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2013 - assert media.source == MediaSource.MANGA - assert media.type == MediaType.ANIME + assert media.source is MediaSource.MANGA + assert media.type is MediaType.ANIME assert media.site_url == HttpUrl("https://anilist.co/anime/16498") + + +async def test_anilist_description() -> None: + media = await AsyncAniList().get(99426) + assert media.title.english == "A Place Further Than the Universe" + assert media.start_date.year == 2018 + assert media.source is MediaSource.ORIGINAL + assert media.type is MediaType.ANIME + assert media.description == ( + """Mari Tamaki is in her second year of high school and wants to start something. It's then that she meets Shirase, a girl with few friends who's considered weirdo by the rest of the class and nicknamed "Antarctic" since it's all she ever talks about. Unlike her peers, Mari is moved by Shirase's dedication and decides that even though it's unlikely that high school girls will ever go to Antarctica, she's going to try to go with Shirase. + +(Source: Anime News Network)""" + ) + assert media.relations[0].description == ( + """Mari Tamaki is in her second year of high school and wants to start something. It's then that she meets Shirase, a girl with few friends who's considered a weirdo by the rest of the class and nicknamed "Antarctica" since it's all she ever talks about. Unlike her peers, Mari is moved by Shirase's dedication and decides that even though it's unlikely that high school girls will ever go to Antarctica, she's going to try to go with Shirase. + +Note: Includes two extra chapters.""" + ) + assert media.site_url == HttpUrl("https://anilist.co/anime/99426") + + +async def test_anilist_characters() -> None: + media = await AsyncAniList().get(20954) + main_characters = [character.name.full for character in media.characters if character.role is CharacterRole.MAIN] + assert media.title.english == "A Silent Voice" + assert media.start_date.year == 2016 + assert media.source is MediaSource.MANGA + assert media.type is MediaType.ANIME + assert len(main_characters) == 2 + assert "Shouya Ishida" in main_characters + assert "Shouko Nishimiya" in main_characters + assert media.site_url == HttpUrl("https://anilist.co/anime/20954") diff --git a/tests/test_async_exceptions.py b/tests/test_async_exceptions.py index 0b61ffc..06f9f36 100644 --- a/tests/test_async_exceptions.py +++ b/tests/test_async_exceptions.py @@ -6,7 +6,7 @@ ValidationError, ) -anilist = AsyncAniList(retries=1) +anilist = AsyncAniList(retries=0) async def test_anilist_title_doesnt_exist() -> None: diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 1473071..4f9e624 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -6,7 +6,7 @@ ValidationError, ) -anilist = AniList(retries=1) +anilist = AniList(retries=0) def test_anilist_title_doesnt_exist() -> None: