From 5696a56e282d36511203d67bdf059e895751e998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Wed, 27 Nov 2024 04:41:34 +0000 Subject: [PATCH] conform to new ruff config --- examples/create_manga.py | 17 +- examples/download_manga.py | 3 +- examples/my_feed.py | 9 +- examples/upload_a_chapter.py | 22 ++- examples/use_logging.py | 11 +- examples/using_query_params.py | 3 +- hondana/__main__.py | 5 +- hondana/artist.py | 54 +++--- hondana/author.py | 54 +++--- hondana/chapter.py | 179 ++++++++++++-------- hondana/client.py | 206 +++++++++++++++-------- hondana/collections.py | 110 ++++++++----- hondana/cover.py | 36 ++-- hondana/custom_list.py | 23 ++- hondana/enums.py | 20 +-- hondana/errors.py | 30 ++-- hondana/forums.py | 17 +- hondana/http.py | 264 +++++++++++++++++++++--------- hondana/legacy.py | 17 +- hondana/manga.py | 184 ++++++++++++--------- hondana/query.py | 84 +++++----- hondana/relationship.py | 4 +- hondana/report.py | 64 ++++++-- hondana/scanlator_group.py | 82 ++++++---- hondana/tags.py | 23 ++- hondana/types_/artist.py | 4 +- hondana/types_/auth.py | 4 +- hondana/types_/author.py | 2 +- hondana/types_/chapter.py | 10 +- hondana/types_/cover.py | 4 +- hondana/types_/custom_list.py | 2 +- hondana/types_/errors.py | 2 +- hondana/types_/legacy.py | 4 +- hondana/types_/manga.py | 25 +-- hondana/types_/relationship.py | 2 +- hondana/types_/report.py | 5 +- hondana/types_/scanlator_group.py | 4 +- hondana/types_/settings.py | 2 - hondana/types_/statistics.py | 12 +- hondana/types_/tags.py | 2 +- hondana/types_/upload.py | 10 +- hondana/types_/user.py | 4 +- hondana/user.py | 52 ++++-- hondana/utils.py | 137 ++++++++++------ tests/test_chapter.py | 2 +- tests/test_collection.py | 16 +- tests/test_manga.py | 17 +- tests/test_scanlator_group.py | 2 +- tests/test_tags.py | 27 ++- 49 files changed, 1179 insertions(+), 693 deletions(-) diff --git a/examples/create_manga.py b/examples/create_manga.py index 80999f6..1f75039 100644 --- a/examples/create_manga.py +++ b/examples/create_manga.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import pathlib from typing import TYPE_CHECKING import hondana @@ -26,18 +27,23 @@ async def main() -> None: # Create the manga with them: draft_manga = await client.create_manga( - title=manga_title, original_language=original_language, status=status, content_rating=content_rating + title=manga_title, + original_language=original_language, + status=status, + content_rating=content_rating, ) # This manga is now created in "draft" state. This is outlined more here: # https://api.mangadex.org/docs.html#section/Manga-Creation # tl;dr it's to remove the spam creations and to ensure there's a cover on the manga... so let's do that now. - with open("our_cover.png", "rb") as file: # noqa: PTH123 - cover = file.read() + cover = pathlib.Path("our_cover.png").read_bytes() # When we upload a cover, we need to attribute it to a manga, so lets use the draft one we created. uploaded_cover = await draft_manga.upload_cover( - cover=cover, volume=None, description="My awesome cover", locale="en" + cover=cover, + volume=None, + description="My awesome cover", + locale="en", ) print(uploaded_cover) @@ -45,7 +51,8 @@ async def main() -> None: submitted_manga = await draft_manga.submit_draft(version=1) print(submitted_manga) - # NOTE: Something to note is that the version of draft MUST match the version of submitted manga during the approval stage. + # NOTE: Something to note is that the version of draft MUST match the version of + # submitted manga during the approval stage. # we don't log out as exiting the context manager provides a clean exit. diff --git a/examples/download_manga.py b/examples/download_manga.py index 2dbd6a8..d01c5af 100644 --- a/examples/download_manga.py +++ b/examples/download_manga.py @@ -16,7 +16,8 @@ async def main() -> None: feed = await manga.feed(limit=500, offset=0, translated_language=["en"]) # This is how you recursively download the chapters. - # The string in the `.download()` call is the path to save all the chapters in. It will recursively create it, if needed. + # The string in the `.download()` call is the path to save all the chapters in. + # It will recursively create it, if needed. for chapter in feed.chapters: await chapter.download(f"{manga.title}/{chapter.chapter}") diff --git a/examples/my_feed.py b/examples/my_feed.py index 97b382e..885e39f 100644 --- a/examples/my_feed.py +++ b/examples/my_feed.py @@ -16,12 +16,17 @@ async def main() -> None: fifteen_minutes_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta(minutes=15) # And let's order the responses by created at descending - # we also coerce the type here to prevent typechecker issues. This isn't needed but if you use a typechecker this is good to do. + # we also coerce the type here to prevent typechecker issues. + # This isn't needed but if you use a typechecker this is good to do. order = FeedOrderQuery(created_at=Order.descending) # `feed` will return a ChapterFeed instance. This just has the response info and list of chapters. feed = await client.get_my_feed( - limit=20, offset=0, translated_language=["en"], created_at_since=fifteen_minutes_ago, order=order + limit=20, + offset=0, + translated_language=["en"], + created_at_since=fifteen_minutes_ago, + order=order, ) # Let's view the responses. diff --git a/examples/upload_a_chapter.py b/examples/upload_a_chapter.py index 0e29dbc..c0a9a2c 100644 --- a/examples/upload_a_chapter.py +++ b/examples/upload_a_chapter.py @@ -38,7 +38,8 @@ async def main() -> None: ) as upload_session: # let's open up some files and use their paths... files = [*pathlib.Path("./to_upload").iterdir()] - # the above is a quick and easy method to create a list of pathlib.Path objects based on the `./to_upload` directory. + # the above is a quick and easy method to create a list of pathlib.Path objects + # based on the `./to_upload` directory. # First we pass the list of paths, adhering to the earlier note. # this method does sort them (alphabetically) by default, you can toggle this behaviour by passing `sort=False` @@ -46,13 +47,16 @@ async def main() -> None: data = await upload_session.upload_images(files) if data.has_failures: print( - data.errored_files - ) # this means the upload request has one or more errors, you may wish to restart the session once fixing the error or other steps. + data.errored_files, + ) + # this means the upload request has one or more errors, + # you may wish to restart the session once fixing the error or other steps. # Then we choose to commit that data, which returns a valid ``hondana.Chapter`` instance. chapter = await upload_session.commit() - ## You can also choose not to commit manually, exiting this context manager will commit for you, and discard the returned chapter data. + # You can also choose not to commit manually, exiting this context manager will + # commit for you, and discard the returned chapter data. async def alternative_main() -> None: @@ -65,7 +69,7 @@ async def alternative_main() -> None: scanlator_groups = ["..."] # This will create and return an instance of ``hondana.ChapterUpload`` - ## You can also use a manga ID, or a ``hondana.Manga`` instance as the first parameter + # You can also use a manga ID, or a ``hondana.Manga`` instance as the first parameter upload_session = client.upload_session( "...", volume=volume, @@ -84,10 +88,12 @@ async def alternative_main() -> None: data = await upload_session.upload_images(files) if data.has_failures: print( - data.errored_files - ) # this means the upload request has one or more errors, you may wish to restart the session once fixing the error or other steps. + data.errored_files, + ) + # this means the upload request has one or more errors + # you may wish to restart the session once fixing the error or other steps. - ## NOTE: You **MUST** commit when not using the context manager. + # NOTE: You **MUST** commit when not using the context manager. chapter = await upload_session.commit() diff --git a/examples/use_logging.py b/examples/use_logging.py index 2808aaa..5cb1c5b 100644 --- a/examples/use_logging.py +++ b/examples/use_logging.py @@ -1,5 +1,6 @@ -## Preface note: DEBUG logging on `hondana` (specifically it's `http` module) will result in your token as well as other information that could be sensitive -## showing to the CLI. Be careful if sharing these logs. +# Preface note: DEBUG logging on `hondana` (specifically it's `http` module) +# will result in your token as well as other information that could be sensitive +# showing to the CLI. Be careful if sharing these logs. from __future__ import annotations @@ -32,7 +33,11 @@ async def main() -> None: # `feed` will return a `hondana.ChapterFeed` instance. feed = await client.get_my_feed( - limit=20, offset=0, translated_language=["en"], created_at_since=fifteen_minutes_ago, order=order + limit=20, + offset=0, + translated_language=["en"], + created_at_since=fifteen_minutes_ago, + order=order, ) # Let's view the response repr. diff --git a/examples/using_query_params.py b/examples/using_query_params.py index 66caa57..de5aef9 100644 --- a/examples/using_query_params.py +++ b/examples/using_query_params.py @@ -12,7 +12,8 @@ async def main() -> None: collection = await client.manga_list(includes=manga_list_includes) print(len(collection.manga)) - # Since our default is all possible expansions, you can just call an empty constructor, and it will populate accordingly. + # Since our default is all possible expansions, + # you can just call an empty constructor, and it will populate accordingly. chapter_list_includes = hondana.query.ChapterIncludes() # We also have the `all()` classmethod should you wish to use that. diff --git a/hondana/__main__.py b/hondana/__main__.py index 4d8a312..e6ee19d 100644 --- a/hondana/__main__.py +++ b/hondana/__main__.py @@ -38,8 +38,7 @@ def show_version() -> None: entries: list[str] = [ f"- Python v{version_info.major}.{version_info.minor}.{version_info.micro}-{version_info.releaselevel}", - f"- Hondana v{md_version_info.major}.{md_version_info.minor}." - f"{md_version_info.micro}-{md_version_info.releaselevel}", + f"- Hondana v{md_version_info.major}.{md_version_info.minor}.{md_version_info.micro}-{md_version_info.releaselevel}", ] if md_version_info.releaselevel != "final": @@ -54,7 +53,7 @@ def show_version() -> None: uname = platform.uname() entries.append(f"- System Info: {uname.system} {uname.release} {uname.version}") - print("\n".join(entries)) + print("\n".join(entries)) # noqa: T201 # this is intended def parse_args() -> tuple[argparse.ArgumentParser, argparse.Namespace]: diff --git a/hondana/artist.py b/hondana/artist.py index 9bc468e..e5a9e01 100644 --- a/hondana/artist.py +++ b/hondana/artist.py @@ -86,33 +86,33 @@ class Artist(AuthorArtistTag): """ __slots__ = ( - "_http", - "_data", + "__manga", "_attributes", + "_biography", + "_created_at", + "_data", + "_http", + "_manga_relationships", "_relationships", + "_updated_at", + "booth", + "fan_box", + "fantia", "id", - "name", "image_url", - "twitter", - "pixiv", "melon_book", - "fan_box", - "booth", + "name", + "namicomi", + "naver", "nico_video", + "pixiv", "skeb", - "fantia", "tumblr", - "youtube", - "weibo", - "naver", - "namicomi", - "website", + "twitter", "version", - "_biography", - "_created_at", - "_updated_at", - "_manga_relationships", - "__manga", + "website", + "weibo", + "youtube", ) def __init__(self, http: HTTPClient, payload: ArtistResponse) -> None: @@ -142,7 +142,8 @@ def __init__(self, http: HTTPClient, payload: ArtistResponse) -> None: self._created_at: str = self._attributes["createdAt"] self._updated_at: str = self._attributes["updatedAt"] self._manga_relationships: list[MangaResponse] = RelationshipResolver["MangaResponse"]( - relationships, "manga" + relationships, + "manga", ).resolve(with_fallback=False, remove_empty=True) self.__manga: list[Manga] | None = None @@ -152,6 +153,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.name + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, AuthorArtistTag) and self.id == other.id @@ -166,7 +170,7 @@ def biography(self) -> str | None: This property will attempt to get the ``"en"`` key first, and fallback to the first key in the object. """ if not self._biography: - return + return None biography = self._biography.get("en") if biography is None: @@ -189,7 +193,7 @@ def localised_biography(self, language: LanguageCode) -> str | None: The artist's biography in the specified language. """ if not self._biography: - return + return None return self._biography.get(language) @@ -244,7 +248,7 @@ def manga(self) -> list[Manga] | None: if not self._manga_relationships: return None - from .manga import Manga + from .manga import Manga # noqa: PLC0415 # cyclic import cheat formatted = [Manga(self._http, item) for item in self._manga_relationships] @@ -273,19 +277,19 @@ async def get_manga(self) -> list[Manga] | None: return self.manga if not self._manga_relationships: - return + return None ids = [r["id"] for r in self._manga_relationships] formatted: list[Manga] = [] - from .manga import Manga + from .manga import Manga # noqa: PLC0415 # cyclic import cheat for manga_id in ids: data = await self._http.get_manga(manga_id, includes=MangaIncludes()) formatted.append(Manga(self._http, data["data"])) if not formatted: - return + return None self.__manga = formatted return self.__manga diff --git a/hondana/author.py b/hondana/author.py index a80e568..26e8e36 100644 --- a/hondana/author.py +++ b/hondana/author.py @@ -85,32 +85,32 @@ class Author(AuthorArtistTag): """ __slots__ = ( - "_http", - "_data", + "__manga", "_attributes", + "_biography", + "_created_at", + "_data", + "_http", + "_manga_relationships", + "_updated_at", + "booth", + "fan_box", + "fantia", "id", - "name", "image_url", - "twitter", - "pixiv", "melon_book", - "fan_box", - "booth", + "name", + "namicomi", + "naver", "nico_video", + "pixiv", "skeb", - "fantia", "tumblr", - "youtube", - "weibo", - "naver", - "namicomi", - "website", + "twitter", "version", - "_biography", - "_created_at", - "_updated_at", - "_manga_relationships", - "__manga", + "website", + "weibo", + "youtube", ) def __init__(self, http: HTTPClient, payload: AuthorResponse) -> None: @@ -140,7 +140,8 @@ def __init__(self, http: HTTPClient, payload: AuthorResponse) -> None: self._created_at: str = self._attributes["createdAt"] self._updated_at: str = self._attributes["updatedAt"] self._manga_relationships: list[MangaResponse] = RelationshipResolver(relationships, "manga").resolve( - with_fallback=False, remove_empty=True + with_fallback=False, + remove_empty=True, ) self.__manga: list[Manga] | None = None @@ -150,6 +151,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.name + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, AuthorArtistTag) and self.id == other.id @@ -164,7 +168,7 @@ def biography(self) -> str | None: This property will attempt to get the ``"en"`` key first, and fallback to the first key in the object. """ if not self._biography: - return + return None biography = self._biography.get("en") if biography is None: @@ -187,7 +191,7 @@ def localised_biography(self, language: LanguageCode) -> str | None: The author's biography in the specified language. """ if not self._biography: - return + return None return self._biography.get(language) @@ -242,7 +246,7 @@ def manga(self) -> list[Manga] | None: if not self._manga_relationships: return None - from .manga import Manga + from .manga import Manga # noqa: PLC0415 # cyclic import cheat formatted = [Manga(self._http, item) for item in self._manga_relationships] @@ -271,19 +275,19 @@ async def get_manga(self) -> list[Manga] | None: return self.manga if not self._manga_relationships: - return + return None ids = [r["id"] for r in self._manga_relationships] formatted: list[Manga] = [] - from .manga import Manga + from .manga import Manga # noqa: PLC0415 # cyclic import cheat for manga_id in ids: data = await self._http.get_manga(manga_id, includes=MangaIncludes()) formatted.append(Manga(self._http, data["data"])) if not formatted: - return + return None self.__manga = formatted return self.__manga diff --git a/hondana/chapter.py b/hondana/chapter.py index dddc000..174e004 100644 --- a/hondana/chapter.py +++ b/hondana/chapter.py @@ -74,10 +74,10 @@ __all__ = ( "Chapter", "ChapterAtHome", - "UploadData", + "ChapterStatistics", "ChapterUpload", "PreviouslyReadChapter", - "ChapterStatistics", + "UploadData", ) LOGGER: logging.Logger = logging.getLogger(__name__) @@ -108,35 +108,37 @@ class Chapter: .. warning:: - THe :attr:`manga` and :meth:`get_parent_manga` will both return a :class:`~hondana.Manga` with minimal data if this Chapter was requested as part of a feed. - The reason is that the ``Chapter.relationships["manga"].relationships`` key is null the API response during feed requests to avoid potential recursive data. + THe :attr:`manga` and :meth:`get_parent_manga` will both return a :class:`~hondana.Manga` + with minimal data if this Chapter was requested as part of a feed. + The reason is that the ``Chapter.relationships["manga"].relationships`` key is null + the API response during feed requests to avoid potential recursive data. """ __slots__ = ( - "_http", - "_data", + "__parent", + "__scanlator_groups", + "__uploader", + "_at_home_url", "_attributes", - "id", - "title", - "volume", - "chapter", - "pages", - "translated_language", - "external_url", - "version", "_created_at", - "_updated_at", + "_cs_relationships", + "_data", + "_http", + "_manga_relationship", "_published_at", "_readable_at", - "_manga_relationship", "_scanlator_group_relationships", - "_uploader_relationship", - "_at_home_url", "_stats", - "__uploader", - "__parent", - "__scanlator_groups", - "_cs_relationships", + "_updated_at", + "_uploader_relationship", + "chapter", + "external_url", + "id", + "pages", + "title", + "translated_language", + "version", + "volume", ) def __init__(self, http: HTTPClient, payload: ChapterResponse) -> None: @@ -158,10 +160,12 @@ def __init__(self, http: HTTPClient, payload: ChapterResponse) -> None: self._readable_at = self._attributes["readableAt"] self._stats: ChapterStatistics | None = None self._manga_relationship: MangaResponse = RelationshipResolver(relationships, "manga").resolve( - with_fallback=False, remove_empty=True + with_fallback=False, + remove_empty=True, )[0] self._scanlator_group_relationships: list[ScanlationGroupResponse] = RelationshipResolver( - relationships, "scanlation_group" + relationships, + "scanlation_group", ).resolve(with_fallback=False, remove_empty=True) self._uploader_relationship: UserResponse = RelationshipResolver(relationships, "user").resolve(remove_empty=True)[0] self._at_home_url: str | None = None @@ -175,6 +179,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.title or f"No title for this chapter, with ID: {self.id!r}" + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, Chapter) and self.id == other.id @@ -213,7 +220,7 @@ def to_dict(self) -> dict[str, Any]: fmt[name] = getattr(self, name) return fmt - async def get_at_home(self, ssl: bool = True) -> ChapterAtHome: + async def get_at_home(self, *, ssl: bool = True) -> ChapterAtHome: """|coro| This method returns the @Home data for this chapter. @@ -300,7 +307,7 @@ def manga(self) -> Manga | None: return self.__parent if not self._manga_relationship: - return + return None manga = Manga(self._http, self._manga_relationship) self.__parent = manga @@ -340,12 +347,12 @@ def scanlator_groups(self) -> list[ScanlatorGroup] | None: return self.__scanlator_groups if not self._scanlator_group_relationships: - return + return None fmt = [ScanlatorGroup(self._http, payload) for payload in self._scanlator_group_relationships] if not fmt: - return + return None self.__scanlator_groups = fmt return self.__scanlator_groups @@ -367,7 +374,7 @@ def uploader(self) -> User | None: return self.__uploader if not self._uploader_relationship: - return + return None self.__uploader = User(self._http, self._uploader_relationship) return self.__uploader @@ -386,10 +393,10 @@ async def get_parent_manga(self) -> Manga | None: return self.manga if not self._manga_relationship: - return + return None if self.manga_id is None: - return + return None manga = await self._http.get_manga(self.manga_id, includes=MangaIncludes()) @@ -415,7 +422,7 @@ async def get_scanlator_groups(self) -> list[ScanlatorGroup] | None: return self.scanlator_groups if not self._scanlator_group_relationships: - return + return None ids = [item["id"] for item in self._scanlator_group_relationships] @@ -512,7 +519,7 @@ async def delete(self) -> None: await self._http.delete_chapter(self.id) @require_authentication - async def mark_as_read(self, update_history: bool = True) -> None: + async def mark_as_read(self, *, update_history: bool = True) -> None: """|coro| This method will mark the current chapter as read for the current authenticated user in the MangaDex API. @@ -524,11 +531,14 @@ async def mark_as_read(self, update_history: bool = True) -> None: """ if self.manga_id: await self._http.manga_read_markers_batch( - self.manga_id, update_history=update_history, read_chapters=[self.id], unread_chapters=None + self.manga_id, + update_history=update_history, + read_chapters=[self.id], + unread_chapters=None, ) @require_authentication - async def mark_as_unread(self, update_history: bool = True) -> None: + async def mark_as_unread(self, *, update_history: bool = True) -> None: """|coro| This method will mark the current chapter as unread for the current authenticated user in the MangaDex API. @@ -540,7 +550,10 @@ async def mark_as_unread(self, update_history: bool = True) -> None: """ if self.manga_id: await self._http.manga_read_markers_batch( - self.manga_id, update_history=update_history, read_chapters=None, unread_chapters=[self.id] + self.manga_id, + update_history=update_history, + read_chapters=None, + unread_chapters=[self.id], ) @require_authentication @@ -562,14 +575,20 @@ async def get_statistics(self) -> ChapterStatistics | None: return self.stats async def _pages( - self, *, start: int, end: int | None, data_saver: bool, ssl: bool, report: bool + self, + *, + start: int, + end: int | None, + data_saver: bool, + ssl: bool, + report: bool, ) -> AsyncGenerator[tuple[bytes, str], None]: at_home_data = await self.get_at_home(ssl=ssl) self._at_home_url = at_home_data.base_url _pages = at_home_data.data_saver if data_saver else at_home_data.data _actual_pages = _pages[start:] if end is None else _pages[start:end] - for i, url in enumerate(_actual_pages, start=1): + for i, url in enumerate(_actual_pages, start=1): # noqa: B007 # it gets used in the outer scope route = Route( "GET", f"/{'data-saver' if data_saver else 'data'}/{at_home_data.hash}/{url}", @@ -632,7 +651,8 @@ async def download( data_saver: :class:`bool` Whether to use the smaller (and poorer quality) images, if you are on a data budget. Defaults to ``False``. ssl: :class:`bool` - Whether to request an SSL @Home link from MangaDex, this guarantees https as compared to potentially getting an HTTP url. + Whether to request an SSL @Home link from MangaDex, this guarantees https as compared + to potentially getting a HTTP url. Defaults to ``False``. report: :class:`bool` Whether to report success or failures to MangaDex per page download. @@ -646,7 +666,11 @@ async def download( idx = 1 async for page_data, page_ext in self._pages( - start=start_page, end=end_page, data_saver=data_saver, ssl=ssl, report=report + start=start_page, + end=end_page, + data_saver=data_saver, + ssl=ssl, + report=report, ): download_path = path_ / f"{idx}.{page_ext}" with download_path.open("wb") as f: @@ -714,12 +738,12 @@ class ChapterAtHome: """ __slots__ = ( - "_http", "_data", + "_http", "base_url", - "hash", "data", "data_saver", + "hash", ) def __init__(self, http: HTTPClient, payload: GetAtHomeResponse) -> None: @@ -734,6 +758,9 @@ def __init__(self, http: HTTPClient, payload: GetAtHomeResponse) -> None: def __repr__(self) -> str: return f"" + def __hash__(self) -> int: + return hash(self.hash) + def __eq__(self, other: object) -> bool: return isinstance(other, ChapterAtHome) and self.hash == other.hash @@ -753,11 +780,11 @@ class UploadData: """ __slots__ = ( - "succeeded", + "_cs_errored_files", + "_filenames", "errors", "has_failures", - "_filenames", - "_cs_errored_files", + "succeeded", ) def __init__(self, succeeded: list[UploadedChapterResponse], errors: list[ErrorType], /, *, filenames: set[str]) -> None: @@ -783,8 +810,7 @@ def errored_files(self) -> set[str]: """ _succeeded: set[str] = set() for item in self.succeeded: - for data in item["data"]: - _succeeded.add(data["attributes"]["originalFileName"]) + _succeeded.update(data["attributes"]["originalFileName"] for data in item["data"]) return self._filenames ^ _succeeded @@ -832,22 +858,22 @@ class ChapterUpload: """ __slots__ = ( + "__committed", "_http", - "manga", - "volume", + "_uploaded_filenames", "chapter", "chapter_to_edit", - "title", - "translated_language", "external_url", + "manga", "publish_at", "scanlator_groups", - "uploaded", + "title", + "translated_language", "upload_errors", "upload_session_id", + "uploaded", "version", - "_uploaded_filenames", - "__committed", + "volume", ) def __init__( @@ -868,10 +894,12 @@ def __init__( version: int | None = None, ) -> None: if len(scanlator_groups) > 10: - raise ValueError("You can only attribute up to 10 scanlator groups per upload.") + msg = "You can only attribute up to 10 scanlator groups per upload." + raise ValueError(msg) if chapter_to_edit and not version: - raise ValueError("You must specify a version if you are editing a chapter.") + msg = "You must specify a version if you are editing a chapter." + raise ValueError(msg) self._http: HTTPClient = http self.manga: Manga | str = manga @@ -900,8 +928,9 @@ async def _check_for_session(self) -> None: except NotFound: LOGGER.info("No upload session found, continuing.") else: + msg = f"You already have an existing session, please terminate it: {data['data']['id']}" raise UploadInProgress( - f"You already have an existing session, please terminate it: {data['data']['id']}", + msg, session_id=data["data"]["id"], ) @@ -919,10 +948,16 @@ async def open_session(self) -> BeginChapterUploadResponse: if self.chapter_to_edit is not None: chapter_id = self.chapter_to_edit.id if isinstance(self.chapter_to_edit, Chapter) else self.chapter_to_edit return await self._http.open_upload_session( - manga_id, scanlator_groups=self.scanlator_groups, chapter_id=chapter_id, version=self.version + manga_id, + scanlator_groups=self.scanlator_groups, + chapter_id=chapter_id, + version=self.version, ) return await self._http.open_upload_session( - manga_id, scanlator_groups=self.scanlator_groups, chapter_id=None, version=None + manga_id, + scanlator_groups=self.scanlator_groups, + chapter_id=None, + version=None, ) @require_authentication @@ -964,7 +999,8 @@ async def upload_images( .. note:: - If ``sorting_key`` is provided, then it must be a callable that takes a single parameter of ``pathlib.Path`` and returns a sortable value. + If ``sorting_key`` is provided, then it must be a callable that takes a single parameter of + ``pathlib.Path`` and returns a sortable value. This means that the return value of ``sorting_key`` must be richly comparable, with ``__lt__`` and ``__gt__``. """ route = Route("POST", "/upload/{session_id}", session_id=self.upload_session_id, authenticate=True) @@ -978,7 +1014,7 @@ async def upload_images( outer_idx = 1 for batch in chunks: form = aiohttp.FormData() - for _, item in enumerate(batch, start=outer_idx): + for _, item in enumerate(batch, start=outer_idx): # noqa: FURB148 # we use this for the passable enumeration with item.open("rb") as f: data = f.read() @@ -996,9 +1032,7 @@ async def upload_images( success.append(response) - data = UploadData(success, self.upload_errors, filenames=self._uploaded_filenames) - - return data + return UploadData(success, self.upload_errors, filenames=self._uploaded_filenames) @require_authentication async def delete_images(self, image_ids: list[str], /) -> None: @@ -1095,7 +1129,10 @@ async def __aenter__(self: Self) -> Self: return self async def __aexit__( - self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, ) -> None: if self.__committed is False: await self.commit() @@ -1116,11 +1153,11 @@ class PreviouslyReadChapter: def __init__(self, http: HTTPClient, data: tuple[str, str]) -> None: self._http = http self.chapter_id: str = data[0] - dt = datetime.datetime.strptime(data[1], "%Y-%m-%dT%H:%M:%S.%fZ") + dt = datetime.datetime.strptime(data[1], "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=datetime.UTC) dt.replace(tzinfo=datetime.UTC) self.read_date: datetime.datetime = dt - async def fetch_chapter(self, *, includes: ChapterIncludes = ChapterIncludes()) -> Chapter: + async def fetch_chapter(self, *, includes: ChapterIncludes = MISSING) -> Chapter: """|coro| This method will fetch the chapter from the ID in the read payload. @@ -1129,7 +1166,7 @@ async def fetch_chapter(self, *, includes: ChapterIncludes = ChapterIncludes()) --------- :class:`~hondana.Chapter` """ - data = await self._http.get_chapter(self.chapter_id, includes=includes) + data = await self._http.get_chapter(self.chapter_id, includes=includes or ChapterIncludes()) return Chapter(self._http, data["data"]) @@ -1144,10 +1181,10 @@ class ChapterStatistics: """ __slots__ = ( - "_http", - "_data", "_comments", "_cs_comments", + "_data", + "_http", "parent_id", ) @@ -1171,3 +1208,5 @@ def comments(self) -> ChapterComments | None: """ if self._comments: return ChapterComments(self._http, self._comments, self.parent_id) + + return None diff --git a/hondana/client.py b/hondana/client.py index 66deea1..d2af235 100644 --- a/hondana/client.py +++ b/hondana/client.py @@ -27,6 +27,7 @@ import datetime import json import logging +import operator import pathlib from typing import TYPE_CHECKING, Any, TypeVar, overload @@ -133,7 +134,7 @@ class Client: The Client will work without authentication, but all authenticated endpoints will fail before attempting a request. """ - __slots__ = "_http" + __slots__ = ("_http",) @overload def __init__(self) -> None: ... @@ -184,12 +185,13 @@ async def __aenter__(self) -> Self: return self - async def __aexit__(self, type_: type[BE], value: BE, traceback: TracebackType) -> None: + async def __aexit__(self, type_: type[BE] | None, value: BE, traceback: TracebackType) -> None: # noqa: PYI036 # not expanding the typevar await self.close() async def login(self) -> None: if not self._http._authenticated: # pyright: ignore[reportPrivateUsage] # sanity reasons - raise RuntimeError("Cannot login as no OAuth2 credentials are set.") + msg = "Cannot login as no OAuth2 credentials are set." + raise RuntimeError(msg) await self._http.get_token() @@ -240,7 +242,7 @@ async def update_tags(self) -> dict[str, str]: tags = await self.get_tags() pre_fmt = {tag.name: tag.id for tag in tags} - fmt = dict(sorted(pre_fmt.items(), key=lambda t: t[0])) + fmt = dict(sorted(pre_fmt.items(), key=operator.itemgetter(0))) path = _PROJECT_DIR.parent / "extras" / "tags.json" with path.open("w") as fp: @@ -324,7 +326,7 @@ async def get_my_feed( updated_at_since: datetime.datetime | None = None, published_at_since: datetime.datetime | None = None, order: FeedOrderQuery | None = None, - includes: ChapterIncludes | None = ChapterIncludes(), + includes: ChapterIncludes | None = None, include_empty_pages: bool | None = None, include_future_publish_at: bool | None = None, include_external_url: bool | None = None, @@ -406,7 +408,7 @@ async def get_my_feed( updated_at_since=updated_at_since, published_at_since=published_at_since, order=order, - includes=includes, + includes=includes or ChapterIncludes(), include_empty_pages=include_empty_pages, include_future_publish_at=include_future_publish_at, include_external_url=include_external_url, @@ -445,7 +447,7 @@ async def manga_list( created_at_since: datetime.datetime | None = None, updated_at_since: datetime.datetime | None = None, order: MangaListOrderQuery | None = None, - includes: MangaIncludes | None = MangaIncludes(), + includes: MangaIncludes | None = None, has_available_chapters: bool | None = None, group: str | None = None, ) -> MangaCollection: @@ -470,7 +472,8 @@ async def manga_list( artists: Optional[List[:class:`str`]] The artist(s) UUIDs to include in the search. year: Optional[:class:`int`] - The release year of the manga to include in the search. Allows passing of ``None`` to search for manga with no year specified. + The release year of the manga to include in the search. Allows passing of ``None`` to + search for manga with no year specified. included_tags: Optional[:class:`QueryTags`] An instance of :class:`hondana.QueryTags` to include in the search. excluded_tags: Optional[:class:`QueryTags`] @@ -547,7 +550,7 @@ async def manga_list( created_at_since=created_at_since, updated_at_since=updated_at_since, order=order, - includes=includes, + includes=includes or MangaIncludes(), has_available_chapters=has_available_chapters, group=group, ) @@ -686,10 +689,12 @@ async def get_manga_volumes_and_chapters( The raw payload from mangadex. There is no guarantee of the keys here. """ return await self._http.get_manga_volumes_and_chapters( - manga_id=manga_id, translated_language=translated_language, groups=groups + manga_id=manga_id, + translated_language=translated_language, + groups=groups, ) - async def get_manga(self, manga_id: str, /, *, includes: MangaIncludes | None = MangaIncludes()) -> Manga: + async def get_manga(self, manga_id: str, /, *, includes: MangaIncludes | None = None) -> Manga: """|coro| The method will fetch a Manga from the MangaDex API. @@ -716,7 +721,7 @@ async def get_manga(self, manga_id: str, /, *, includes: MangaIncludes | None = .. versionadded:: 2.0.11 """ - data = await self._http.get_manga(manga_id, includes=includes) + data = await self._http.get_manga(manga_id, includes=includes or MangaIncludes()) return Manga(self._http, data["data"]) @@ -845,7 +850,7 @@ async def delete_manga(self, manga_id: str, /) -> None: """ await self._http.delete_manga(manga_id) - ## TODO + # TODO @require_authentication async def unfollow_manga(self, manga_id: str, /) -> None: """|coro| @@ -866,10 +871,15 @@ async def unfollow_manga(self, manga_id: str, /) -> None: """ await self._http.unfollow_manga(manga_id) - ## TODO + # TODO @require_authentication async def follow_manga( - self, manga_id: str, /, *, set_status: bool = True, status: ReadingStatus = ReadingStatus.reading + self, + manga_id: str, + /, + *, + set_status: bool = True, + status: ReadingStatus = ReadingStatus.reading, ) -> None: """|coro| @@ -881,7 +891,8 @@ async def follow_manga( The UUID of the manga to follow. set_status: :class:`bool` Whether to set the reading status of the manga you follow. - Due to the current MangaDex infrastructure, not setting a status will cause the manga to not show up in your lists. + Due to the current MangaDex infrastructure, not setting a status will cause + the manga to not show up in your lists. Defaults to ``True`` status: :class:`~hondana.ReadingStatus` The status to apply to the newly followed manga. @@ -916,7 +927,7 @@ async def manga_feed( updated_at_since: datetime.datetime | None = None, published_at_since: datetime.datetime | None = None, order: FeedOrderQuery | None = None, - includes: ChapterIncludes | None = ChapterIncludes(), + includes: ChapterIncludes | None = None, include_empty_pages: bool | None = None, include_future_publish_at: bool | None = None, include_external_url: bool | None = None, @@ -998,7 +1009,7 @@ async def manga_feed( updated_at_since=updated_at_since, published_at_since=published_at_since, order=order, - includes=includes, + includes=includes or ChapterIncludes(), include_empty_pages=include_empty_pages, include_future_publish_at=include_future_publish_at, include_external_url=include_external_url, @@ -1014,7 +1025,9 @@ async def manga_feed( @require_authentication async def manga_read_markers( - self, *, manga_ids: list[str] + self, + *, + manga_ids: list[str], ) -> manga.MangaReadMarkersResponse | manga.MangaGroupedReadMarkersResponse: """|coro| @@ -1028,7 +1041,7 @@ async def manga_read_markers( Returns -------- Union[:class:`~hondana.types_.manga.MangaReadMarkersResponse`, :class:`~hondana.types_.manga.MangaGroupedReadMarkersResponse`] - """ + """ # noqa: E501 # required for formatting if len(manga_ids) == 1: return await self._http.manga_read_markers(manga_ids, grouped=False) return await self._http.manga_read_markers(manga_ids, grouped=True) @@ -1066,15 +1079,19 @@ async def batch_update_manga_read_markers( """ if read_chapters or unread_chapters: await self._http.manga_read_markers_batch( - manga_id, update_history=update_history, read_chapters=read_chapters, unread_chapters=unread_chapters + manga_id, + update_history=update_history, + read_chapters=read_chapters, + unread_chapters=unread_chapters, ) - else: - raise TypeError("You must provide either `read_chapters` and/or `unread_chapters` to this method.") + return + msg = "You must provide either `read_chapters` and/or `unread_chapters` to this method." + raise TypeError(msg) async def get_random_manga( self, *, - includes: MangaIncludes | None = MangaIncludes(), + includes: MangaIncludes | None = None, content_rating: list[ContentRating] | None = None, included_tags: QueryTags | None = None, excluded_tags: QueryTags | None = None, @@ -1101,7 +1118,10 @@ async def get_random_manga( The random Manga that was returned. """ data = await self._http.get_random_manga( - includes=includes, content_rating=content_rating, included_tags=included_tags, excluded_tags=excluded_tags + includes=includes or MangaIncludes(), + content_rating=content_rating, + included_tags=included_tags, + excluded_tags=excluded_tags, ) return Manga(self._http, data["data"]) @@ -1112,7 +1132,7 @@ async def get_my_followed_manga( *, limit: int | None = 100, offset: int = 0, - includes: MangaIncludes | None = MangaIncludes(), + includes: MangaIncludes | None = None, ) -> MangaCollection: """|coro| @@ -1140,7 +1160,11 @@ async def get_my_followed_manga( manga: list[Manga] = [] while True: - data = await self._http.get_user_followed_manga(limit=inner_limit, offset=offset, includes=includes) + data = await self._http.get_user_followed_manga( + limit=inner_limit, + offset=offset, + includes=includes or MangaIncludes(), + ) manga.extend([Manga(self._http, item) for item in data["data"]]) offset += inner_limit @@ -1151,7 +1175,9 @@ async def get_my_followed_manga( @require_authentication async def get_all_manga_reading_status( - self, *, status: ReadingStatus | None = None + self, + *, + status: ReadingStatus | None = None, ) -> manga.MangaMultipleReadingStatusResponse: """|coro| @@ -1277,7 +1303,7 @@ async def get_manga_draft_list( offset: int = 0, state: MangaState | None = None, order: MangaDraftListOrderQuery | None = None, - includes: MangaIncludes | None = MangaIncludes(), + includes: MangaIncludes | None = None, ) -> Manga: """|coro| @@ -1302,11 +1328,21 @@ async def get_manga_draft_list( -------- :class:`~hondana.Manga` """ - data = await self._http.get_manga_draft_list(limit=limit, offset=offset, state=state, order=order, includes=includes) + data = await self._http.get_manga_draft_list( + limit=limit, + offset=offset, + state=state, + order=order, + includes=includes or MangaIncludes(), + ) return Manga(self._http, data["data"]) async def get_manga_relation_list( - self, manga_id: str, /, *, includes: MangaIncludes | None = MangaIncludes() + self, + manga_id: str, + /, + *, + includes: MangaIncludes | None = None, ) -> MangaRelationCollection: """|coro| @@ -1329,13 +1365,18 @@ async def get_manga_relation_list( :exc:`BadRequest` The manga ID passed is malformed """ - data = await self._http.get_manga_relation_list(manga_id, includes=includes) + data = await self._http.get_manga_relation_list(manga_id, includes=includes or MangaIncludes()) fmt = [MangaRelation(self._http, manga_id, item) for item in data["data"]] return MangaRelationCollection(self._http, data, fmt) @require_authentication async def create_manga_relation( - self, manga_id: str, /, *, target_manga: str, relation_type: MangaRelationType + self, + manga_id: str, + /, + *, + target_manga: str, + relation_type: MangaRelationType, ) -> MangaRelation: """|coro| @@ -1451,7 +1492,7 @@ async def chapter_list( updated_at_since: datetime.datetime | None = None, published_at_since: datetime.datetime | None = None, order: FeedOrderQuery | None = None, - includes: ChapterIncludes | None = ChapterIncludes(), + includes: ChapterIncludes | None = None, ) -> ChapterFeed: """|coro| @@ -1557,7 +1598,7 @@ async def chapter_list( updated_at_since=updated_at_since, published_at_since=published_at_since, order=order, - includes=includes, + includes=includes or ChapterIncludes(), ) chapters.extend([Chapter(self._http, item) for item in data["data"]]) @@ -1573,7 +1614,7 @@ async def get_chapter( chapter_id: str, /, *, - includes: ChapterIncludes | None = ChapterIncludes(), + includes: ChapterIncludes | None = None, fetch_full_manga: bool = False, ) -> Chapter: """|coro| @@ -1600,7 +1641,7 @@ async def get_chapter( :class:`~hondana.Chapter` The Chapter we fetched from the API. """ - data = await self._http.get_chapter(chapter_id, includes=includes) + data = await self._http.get_chapter(chapter_id, includes=includes or ChapterIncludes()) chapter = Chapter(self._http, data["data"]) @@ -1736,7 +1777,7 @@ async def cover_art_list( uploaders: list[str] | None = None, locales: list[common.LanguageCode] | None = None, order: CoverArtListOrderQuery | None = None, - includes: CoverIncludes | None = CoverIncludes(), + includes: CoverIncludes | None = None, ) -> CoverCollection: """|coro| @@ -1785,7 +1826,7 @@ async def cover_art_list( uploaders=uploaders, locales=locales, order=order, - includes=includes, + includes=includes or CoverIncludes(), ) covers.extend([Cover(self._http, item) for item in data["data"]]) @@ -1839,7 +1880,7 @@ async def upload_cover( return Cover(self._http, data["data"]) - async def get_cover(self, cover_id: str, /, *, includes: CoverIncludes | None = CoverIncludes()) -> Cover: + async def get_cover(self, cover_id: str, /, *, includes: CoverIncludes | None = None) -> Cover: """|coro| The method will fetch a Cover from the MangaDex API. @@ -1865,13 +1906,19 @@ async def get_cover(self, cover_id: str, /, *, includes: CoverIncludes | None = :class:`~hondana.Cover` The Cover returned from the API. """ - data = await self._http.get_cover(cover_id, includes=includes) + data = await self._http.get_cover(cover_id, includes=includes or CoverIncludes()) return Cover(self._http, data["data"]) @require_authentication async def edit_cover( - self, cover_id: str, /, *, volume: str = MISSING, description: str = MISSING, version: int + self, + cover_id: str, + /, + *, + volume: str = MISSING, + description: str = MISSING, + version: int, ) -> Cover: """|coro| @@ -1939,7 +1986,7 @@ async def scanlation_group_list( name: str | None = None, focused_language: common.LanguageCode | None = None, order: ScanlatorGroupListOrderQuery | None = None, - includes: ScanlatorGroupIncludes | None = ScanlatorGroupIncludes(), + includes: ScanlatorGroupIncludes | None = None, ) -> ScanlatorGroupCollection: """|coro| @@ -1989,7 +2036,7 @@ async def scanlation_group_list( name=name, focused_language=focused_language, order=order, - includes=includes, + includes=includes or ScanlatorGroupIncludes(), ) groups.extend([ScanlatorGroup(self._http, item) for item in data["data"]]) @@ -2448,7 +2495,11 @@ async def ping_the_server(self) -> bool: return data == "pong" async def legacy_id_mapping( - self, mapping_type: legacy.LegacyMappingType, /, *, item_ids: list[int] + self, + mapping_type: legacy.LegacyMappingType, + /, + *, + item_ids: list[int], ) -> LegacyMappingCollection: """|coro| @@ -2543,7 +2594,7 @@ async def get_custom_list( custom_list_id: str, /, *, - includes: CustomListIncludes | None = CustomListIncludes(), + includes: CustomListIncludes | None = None, ) -> CustomList: """|coro| @@ -2566,7 +2617,7 @@ async def get_custom_list( :class:`~hondana.CustomList` The retrieved custom list. """ - data = await self._http.get_custom_list(custom_list_id, includes=includes) + data = await self._http.get_custom_list(custom_list_id, includes=includes or CustomListIncludes()) return CustomList(self._http, data["data"]) @@ -2618,7 +2669,11 @@ async def update_custom_list( The returned custom list after it was updated. """ data = await self._http.update_custom_list( - custom_list_id, name=name, visibility=visibility, manga=manga, version=version + custom_list_id, + name=name, + visibility=visibility, + manga=manga, + version=version, ) return CustomList(self._http, data["data"]) @@ -2733,7 +2788,12 @@ async def get_my_custom_lists(self, *, limit: int | None = 10, offset: int = 0) @require_authentication async def get_users_custom_lists( - self, user_id: str, /, *, limit: int | None = 10, offset: int = 0 + self, + user_id: str, + /, + *, + limit: int | None = 10, + offset: int = 0, ) -> CustomListCollection: """|coro| @@ -2794,7 +2854,7 @@ async def get_custom_list_manga_feed( updated_at_since: datetime.datetime | None = None, published_at_since: datetime.datetime | None = None, order: FeedOrderQuery | None = None, - includes: ChapterIncludes | None = ChapterIncludes(), + includes: ChapterIncludes | None = None, include_empty_pages: bool | None = None, include_future_publish_at: bool | None = None, include_external_url: bool | None = None, @@ -2879,7 +2939,7 @@ async def get_custom_list_manga_feed( updated_at_since=updated_at_since, published_at_since=published_at_since, order=order, - includes=includes, + includes=includes or ChapterIncludes(), include_empty_pages=include_empty_pages, include_future_publish_at=include_future_publish_at, include_external_url=include_external_url, @@ -2976,7 +3036,7 @@ async def get_scanlation_group( scanlation_group_id: str, /, *, - includes: ScanlatorGroupIncludes | None = ScanlatorGroupIncludes(), + includes: ScanlatorGroupIncludes | None = None, ) -> ScanlatorGroup: """|coro| @@ -3002,7 +3062,7 @@ async def get_scanlation_group( :class:`~hondana.ScanlatorGroup` The group returned from the API. """ - data = await self._http.view_scanlation_group(scanlation_group_id, includes=includes) + data = await self._http.view_scanlation_group(scanlation_group_id, includes=includes or ScanlatorGroupIncludes()) return ScanlatorGroup(self._http, data["data"]) @require_authentication @@ -3071,7 +3131,8 @@ async def update_scanlation_group( .. note:: - The ``website``, ``irc_server``, ``irc_channel``, ``discord``, ``contact_email``, ``description``, ``twitter``, ``manga_updates`` and ``focused_language`` + The ``website``, ``irc_server``, ``irc_channel``, ``discord``, ``contact_email``, ``description``, + ``twitter``, ``manga_updates`` and ``focused_language`` keys are all nullable in the API. To do so please pass ``None`` explicitly to these keys. .. note:: @@ -3184,7 +3245,7 @@ async def author_list( ids: list[str] | None = None, name: str | None = None, order: AuthorListOrderQuery | None = None, - includes: AuthorIncludes | None = AuthorIncludes(), + includes: AuthorIncludes | None = None, ) -> AuthorCollection: """|coro| @@ -3227,7 +3288,12 @@ async def author_list( authors: list[Author] = [] while True: data = await self._http.author_list( - limit=inner_limit, offset=offset, ids=ids, name=name, order=order, includes=includes + limit=inner_limit, + offset=offset, + ids=ids, + name=name, + order=order, + includes=includes or AuthorIncludes(), ) authors.extend([Author(self._http, item) for item in data["data"]]) @@ -3318,7 +3384,7 @@ async def create_author( ) return Author(self._http, data["data"]) - async def get_author(self, author_id: str, /, *, includes: AuthorIncludes | None = AuthorIncludes()) -> Author: + async def get_author(self, author_id: str, /, *, includes: AuthorIncludes | None = None) -> Author: """|coro| The method will fetch an Author from the MangaDex API. @@ -3345,11 +3411,11 @@ async def get_author(self, author_id: str, /, *, includes: AuthorIncludes | None :class:`~hondana.Author` The Author returned from the API. """ - data = await self._http.get_author(author_id, includes=includes) + data = await self._http.get_author(author_id, includes=includes or AuthorIncludes()) return Author(self._http, data["data"]) - async def get_artist(self, artist_id: str, /, *, includes: ArtistIncludes | None = ArtistIncludes()) -> Artist: + async def get_artist(self, artist_id: str, /, *, includes: ArtistIncludes | None = None) -> Artist: """|coro| The method will fetch an artist from the MangaDex API. @@ -3376,7 +3442,7 @@ async def get_artist(self, artist_id: str, /, *, includes: ArtistIncludes | None :class:`~hondana.Artist` The Author returned from the API. """ - data = await self._http.get_artist(artist_id, includes=includes) + data = await self._http.get_artist(artist_id, includes=includes or ArtistIncludes()) return Artist(self._http, data["data"]) @@ -3613,7 +3679,10 @@ async def delete_manga_rating(self, manga_id: str, /) -> None: await self._http.delete_manga_rating(manga_id) async def get_manga_statistics( - self, manga_id: str | None = None, manga_ids: list[str] | None = None, / + self, + manga_id: str | None = None, + manga_ids: list[str] | None = None, + /, ) -> MangaStatistics: """|coro| @@ -3666,7 +3735,8 @@ def upload_session( version: int | None = None, ) -> ChapterUpload: """ - This method will return an async `context manager `_ to handle some upload session management. + This method will return an async `context manager `_ + to handle some upload session management. Examples @@ -3800,6 +3870,11 @@ async def upload_chapter( images: List[:class:`pathlib.Path`] The list of images to upload as their Paths. + Returns + -------- + :class:`hondana.Chapter` + The chapter we created. + .. note:: The ``external_url`` parameter requires an explicit permission on MangaDex to set. @@ -3816,7 +3891,8 @@ async def upload_chapter( I suggest using :meth:`~hondana.Client.upload_session` instead for greater control. .. note:: - I personally advise the `context manager `_ method as it allows more control over your upload session. + I personally advise the `context manager `_ + method as it allows more control over your upload session. """ async with ChapterUpload( @@ -3834,9 +3910,7 @@ async def upload_chapter( version=version, ) as session: await session.upload_images(images) - new_chapter = await session.commit() - - return new_chapter + return await session.commit() @require_authentication async def get_latest_settings_template(self) -> dict[str, Any]: diff --git a/hondana/collections.py b/hondana/collections.py index b225873..fda2b7c 100644 --- a/hondana/collections.py +++ b/hondana/collections.py @@ -20,7 +20,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -""" +""" # noqa: A005 # we would use this as a namespace ideally from __future__ import annotations @@ -49,18 +49,18 @@ from .user import User __all__ = ( - "MangaCollection", - "MangaRelationCollection", - "ChapterFeed", "AuthorCollection", + "ChapterFeed", + "ChapterReadHistoryCollection", "CoverCollection", - "ScanlatorGroupCollection", - "ReportCollection", - "UserReportCollection", - "UserCollection", "CustomListCollection", "LegacyMappingCollection", - "ChapterReadHistoryCollection", + "MangaCollection", + "MangaRelationCollection", + "ReportCollection", + "ScanlatorGroupCollection", + "UserCollection", + "UserReportCollection", ) T = TypeVar("T") @@ -115,12 +115,12 @@ class MangaCollection(BaseCollection["Manga"]): """ __slots__ = ( - "_http", "_data", - "manga", - "total", + "_http", "limit", + "manga", "offset", + "total", ) def __init__(self, http: HTTPClient, payload: MangaSearchResponse, manga: list[Manga]) -> None: @@ -166,12 +166,12 @@ class MangaRelationCollection(BaseCollection["MangaRelation"]): """ __slots__ = ( - "_http", "_data", + "_http", + "limit", + "offset", "relations", "total", - "offset", - "limit", ) def __init__(self, http: HTTPClient, payload: MangaRelationResponse, relations: list[MangaRelation]) -> None: @@ -185,7 +185,13 @@ def __init__(self, http: HTTPClient, payload: MangaRelationResponse, relations: super().__init__() def __repr__(self) -> str: - return f"" + return ( + "" + ) @property def items(self) -> list[MangaRelation]: @@ -217,12 +223,12 @@ class ChapterFeed(BaseCollection["Chapter"]): """ __slots__ = ( - "_http", "_data", + "_http", "chapters", - "total", "limit", "offset", + "total", ) def __init__(self, http: HTTPClient, payload: GetMultiChapterResponse, chapters: list[Chapter]) -> None: @@ -268,12 +274,12 @@ class AuthorCollection(BaseCollection["Author"]): """ __slots__ = ( - "_http", "_data", + "_http", "authors", - "total", "limit", "offset", + "total", ) def __init__(self, http: HTTPClient, payload: GetMultiAuthorResponse, authors: list[Author]) -> None: @@ -319,12 +325,12 @@ class CoverCollection(BaseCollection["Cover"]): """ __slots__ = ( - "_http", "_data", + "_http", "covers", - "total", "limit", "offset", + "total", ) def __init__(self, http: HTTPClient, payload: GetMultiCoverResponse, covers: list[Cover]) -> None: @@ -370,12 +376,12 @@ class ScanlatorGroupCollection(BaseCollection["ScanlatorGroup"]): """ __slots__ = ( - "_http", "_data", + "_http", "groups", - "total", "limit", "offset", + "total", ) def __init__(self, http: HTTPClient, payload: GetMultiScanlationGroupResponse, groups: list[ScanlatorGroup]) -> None: @@ -389,7 +395,13 @@ def __init__(self, http: HTTPClient, payload: GetMultiScanlationGroupResponse, g super().__init__() def __repr__(self) -> str: - return f"" + return ( + "" + ) @property def items(self) -> list[ScanlatorGroup]: @@ -421,12 +433,12 @@ class ReportCollection(BaseCollection["Report"]): """ __slots__ = ( - "_http", "_data", - "reports", - "total", + "_http", "limit", "offset", + "reports", + "total", ) def __init__(self, http: HTTPClient, payload: GetReportReasonResponse, reports: list[Report]) -> None: @@ -472,12 +484,12 @@ class UserReportCollection(BaseCollection["UserReport"]): """ __slots__ = ( - "_http", "_data", - "reports", - "total", + "_http", "limit", "offset", + "reports", + "total", ) def __init__(self, http: HTTPClient, payload: GetUserReportReasonResponse, reports: list[UserReport]) -> None: @@ -525,12 +537,12 @@ class UserCollection(BaseCollection["User"]): """ __slots__ = ( - "_http", "_data", - "users", - "total", + "_http", "limit", "offset", + "total", + "users", ) def __init__(self, http: HTTPClient, payload: GetMultiUserResponse, users: list[User]) -> None: @@ -576,12 +588,12 @@ class CustomListCollection(BaseCollection["CustomList"]): """ __slots__ = ( - "_http", "_data", - "lists", - "total", + "_http", "limit", + "lists", "offset", + "total", ) def __init__(self, http: HTTPClient, payload: GetMultiCustomListResponse, lists: list[CustomList]) -> None: @@ -627,12 +639,12 @@ class LegacyMappingCollection(BaseCollection["LegacyItem"]): """ __slots__ = ( - "_http", "_data", + "_http", "legacy_mappings", - "total", "limit", "offset", + "total", ) def __init__(self, http: HTTPClient, payload: GetLegacyMappingResponse, mappings: list[LegacyItem]) -> None: @@ -646,7 +658,13 @@ def __init__(self, http: HTTPClient, payload: GetLegacyMappingResponse, mappings super().__init__() def __repr__(self) -> str: - return f"" + return ( + "" + ) @property def items(self) -> list[LegacyItem]: @@ -678,12 +696,12 @@ class ChapterReadHistoryCollection(BaseCollection["PreviouslyReadChapter"]): """ __slots__ = ( - "_http", "_data", + "_http", "chapter_read_histories", - "total", "limit", "offset", + "total", ) def __init__(self, http: HTTPClient, payload: ChapterReadHistoryResponse, history: list[PreviouslyReadChapter]) -> None: @@ -697,7 +715,13 @@ def __init__(self, http: HTTPClient, payload: ChapterReadHistoryResponse, histor super().__init__() def __repr__(self) -> str: - return f"" + return ( + "" + ) @property def items(self) -> list[PreviouslyReadChapter]: diff --git a/hondana/cover.py b/hondana/cover.py index e59c1e7..d7af1e6 100644 --- a/hondana/cover.py +++ b/hondana/cover.py @@ -59,19 +59,19 @@ class Cover: """ __slots__ = ( - "_http", - "_data", "_attributes", - "id", - "volume", - "file_name", - "description", - "locale", - "version", "_created_at", - "_updated_at", + "_data", + "_http", "_manga_relationship", + "_updated_at", "_uploader_relationship", + "description", + "file_name", + "id", + "locale", + "version", + "volume", ) def __init__(self, http: HTTPClient, payload: CoverResponse) -> None: @@ -88,10 +88,10 @@ def __init__(self, http: HTTPClient, payload: CoverResponse) -> None: self._created_at = self._attributes["createdAt"] self._updated_at = self._attributes["updatedAt"] self._manga_relationship: MangaResponse | None = RelationshipResolver(relationships, "manga").resolve( - with_fallback=True + with_fallback=True, )[0] self._uploader_relationship: UserResponse | None = RelationshipResolver(relationships, "user").resolve( - with_fallback=True + with_fallback=True, )[0] def __repr__(self) -> str: @@ -100,6 +100,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.file_name + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, Cover) and self.id == other.id @@ -141,15 +144,18 @@ def uploader(self) -> User | None: The user who uploaded this cover, if present. """ if not self._uploader_relationship: - return + return None if "attributes" in self._uploader_relationship: return User(self._http, self._uploader_relationship) + return None + def url(self, image_size: Literal[256, 512] | None = None, /, parent_id: str | None = None) -> str | None: """Method to return the Cover url. - Due to the API structure, this will return ``None`` if the parent manga key is missing from the response relationships. + Due to the API structure, this will return ``None`` if the parent manga key is missing from the + response relationships. Parameters ----------- @@ -167,7 +173,7 @@ def url(self, image_size: Literal[256, 512] | None = None, /, parent_id: str | N """ manga_id = parent_id or (self._manga_relationship["id"] if self._manga_relationship else None) if manga_id is None: - return + return None if image_size == 256: fmt = ".256.jpg" @@ -196,7 +202,7 @@ async def fetch_image(self, size: Literal[256, 512] | None = None, /) -> bytes | """ url = self.url(size) if url is None: - return + return None route = Route("GET", url) return await self._http.request(route) diff --git a/hondana/custom_list.py b/hondana/custom_list.py index 1cae1c9..8f7b26e 100644 --- a/hondana/custom_list.py +++ b/hondana/custom_list.py @@ -62,18 +62,18 @@ class CustomList: """ __slots__ = ( - "_http", - "_data", + "__manga", + "__owner", "_attributes", + "_data", + "_http", + "_manga_relationships", + "_owner_relationship", "id", "name", - "visibility", "pinned", "version", - "_owner_relationship", - "_manga_relationships", - "__owner", - "__manga", + "visibility", ) def __init__(self, http: HTTPClient, payload: CustomListResponse) -> None: @@ -102,6 +102,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.name + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, CustomList) and self.id == other.id @@ -138,6 +141,8 @@ def owner(self) -> User | None: self.__owner = User(self._http, self._owner_relationship) return self.__owner + return None + @owner.setter def owner(self, other: User) -> None: self.__owner = other @@ -161,6 +166,8 @@ def manga(self) -> list[Manga] | None: if fmt := [Manga(self._http, manga) for manga in self._manga_relationships if "attributes" in manga]: return fmt + return None + @manga.setter def manga(self, other: list[Manga]) -> None: self.__manga = other @@ -237,6 +244,8 @@ async def get_manga(self, *, limit: int | None = 100, offset: int = 0) -> list[M if ret := [Manga(self._http, item) for item in data["data"]]: return ret + return None + @require_authentication async def update( self, diff --git a/hondana/enums.py b/hondana/enums.py index 3b77d7c..0175dbe 100644 --- a/hondana/enums.py +++ b/hondana/enums.py @@ -31,22 +31,22 @@ ) __all__ = ( + "AuthorReportReason", + "ChapterReportReason", "ContentRating", - "PublicationDemographic", "CustomListVisibility", - "ReportCategory", - "ReportStatus", + "ForumThreadType", + "MangaRelationType", + "MangaReportReason", + "MangaState", "MangaStatus", + "PublicationDemographic", "ReadingStatus", - "MangaState", - "MangaRelationType", - "AuthorReportReason", - "ChapterReportReason", + "ReportCategory", + "ReportReason", + "ReportStatus", "ScanlationGroupReportReason", - "MangaReportReason", "UserReportReason", - "ReportReason", - "ForumThreadType", ) diff --git a/hondana/errors.py b/hondana/errors.py index 1eb7d1e..6809726 100644 --- a/hondana/errors.py +++ b/hondana/errors.py @@ -32,15 +32,15 @@ from .types_.errors import ErrorType __all__ = ( + "APIException", "AuthenticationRequired", - "RefreshTokenFailure", - "UploadInProgress", + "BadRequest", + "Forbidden", "MangaDexServerError", - "APIException", "NotFound", - "BadRequest", + "RefreshTokenFailure", "Unauthorized", - "Forbidden", + "UploadInProgress", ) @@ -61,10 +61,10 @@ class Error: """ __slots__ = ( + "error_detail", "error_id", "error_status", "error_title", - "error_detail", ) def __init__(self, error: ErrorType) -> None: @@ -171,11 +171,11 @@ class APIException(Exception): """ __slots__ = ( + "_errors", + "errors", "response", - "status_code", "response_id", - "errors", - "_errors", + "status_code", ) def __init__(self, response: aiohttp.ClientResponse, /, *, status_code: int, errors: list[ErrorType]) -> None: @@ -188,10 +188,18 @@ def __init__(self, response: aiohttp.ClientResponse, /, *, status_code: int, err super().__init__(self.status_code, self.errors) def __repr__(self) -> str: - return f"<{self.__class__.__name__} response_id={self.response_id} status_code={self.status_code} error_amount: {len(self.errors)}>" + return ( + f"<{self.__class__.__name__} " + f"response_id={self.response_id} " + f"status_code={self.status_code} " + f"error_amount: {len(self.errors)}>" + ) def __str__(self) -> str: - return f"HTTP Status: {self.status_code} and response id: {self.response_id} :: {', '.join([error.error_detail for error in self.errors])}" + return ( + f"HTTP Status: {self.status_code} and response id: {self.response_id} :: " + f"{', '.join([error.error_detail for error in self.errors])}" + ) class BadRequest(APIException): diff --git a/hondana/forums.py b/hondana/forums.py index ca66849..c0cbbc1 100644 --- a/hondana/forums.py +++ b/hondana/forums.py @@ -34,10 +34,10 @@ from .types_.statistics import CommentMetaData __all__ = ( - "MangaComments", "ChapterComments", - "ScanlatorGroupComments", "ForumThread", + "MangaComments", + "ScanlatorGroupComments", ) @@ -55,7 +55,7 @@ class _Comments: The amount of replies (comments) this object has in total. """ - __slots__ = ("_data", "_http", "__thread", "thread_id", "reply_count", "parent_id") + __slots__ = ("__thread", "_data", "_http", "parent_id", "reply_count", "thread_id") __inner_type__: ForumThreadType def __init__(self, http: HTTPClient, comment_payload: CommentMetaData, parent_id: str, /) -> None: @@ -67,7 +67,12 @@ def __init__(self, http: HTTPClient, comment_payload: CommentMetaData, parent_id self.__thread: ForumThread | None = None def __repr__(self) -> str: - return f"<{self.__class__.__name__} thread_id={self.thread_id} reply_count={self.reply_count} parent_id={self.parent_id!r}>" + return ( + f"<{self.__class__.__name__} " + f"thread_id={self.thread_id} " + f"reply_count={self.reply_count} " + f"parent_id={self.parent_id!r}>" + ) @property def thread(self) -> ForumThread | None: @@ -140,9 +145,9 @@ class ForumThread: """ __slots__ = ( - "_http", - "_data", "_attributes", + "_data", + "_http", "id", "replies_count", ) diff --git a/hondana/http.py b/hondana/http.py index 9516cbf..cc213c4 100755 --- a/hondana/http.py +++ b/hondana/http.py @@ -20,7 +20,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -""" +""" # noqa: A005 # we only use this as part of a namespace, not directly from __future__ import annotations @@ -49,7 +49,16 @@ ReportReason, ReportStatus, ) -from .errors import APIException, BadRequest, Forbidden, MangaDexServerError, NotFound, RefreshTokenFailure, Unauthorized +from .errors import ( + APIException, + AuthenticationRequired, + BadRequest, + Forbidden, + MangaDexServerError, + NotFound, + RefreshTokenFailure, + Unauthorized, +) from .utils import ( MANGA_TAGS, MANGADEX_TIME_REGEX, @@ -132,13 +141,13 @@ class Token: __slots__ = ( - "raw_token", - "refresh_token", - "created_at", + "_client_secret", + "_http", "client_id", + "created_at", "expires", - "_http", - "_client_secret", + "raw_token", + "refresh_token", ) created_at: datetime.datetime expires: datetime.datetime @@ -164,7 +173,12 @@ def __str__(self) -> str: @classmethod def from_token_response( - cls, *, payload: token.GetTokenPayload, session: aiohttp.ClientSession, client_secret: str, client_id: str + cls, + *, + payload: token.GetTokenPayload, + session: aiohttp.ClientSession, + client_secret: str, + client_id: str, ) -> Self: self = cls(payload["access_token"], session=session, client_id=client_id, client_secret=client_secret) self.add_refresh_token(payload["refresh_token"]) @@ -190,7 +204,8 @@ def has_expired(self) -> bool: async def refresh(self) -> Self: if not self.refresh_token: - raise TypeError("Current token has no refresh_token.") + msg = "Current token has no refresh_token." + raise TypeError(msg) route = AuthRoute("POST", "/token") @@ -200,7 +215,7 @@ async def refresh(self) -> Self: ("refresh_token", self.refresh_token.raw_token), ("client_id", self.client_id), ("client_secret", self._client_secret), - ] + ], ) async with self._http.request(route.verb, route.url, data=data) as resp: @@ -209,7 +224,8 @@ async def refresh(self) -> Self: try: self.raw_token = response_data["access_token"] except KeyError as exc: - raise RefreshTokenFailure("Failed to refresh token", resp, response_data) from exc + msg = "Failed to refresh token" + raise RefreshTokenFailure(msg, resp, response_data) from exc self._parse() @@ -218,7 +234,10 @@ async def refresh(self) -> Self: def add_refresh_token(self, raw_token: str) -> None: self.refresh_token = self.__class__( - raw_token, client_id=self.client_id, client_secret=self._client_secret, session=self._http + raw_token, + client_id=self.client_id, + client_secret=self._client_secret, + session=self._http, ) @@ -235,8 +254,8 @@ def defer(self) -> None: def __exit__( self, - exc_type: type[BE] | None, - exc: BE | None, + exc_type: type[BE] | None, # noqa: PYI036 # not expanding the typevar + exc: BE | None, # noqa: PYI036 # not expanding the typevar traceback: TracebackType | None, ) -> None: if self._unlock: @@ -245,18 +264,18 @@ def __exit__( class HTTPClient: __slots__ = ( + "_auth_token", "_authenticated", - "_session", + "_client_secret", "_locks", - "_token_lock", "_oauth_scopes", "_password", - "_client_secret", - "_auth_token", "_refresh_token", - "username", + "_session", + "_token_lock", "client_id", "user_agent", + "username", ) def __init__( @@ -281,13 +300,12 @@ def __init__( self._auth_token: Token | None = None self._refresh_token: Token | None = None self._authenticated: bool = all([username, password, client_id, client_secret]) - self._resolve_api_type(dev_api) + self._resolve_api_type(dev_api=dev_api) if any([username, password, client_id, client_secret]) and not self._authenticated: - raise RuntimeError( - "You must pass all required login attributes: `username`, `password`, `client_id`, `client_secret`" - ) + msg = "You must pass all required login attributes: `username`, `password`, `client_id`, `client_secret`" + raise RuntimeError(msg) - def _resolve_api_type(self, dev_api: bool) -> None: + def _resolve_api_type(self, *, dev_api: bool) -> None: if dev_api is True or getenv("HONDANA_API_DEV"): Route.API_BASE_URL = Route.API_DEV_BASE_URL AuthRoute.API_BASE_URL = AuthRoute.API_DEV_BASE_URL @@ -318,8 +336,9 @@ async def close(self) -> None: await self._session.close() async def get_token(self) -> Token: - assert self.client_id - assert self._client_secret + if not self.client_id or not self._client_secret: + msg = "You must pass the correct OAuth2 details to use authentication." + raise AuthenticationRequired(msg) if self._auth_token and not self._auth_token.has_expired(): return self._auth_token @@ -332,7 +351,7 @@ async def get_token(self) -> Token: try: await self._auth_token.refresh() except RefreshTokenFailure as exc: - LOGGER.error( + LOGGER.exception( "Failed to refresh token. Will attempt the login flow again. Errored payload:\n%s", exc.data, exc_info=exc, @@ -347,7 +366,7 @@ async def get_token(self) -> Token: ("password", self._password), ("client_id", self.client_id), ("client_secret", self._client_secret), - ] + ], ) if not self._session: @@ -355,10 +374,16 @@ async def get_token(self) -> Token: # to prevent circular we handle this logic manually, not the request method async with self._session.request(route.verb, route.url, data=data) as resp: - response_data: token.GetTokenPayload = await resp.json() + if 200 < resp.status < 300: + response_data: token.GetTokenPayload = await resp.json() + else: + raise APIException(resp, status_code=resp.status, errors=[]) self._auth_token = Token.from_token_response( - payload=response_data, client_id=self.client_id, client_secret=self._client_secret, session=self._session + payload=response_data, + client_id=self.client_id, + client_secret=self._client_secret, + session=self._session, ) return self._auth_token @@ -445,14 +470,15 @@ async def request( retry = response.headers.get("x-ratelimit-retry-after", None) LOGGER.debug("retry is: %s", retry) if retry is not None: - retry = datetime.datetime.fromtimestamp(int(retry)) + retry = datetime.datetime.fromtimestamp(int(retry), datetime.UTC) # The total ratelimit session hits limit = response.headers.get("x-ratelimit-limit", None) LOGGER.debug("limit is: %s", limit) if remaining == "0" and response.status != 429: - assert retry is not None - delta = retry - datetime.datetime.now() + if not retry: + break # unreachable + delta = retry - datetime.datetime.now(datetime.UTC) sleep = delta.total_seconds() + 1 LOGGER.warning("A ratelimit has been exhausted, sleeping for: %d", sleep) maybe_lock.defer() @@ -471,8 +497,10 @@ async def request( return data if response.status == 429: - assert retry is not None - delta = retry - datetime.datetime.now() + if not retry: + break # unreachable + + delta = retry - datetime.datetime.now(datetime.UTC) sleep = delta.total_seconds() + 1 LOGGER.warning("A ratelimit has been hit, sleeping for: %d", sleep) await asyncio.sleep(sleep) @@ -484,14 +512,16 @@ async def request( await asyncio.sleep(sleep_) continue - assert isinstance(data, dict) + if not isinstance(data, dict): + break # unreachable + if response.status == 400: raise BadRequest(response, errors=data["errors"]) - elif response.status == 401: + if response.status == 401: raise Unauthorized(response, errors=data["errors"]) - elif response.status == 403: + if response.status == 403: raise Forbidden(response, errors=data["errors"]) - elif response.status == 404: + if response.status == 404: raise NotFound(response, errors=data["errors"]) LOGGER.exception("Unhandled HTTP error occurred: %s -> %s", response.status, data) raise APIException( @@ -499,8 +529,8 @@ async def request( status_code=response.status, errors=data["errors"], ) - except (aiohttp.ServerDisconnectedError, aiohttp.ServerTimeoutError) as error: - LOGGER.exception("Network error occurred: %s", error) + except (aiohttp.ServerDisconnectedError, aiohttp.ServerTimeoutError): + LOGGER.exception("Network error occurred:-") await asyncio.sleep(5) continue @@ -510,7 +540,8 @@ async def request( raise APIException(response, status_code=response.status, errors=[]) - raise RuntimeError("Unreachable code in HTTP handling.") + msg = "Unreachable code in HTTP handling." + raise RuntimeError(msg) def account_available(self, username: str) -> Response[GetAccountAvailable]: route = Route("GET", "/account/available/{username}", username=username) @@ -568,12 +599,10 @@ def manga_list( query["year"] = year if included_tags: - assert included_tags.tags is not None # the init of QueryTags raises if this is None query["includedTags"] = included_tags.tags query["includedTagsMode"] = included_tags.mode if excluded_tags: - assert excluded_tags.tags is not None # the init of QueryTags raises if this is None query["excludedTags"] = excluded_tags.tags query["excludedTagsMode"] = excluded_tags.mode @@ -910,20 +939,33 @@ def get_random_manga( @overload def manga_read_markers( - self, manga_ids: list[str], /, *, grouped: Literal[False] + self, + manga_ids: list[str], + /, + *, + grouped: Literal[False], ) -> Response[manga.MangaReadMarkersResponse]: ... @overload def manga_read_markers( - self, manga_ids: list[str], /, *, grouped: Literal[True] + self, + manga_ids: list[str], + /, + *, + grouped: Literal[True], ) -> Response[manga.MangaGroupedReadMarkersResponse]: ... def manga_read_markers( - self, manga_ids: list[str], /, *, grouped: bool = False + self, + manga_ids: list[str], + /, + *, + grouped: bool = False, ) -> Response[manga.MangaReadMarkersResponse | manga.MangaGroupedReadMarkersResponse]: if not grouped: if len(manga_ids) != 1: - raise ValueError("If `grouped` is False, then `manga_ids` should be a single length list.") + msg = "If `grouped` is False, then `manga_ids` should be a single length list." + raise ValueError(msg) id_ = manga_ids[0] route = Route("GET", "/manga/{manga_id}/read", manga_id=id_, authenticate=True) @@ -958,7 +1000,9 @@ def manga_read_markers_batch( return self.request(route, json=body) def get_all_manga_reading_status( - self, *, status: ReadingStatus | None = None + self, + *, + status: ReadingStatus | None = None, ) -> Response[manga.MangaMultipleReadingStatusResponse]: route = Route("GET", "/manga/status", authenticate=True) if status: @@ -1011,7 +1055,11 @@ def get_manga_draft_list( return self.request(route, params=query) def get_manga_relation_list( - self, manga_id: str, /, *, includes: MangaIncludes | None + self, + manga_id: str, + /, + *, + includes: MangaIncludes | None, ) -> Response[manga.MangaRelationResponse]: route = Route("GET", "/manga/{manga_id}/relation", manga_id=manga_id) @@ -1022,7 +1070,12 @@ def get_manga_relation_list( return self.request(route) def create_manga_relation( - self, manga_id: str, /, *, target_manga: str, relation_type: MangaRelationType + self, + manga_id: str, + /, + *, + target_manga: str, + relation_type: MangaRelationType, ) -> Response[manga.MangaRelationCreateResponse]: route = Route("POST", "/manga/{manga_id}/relation", manga_id=manga_id, authenticate=True) query: dict[str, Any] = {"targetManga": target_manga, "relation": relation_type.value} @@ -1145,7 +1198,11 @@ def chapter_list( return self.request(route, params=query) def get_chapter( - self, chapter_id: str, /, *, includes: ChapterIncludes | None + self, + chapter_id: str, + /, + *, + includes: ChapterIncludes | None, ) -> Response[chapter.GetSingleChapterResponse]: route = Route("GET", "/chapter/{chapter_id}", chapter_id=chapter_id) @@ -1279,7 +1336,8 @@ def edit_cover( query: dict[str, Any] = {"version": version} if volume is MISSING: - raise TypeError("`volume` key must be a value of `str` or `NoneType`.") + msg = "`volume` key must be a value of `str` or `NoneType`." + raise TypeError(msg) query["volume"] = volume @@ -1390,7 +1448,10 @@ def unfollow_user(self, user_id: str) -> Response[DefaultResponseType]: return self.request(route) def get_my_followed_groups( - self, *, limit: int, offset: int + self, + *, + limit: int, + offset: int, ) -> Response[scanlator_group.GetMultiScanlationGroupResponse]: route = Route("GET", "/user/follows/group", authenticate=True) @@ -1429,7 +1490,10 @@ def is_custom_list_followed(self, custom_list_id: str, /) -> Response[DefaultRes return self.request(route) def get_user_followed_manga( - self, limit: int, offset: int, includes: MangaIncludes | None + self, + limit: int, + offset: int, + includes: MangaIncludes | None, ) -> Response[manga.MangaSearchResponse]: route = Route("GET", "/user/follows/manga", authenticate=True) @@ -1473,7 +1537,11 @@ def ping_the_server(self) -> Response[str]: return self.request(route) def legacy_id_mapping( - self, mapping_type: legacy.LegacyMappingType, /, *, item_ids: list[int] + self, + mapping_type: legacy.LegacyMappingType, + /, + *, + item_ids: list[int], ) -> Response[legacy.GetLegacyMappingResponse]: route = Route("POST", "/legacy/mapping") query: dict[str, Any] = {"type": mapping_type, "ids": item_ids} @@ -1504,7 +1572,11 @@ def create_custom_list( return self.request(route, json=query) def get_custom_list( - self, custom_list_id: str, /, *, includes: CustomListIncludes | None + self, + custom_list_id: str, + /, + *, + includes: CustomListIncludes | None, ) -> Response[custom_list.GetSingleCustomListResponse]: route = Route("GET", "/list/{custom_list_id}", custom_list_id=custom_list_id) @@ -1578,7 +1650,12 @@ def get_my_custom_lists(self, limit: int, offset: int) -> Response[custom_list.G return self.request(route, params=query) def get_users_custom_lists( - self, user_id: str, /, *, limit: int, offset: int + self, + user_id: str, + /, + *, + limit: int, + offset: int, ) -> Response[custom_list.GetMultiCustomListResponse]: route = Route("GET", "/user/{user_id}/list", user_id=user_id) @@ -1714,14 +1791,19 @@ def create_scanlation_group( publish_delay = delta_to_iso(publish_delay) if not MANGADEX_TIME_REGEX.fullmatch(publish_delay): - raise ValueError("The `publish_delay` parameter must match the regex format.") + msg = "The `publish_delay` parameter must match the regex format." + raise ValueError(msg) query["publishDelay"] = publish_delay return self.request(route, json=query) def view_scanlation_group( - self, scanlation_group_id: str, /, *, includes: ScanlatorGroupIncludes | None + self, + scanlation_group_id: str, + /, + *, + includes: ScanlatorGroupIncludes | None, ) -> Response[scanlator_group.GetSingleScanlationGroupResponse]: route = Route("GET", "/group/{scanlation_group_id}", scanlation_group_id=scanlation_group_id) @@ -1797,7 +1879,8 @@ def update_scanlation_group( publish_delay = delta_to_iso(publish_delay) if not MANGADEX_TIME_REGEX.fullmatch(publish_delay): - raise ValueError("The `publish_delay` parameter's string must match the regex pattern.") + msg = "The `publish_delay` parameter's string must match the regex pattern." + raise ValueError(msg) query["publishDelay"] = publish_delay @@ -1815,13 +1898,19 @@ def delete_scanlation_group(self, scanlation_group_id: str, /) -> Response[Defau def follow_scanlation_group(self, scanlation_group_id: str, /) -> Response[DefaultResponseType]: route = Route( - "POST", "/group/{scanlation_group_id}/follow", scanlation_group_id=scanlation_group_id, authenticate=True + "POST", + "/group/{scanlation_group_id}/follow", + scanlation_group_id=scanlation_group_id, + authenticate=True, ) return self.request(route) def unfollow_scanlation_group(self, scanlation_group_id: str, /) -> Response[DefaultResponseType]: route = Route( - "DELETE", "/group/{scanlation_group_id}/follow", scanlation_group_id=scanlation_group_id, authenticate=True + "DELETE", + "/group/{scanlation_group_id}/follow", + scanlation_group_id=scanlation_group_id, + authenticate=True, ) return self.request(route) @@ -2160,44 +2249,65 @@ def delete_manga_rating(self, manga_id: str, /) -> Response[Literal["ok", "error return self.request(route) def get_chapter_statistics( - self, chapter_id: str | None, chapter_ids: str | None + self, + chapter_id: str | None, + chapter_ids: str | None, ) -> Response[statistics.GetCommentsStatisticsResponse]: if chapter_id: route = Route("GET", "/statistics/chapter/{chapter_id}", chapter_id=chapter_id, authenticate=True) return self.request(route) - elif chapter_ids: + if chapter_ids: route = Route("GET", "/statistics/chapter", authenticate=True) return self.request(route, params={"chapter": chapter_ids}) - raise ValueError("Either chapter_id or chapter_ids is required.") + + msg = "Either chapter_id or chapter_ids is required." + raise ValueError(msg) def get_scanlation_group_statistics( - self, scanlation_group_id: str | None, scanlation_group_ids: str | None + self, + scanlation_group_id: str | None, + scanlation_group_ids: str | None, ) -> Response[statistics.GetCommentsStatisticsResponse]: if scanlation_group_id: route = Route( - "GET", "/statistics/group/{scanlation_group_id}", scanlation_group_id=scanlation_group_ids, authenticate=True + "GET", + "/statistics/group/{scanlation_group_id}", + scanlation_group_id=scanlation_group_ids, + authenticate=True, ) return self.request(route) - elif scanlation_group_ids: + if scanlation_group_ids: route = Route("GET", "/statistics/group", authenticate=True) return self.request(route, params={"group": scanlation_group_ids}) - raise ValueError("Either chapter_id or chapter_ids is required.") + + msg = "Either chapter_id or chapter_ids is required." + raise ValueError(msg) def get_manga_statistics( - self, manga_id: str | None, manga_ids: list[str] | None, / + self, + manga_id: str | None, + manga_ids: list[str] | None, + /, ) -> Response[statistics.GetMangaStatisticsResponse]: if manga_id: route = Route("GET", "/statistics/manga/{manga_id}", manga_id=manga_id, authenticate=True) return self.request(route) - elif manga_ids: + if manga_ids: route = Route("GET", "/statistics/manga", authenticate=True) query: MANGADEX_QUERY_PARAM_TYPE = {"manga": manga_ids} return self.request(route, params=query) - else: - raise ValueError("Either `manga_id` or `manga_ids` must be passed.") + + msg = "Either `manga_id` or `manga_ids` must be passed." + raise ValueError(msg) def open_upload_session( - self, manga_id: str, /, *, scanlator_groups: list[str], chapter_id: str | None, version: int | None + self, + manga_id: str, + /, + *, + scanlator_groups: list[str], + chapter_id: str | None, + version: int | None, ) -> Response[upload.BeginChapterUploadResponse]: query: dict[str, Any] = {"manga": manga_id, "groups": scanlator_groups} if chapter_id is not None: @@ -2246,7 +2356,9 @@ def create_forum_thread(self, thread_type: ForumThreadType, resource_id: str) -> return self.request(route, json=query) def check_approval_required( - self, manga_id: str, locale: common.LanguageCode + self, + manga_id: str, + locale: common.LanguageCode, ) -> Response[upload.GetCheckApprovalRequired]: route = Route("POST", "/upload/check-approval-required", authenticate=True) diff --git a/hondana/legacy.py b/hondana/legacy.py index 8b992eb..6719003 100644 --- a/hondana/legacy.py +++ b/hondana/legacy.py @@ -50,12 +50,12 @@ class LegacyItem: """ __slots__ = ( - "_http", + "_attributes", "_data", + "_http", "id", - "_attributes", - "obj_new_id", "obj_legacy_id", + "obj_new_id", "obj_type", ) @@ -69,7 +69,16 @@ def __init__(self, http: HTTPClient, payload: LegacyMappingResponse) -> None: self.obj_type: LegacyMappingType = self._attributes["type"] def __repr__(self) -> str: - return f"" + return ( + "" + ) + + def __hash__(self) -> int: + return hash(self.id) def __eq__(self, other: object) -> bool: return isinstance(other, LegacyItem) and self.id == other.id diff --git a/hondana/manga.py b/hondana/manga.py index c6e56c2..485cf95 100644 --- a/hondana/manga.py +++ b/hondana/manga.py @@ -57,9 +57,9 @@ __all__ = ( "Manga", + "MangaRating", "MangaRelation", "MangaStatistics", - "MangaRating", ) @@ -110,41 +110,41 @@ class Manga: """ __slots__ = ( - "_http", - "_data", + "__artists", + "__authors", + "__cover", + "__related_manga", + "_artist_relationships", "_attributes", - "_title", + "_author_relationships", + "_cover_relationship", + "_created_at", + "_cs_tags", + "_data", "_description", - "id", - "relation_type", + "_http", + "_related_manga_relationships", + "_tags", + "_title", + "_updated_at", "alternate_titles", - "locked", + "available_translated_languages", + "chapter_numbers_reset_on_new_volume", + "content_rating", + "id", + "last_chapter", + "last_volume", + "latest_uploaded_chapter", "links", + "locked", "original_language", - "last_volume", - "last_chapter", "publication_demographic", - "status", - "year", - "content_rating", - "chapter_numbers_reset_on_new_volume", - "available_translated_languages", - "latest_uploaded_chapter", + "relation_type", "state", "stats", + "status", "version", - "_tags", - "_created_at", - "_updated_at", - "_artist_relationships", - "_author_relationships", - "_related_manga_relationships", - "_cover_relationship", - "__authors", - "__artists", - "__cover", - "__related_manga", - "_cs_tags", + "year", ) def __init__(self, http: HTTPClient, payload: manga.MangaResponse) -> None: @@ -183,16 +183,20 @@ def __init__(self, http: HTTPClient, payload: manga.MangaResponse) -> None: self._created_at = self._attributes["createdAt"] self._updated_at = self._attributes["updatedAt"] self._author_relationships: list[AuthorResponse] = RelationshipResolver["AuthorResponse"]( - relationships, "author" + relationships, + "author", ).resolve() self._artist_relationships: list[ArtistResponse] = RelationshipResolver["ArtistResponse"]( - relationships, "artist" + relationships, + "artist", ).resolve() self._related_manga_relationships: list[MangaResponse] = RelationshipResolver["MangaResponse"]( - relationships, "manga" + relationships, + "manga", ).resolve() self._cover_relationship: CoverResponse | None = RelationshipResolver["CoverResponse"]( - relationships, "cover_art" + relationships, + "cover_art", ).resolve(with_fallback=True)[0] self.__authors: list[Author] | None = None self.__artists: list[Artist] | None = None @@ -205,6 +209,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.title + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, (Manga, MangaRelation)) and self.id == other.id @@ -267,7 +274,8 @@ def raw_description(self) -> LocalizedString: -------- :class:`~hondana.types_.common.LocalizedString` The raw object from the manga's api response payload. - Provides no formatting on its own. Consider :meth:`~hondana.Manga.description` or :meth:`~hondana.Manga.localised_description` instead. + Provides no formatting on its own. + Consider :meth:`~hondana.Manga.description` or :meth:`~hondana.Manga.localised_description` instead. """ return self._description @@ -334,7 +342,7 @@ def artists(self) -> list[Artist] | None: return self.__artists if not self._artist_relationships: - return + return None formatted: list[Artist] = [ Artist(self._http, artist) for artist in self._artist_relationships if "attributes" in artist @@ -369,14 +377,14 @@ def authors(self) -> list[Author] | None: return self.__authors if not self._author_relationships: - return + return None formatted: list[Author] = [ Author(self._http, author) for author in self._author_relationships if "attributes" in author ] if not formatted: - return + return None self.__authors = formatted return self.__authors @@ -403,12 +411,14 @@ def cover(self) -> Cover | None: return self.__cover if not self._cover_relationship: - return + return None if "attributes" in self._cover_relationship: self.__cover = Cover(self._http, self._cover_relationship) return self.__cover + return None + @cover.setter def cover(self, value: Cover) -> None: self.__cover = value @@ -426,14 +436,14 @@ def related_manga(self) -> list[Manga] | None: return self.__related_manga if not self._related_manga_relationships: - return + return None formatted: list[Manga] = [ self.__class__(self._http, item) for item in self._related_manga_relationships if "attributes" in item ] if not formatted: - return + return None self.__related_manga = formatted return self.__related_manga @@ -461,7 +471,7 @@ async def get_artists(self) -> list[Artist] | None: return self.artists if not self._artist_relationships: - return + return None ids = [r["id"] for r in self._artist_relationships] @@ -471,7 +481,7 @@ async def get_artists(self) -> list[Artist] | None: formatted.append(Artist(self._http, data["data"])) if not formatted: - return + return None self.artists = formatted return formatted @@ -495,7 +505,7 @@ async def get_authors(self) -> list[Author] | None: return self.authors if not self._author_relationships: - return + return None ids = [r["id"] for r in self._related_manga_relationships] @@ -505,7 +515,7 @@ async def get_authors(self) -> list[Author] | None: formatted.append(Author(self._http, data["data"])) if not formatted: - return + return None self.authors = formatted return formatted @@ -524,7 +534,7 @@ async def get_cover(self) -> Cover | None: return self.cover if not self._cover_relationship: - return + return None data = await self._http.get_cover(self._cover_relationship["id"], includes=CoverIncludes()) self.cover = Cover(self._http, data["data"]) @@ -535,12 +545,17 @@ def cover_url(self, *, size: Literal[256, 512] | None = None) -> str | None: If the manga was requested without the ``"cover_art"`` includes[] parameters, then this method will return ``None``. + Returns + -------- + Optional[:class:`str`] + The cover url, if present in the underlying manga details. + .. note:: For a more stable cover return, try :meth:`~hondana.Manga.get_cover` """ if not self.cover: - return + return None return self.cover.url(size, parent_id=self.id) @@ -570,7 +585,7 @@ async def get_related_manga(self, *, limit: int = 100, offset: int = 0) -> list[ return self.related_manga if not self._related_manga_relationships: - return + return None ids = [r["id"] for r in self._related_manga_relationships] @@ -601,7 +616,7 @@ async def get_related_manga(self, *, limit: int = 100, offset: int = 0) -> list[ ret: list[Manga] = [Manga(self._http, item) for item in data["data"]] if not ret: - return + return None self.related_manga = ret return self.related_manga @@ -752,7 +767,8 @@ async def follow(self, *, set_status: bool = True, status: ReadingStatus = Readi ----------- set_status: :class:`bool` Whether to set the reading status of the manga you follow. - Due to the current MangaDex infrastructure, not setting a status will cause the manga to not show up in your lists. + Due to the current MangaDex infrastructure, not setting a + status will cause the manga to not show up in your lists. Defaults to ``True`` status: :class:`~hondana.ReadingStatus` The status to apply to the newly followed manga. @@ -827,7 +843,7 @@ async def feed( updated_at_since: datetime.datetime | None = None, published_at_since: datetime.datetime | None = None, order: FeedOrderQuery | None = None, - includes: ChapterIncludes | None = ChapterIncludes(), + includes: ChapterIncludes | None = None, include_empty_pages: bool | None = None, include_future_publish_at: bool | None = None, include_external_url: bool | None = None, @@ -909,13 +925,13 @@ async def feed( updated_at_since=updated_at_since, published_at_since=published_at_since, order=order, - includes=includes, + includes=includes or ChapterIncludes(), include_empty_pages=include_empty_pages, include_future_publish_at=include_future_publish_at, include_external_url=include_external_url, ) - from .chapter import Chapter + from .chapter import Chapter # noqa: PLC0415 # cyclic import cheat chapters.extend([Chapter(self._http, item) for item in data["data"]]) @@ -941,7 +957,11 @@ async def update_read_markers(self) -> manga.MangaReadMarkersResponse: @require_authentication async def bulk_update_read_markers( - self, *, update_history: bool = True, read_chapters: list[str] | None, unread_chapters: list[str] | None + self, + *, + update_history: bool = True, + read_chapters: list[str] | None, + unread_chapters: list[str] | None, ) -> None: """|coro| @@ -964,10 +984,15 @@ async def bulk_update_read_markers( """ if read_chapters or unread_chapters: await self._http.manga_read_markers_batch( - self.id, update_history=update_history, read_chapters=read_chapters, unread_chapters=unread_chapters + self.id, + update_history=update_history, + read_chapters=read_chapters, + unread_chapters=unread_chapters, ) - else: - raise TypeError("You must provide either `read_chapters` and/or `unread_chapters` to this method.") + return + + msg = "You must provide either `read_chapters` and/or `unread_chapters` to this method." + raise TypeError(msg) @require_authentication async def get_reading_status(self) -> manga.MangaSingleReadingStatusResponse: @@ -1038,7 +1063,9 @@ async def get_volumes_and_chapters( The raw payload from mangadex. There is no guarantee of the keys here. """ return await self._http.get_manga_volumes_and_chapters( - manga_id=self.id, translated_language=translated_language, groups=groups + manga_id=self.id, + translated_language=translated_language, + groups=groups, ) @require_authentication @@ -1108,7 +1135,7 @@ async def get_chapters( updated_at_since: datetime.datetime | None = None, published_at_since: datetime.datetime | None = None, order: FeedOrderQuery | None = None, - includes: ChapterIncludes | None = ChapterIncludes(), + includes: ChapterIncludes | None = None, ) -> ChapterFeed: """|coro| @@ -1212,9 +1239,9 @@ async def get_chapters( updated_at_since=updated_at_since, published_at_since=published_at_since, order=order, - includes=includes, + includes=includes or ChapterIncludes(), ) - from .chapter import Chapter + from .chapter import Chapter # noqa: PLC0415 # cyclic import cheat chapters.extend([Chapter(self._http, item) for item in data["data"]]) @@ -1263,7 +1290,7 @@ async def submit_draft(self, *, version: int) -> Manga: data = await self._http.submit_manga_draft(self.id, version=version) return self.__class__(self._http, data["data"]) - async def get_relations(self, *, includes: MangaIncludes | None = MangaIncludes()) -> MangaRelationCollection: + async def get_relations(self, *, includes: MangaIncludes | None = None) -> MangaRelationCollection: """|coro| This method will return a list of all relations to a given manga. @@ -1283,13 +1310,18 @@ async def get_relations(self, *, includes: MangaIncludes | None = MangaIncludes( -------- :class:`~hondana.MangaRelationCollection` """ - data = await self._http.get_manga_relation_list(self.id, includes=includes) + data = await self._http.get_manga_relation_list(self.id, includes=includes or MangaIncludes()) fmt = [MangaRelation(self._http, self.id, item) for item in data["data"]] return MangaRelationCollection(self._http, data, fmt) @require_authentication async def upload_cover( - self, *, cover: bytes, volume: str | None = None, description: str, locale: LanguageCode | None = None + self, + *, + cover: bytes, + volume: str | None = None, + description: str, + locale: LanguageCode | None = None, ) -> Cover: """|coro| @@ -1429,14 +1461,14 @@ class MangaRelation: """ __slots__ = ( - "_http", - "_data", "_attributes", + "_data", + "_http", "_relationships", - "source_manga_id", "id", - "version", "relation_type", + "source_manga_id", + "version", ) def __init__(self, http: HTTPClient, parent_id: str, payload: manga.MangaRelation, /) -> None: @@ -1452,6 +1484,9 @@ def __init__(self, http: HTTPClient, parent_id: str, payload: manga.MangaRelatio def __repr__(self) -> str: return f"" + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, (Manga, MangaRelation)) and (self.id == other.id or self.source_manga_id == other.id) @@ -1479,20 +1514,21 @@ class MangaStatistics: .. note:: - The :attr:`distribution` attribute will be None unless this object was created with :meth:`hondana.Client.get_manga_statistics` or :meth:`hondana.Manga.get_statistics` + The :attr:`distribution` attribute will be None unless this object was + created with :meth:`hondana.Client.get_manga_statistics` or :meth:`hondana.Manga.get_statistics` """ __slots__ = ( - "_http", - "_data", - "_rating", "_comments", "_cs_comments", - "follows", - "parent_id", + "_data", + "_http", + "_rating", "average", "bayesian", "distribution", + "follows", + "parent_id", ) def __init__(self, http: HTTPClient, parent_id: str, payload: MangaStatisticsResponse | BatchStatisticsResponse) -> None: @@ -1521,6 +1557,8 @@ def comments(self) -> MangaComments | None: if self._comments: return MangaComments(self._http, self._comments, self.parent_id) + return None + class MangaRating: """ @@ -1537,11 +1575,11 @@ class MangaRating: """ __slots__ = ( - "_http", "_data", + "_http", + "created_at", "parent_id", "rating", - "created_at", ) def __init__(self, http: HTTPClient, parent_id: str, payload: PersonalMangaRatingsResponse) -> None: diff --git a/hondana/query.py b/hondana/query.py index 0af9cf5..6c7fc5e 100644 --- a/hondana/query.py +++ b/hondana/query.py @@ -34,23 +34,23 @@ __all__ = ( - "Order", - "MangaListOrderQuery", - "FeedOrderQuery", - "MangaDraftListOrderQuery", - "CoverArtListOrderQuery", - "ScanlatorGroupListOrderQuery", - "AuthorListOrderQuery", - "UserListOrderQuery", - "ReportListOrderQuery", "ArtistIncludes", "AuthorIncludes", + "AuthorListOrderQuery", "ChapterIncludes", + "CoverArtListOrderQuery", "CoverIncludes", "CustomListIncludes", + "FeedOrderQuery", + "MangaDraftListOrderQuery", "MangaIncludes", + "MangaListOrderQuery", + "Order", + "ReportListOrderQuery", "ScanlatorGroupIncludes", + "ScanlatorGroupListOrderQuery", "SubscriptionIncludes", + "UserListOrderQuery", ) @@ -59,7 +59,8 @@ class _OrderQuery: def __init__(self, **kwargs: Order) -> None: if not kwargs: - raise TypeError("You must pass valid kwargs.") + msg = "You must pass valid kwargs." + raise TypeError(msg) _fmt: list[str] = [] for name, value in kwargs.items(): @@ -69,7 +70,8 @@ def __init__(self, **kwargs: Order) -> None: _fmt.append(name) if _fmt: - raise TypeError(f"You have passed invalid kwargs: {', '.join(_fmt)}") + msg = f"You have passed invalid kwargs: {', '.join(_fmt)}" + raise TypeError(msg) def __repr__(self) -> str: opt: list[str] = [] @@ -114,7 +116,12 @@ def __repr__(self) -> str: @classmethod def all(cls: type[Self]) -> Self: - """A factory method that returns all possible reference expansions for this type.""" + """A factory method that returns all possible reference expansions for this type. + + Returns + -------- + An Includes type object with all flags set. + """ self = cls() for item in self.__slots__: @@ -124,7 +131,12 @@ def all(cls: type[Self]) -> Self: @classmethod def none(cls: type[Self]) -> Self: - """A factory method that disables all possible reference expansions for this type.""" + """A factory method that disables all possible reference expansions for this type. + + Returns + -------- + An Includes type object with no flags set. + """ self = cls() for item in self.__slots__: @@ -154,13 +166,13 @@ class MangaListOrderQuery(_OrderQuery): """ __slots__ = ( - "title", - "year", "created_at", - "updated_at", "latest_uploaded_chapter", - "relevance", "rating", + "relevance", + "title", + "updated_at", + "year", ) title: Order | None @@ -194,12 +206,12 @@ class FeedOrderQuery(_OrderQuery): """ __slots__ = ( + "chapter", "created_at", - "updated_at", "publish_at", "readable_at", + "updated_at", "volume", - "chapter", ) created_at: Order | None @@ -228,10 +240,10 @@ class MangaDraftListOrderQuery(_OrderQuery): """ __slots__ = ( - "title", - "year", "created_at", + "title", "updated_at", + "year", ) title: Order | None @@ -286,11 +298,11 @@ class ScanlatorGroupListOrderQuery(_OrderQuery): """ __slots__ = ( - "name", "created_at", - "updated_at", "followed_count", + "name", "relevance", + "updated_at", ) name: Order | None @@ -419,8 +431,8 @@ class ChapterIncludes(_Includes): __slots__ = ( "manga", - "user", "scanlation_group", + "user", ) def __init__(self, *, manga: bool = True, user: bool = True, scanlation_group: bool = True) -> None: @@ -487,8 +499,8 @@ class CustomListIncludes(_Includes): __slots__ = ( "manga", - "user", "owner", + "user", ) def __init__(self, *, manga: bool = True, user: bool = True, owner: bool = True) -> None: @@ -497,7 +509,13 @@ def __init__(self, *, manga: bool = True, user: bool = True, owner: bool = True) self.owner: bool = owner def to_query(self) -> list[str]: - """Returns a list of valid query strings.""" + """Returns a list of valid query strings. + + Returns + -------- + List[:class:`str`] + The formatted query string. + """ return super().to_query() @@ -518,8 +536,8 @@ class MangaIncludes(_Includes): """ __slots__ = ( - "author", "artist", + "author", "cover_art", "manga", ) @@ -530,16 +548,6 @@ def __init__(self, *, author: bool = True, artist: bool = True, cover_art: bool self.cover_art: bool = cover_art self.manga: bool = manga - def to_query(self) -> list[str]: - """Returns a list of valid query strings. - - Returns - -------- - List[:class:`str`] - The list of query parameters (pre-PHP formatting). - """ - return super().to_query() - class ScanlatorGroupIncludes(_Includes): """ @@ -586,8 +594,8 @@ class UserReportIncludes(_Includes): """ __slots__ = ( - "user", "reason", + "user", ) def __init__(self, *, user: bool = True, reason: bool = True) -> None: diff --git a/hondana/relationship.py b/hondana/relationship.py index 7220f5f..b0d65c3 100644 --- a/hondana/relationship.py +++ b/hondana/relationship.py @@ -55,16 +55,16 @@ class Relationship: __slots__ = ( "_data", + "attributes", "id", "type", - "attributes", ) def __init__(self, payload: RelationshipResponse) -> None: self._data: RelationshipResponse = payload self.id: str = self._data["id"] self.type: str = self._data["type"] - self.attributes: Mapping[str, Any] = self._data.pop("attributes", {}) # pyright: ignore # can't pop from a TypedDict + self.attributes: Mapping[str, Any] = self._data.pop("attributes", {}) # pyright: ignore[reportAttributeAccessIssue,reportCallIssue,reportArgumentType] # can't pop from a TypedDict def __repr__(self) -> str: return f"" diff --git a/hondana/report.py b/hondana/report.py index 0e38a88..7e220c9 100644 --- a/hondana/report.py +++ b/hondana/report.py @@ -45,8 +45,8 @@ __all__ = ( - "ReportDetails", "Report", + "ReportDetails", "UserReport", ) @@ -65,23 +65,33 @@ class ReportDetails: target_id: :class:`str` The ID of the object we are reporting. E.g. the chapter's ID, or the scanlator group's ID. - """ + """ # noqa: E501 # required for documentation formatting __slots__ = ( "category", - "reason", "details", + "reason", "target_id", ) @overload def __init__( - self, *, category: Literal[ReportCategory.author], reason: AuthorReportReason, details: ..., target_id: ... + self, + *, + category: Literal[ReportCategory.author], + reason: AuthorReportReason, + details: ..., + target_id: ..., ) -> None: ... @overload def __init__( - self, *, category: Literal[ReportCategory.chapter], reason: ChapterReportReason, details: ..., target_id: ... + self, + *, + category: Literal[ReportCategory.chapter], + reason: ChapterReportReason, + details: ..., + target_id: ..., ) -> None: ... @overload @@ -96,12 +106,22 @@ def __init__( @overload def __init__( - self, *, category: Literal[ReportCategory.manga], reason: MangaReportReason, details: ..., target_id: ... + self, + *, + category: Literal[ReportCategory.manga], + reason: MangaReportReason, + details: ..., + target_id: ..., ) -> None: ... @overload def __init__( - self, *, category: Literal[ReportCategory.user], reason: UserReportReason, details: ..., target_id: ... + self, + *, + category: Literal[ReportCategory.user], + reason: UserReportReason, + details: ..., + target_id: ..., ) -> None: ... def __init__( @@ -118,7 +138,13 @@ def __init__( self.target_id: str = target_id def __repr__(self) -> str: - return f"" + return ( + "" + ) class Report: @@ -138,13 +164,13 @@ class Report: """ __slots__ = ( - "_http", - "_data", "_attributes", + "_data", + "_http", + "category", + "details_required", "id", "reason", - "details_required", - "category", "version", ) @@ -164,6 +190,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return f"Report for {str(self.category).title()} and reason: {self.reason}" + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, Report) and self.id == other.id @@ -188,14 +217,14 @@ class UserReport: """ __slots__ = ( - "_http", - "_data", "_attributes", - "id", + "_created_at", + "_data", + "_http", "details", + "id", "object_id", "status", - "_created_at", ) def __init__(self, http: HTTPClient, payload: UserReportReasonResponse) -> None: @@ -211,6 +240,9 @@ def __init__(self, http: HTTPClient, payload: UserReportReasonResponse) -> None: def __repr__(self) -> str: return f"" + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, UserReport) and self.id == other.id diff --git a/hondana/scanlator_group.py b/hondana/scanlator_group.py index a144b20..91009ac 100644 --- a/hondana/scanlator_group.py +++ b/hondana/scanlator_group.py @@ -37,7 +37,7 @@ from .types_.scanlator_group import ScanlationGroupResponse from .types_.statistics import CommentMetaData, StatisticsCommentsResponse from .types_.user import UserResponse - from .user import User # noqa: TCH004 + from .user import User # noqa: TC004 __all__ = ( "ScanlatorGroup", @@ -90,35 +90,35 @@ class ScanlatorGroup: """ __slots__ = ( - "_http", - "_data", + "__leader", + "__members", "_attributes", - "id", - "name", + "_created_at", + "_data", + "_http", + "_leader_relationship", + "_member_relationships", + "_publish_delay", + "_stats", + "_updated_at", "alt_names", - "website", - "irc_server", - "irc_channel", - "discord", - "focused_languages", "contact_email", "description", - "twitter", - "manga_updates", + "discord", + "ex_licensed", + "focused_languages", + "id", + "inactive", + "irc_channel", + "irc_server", "locked", + "manga_updates", + "name", "official", + "twitter", "verified", - "inactive", - "ex_licensed", "version", - "_created_at", - "_updated_at", - "_publish_delay", - "_stats", - "_leader_relationship", - "_member_relationships", - "__leader", - "__members", + "website", ) def __init__(self, http: HTTPClient, payload: ScanlationGroupResponse) -> None: @@ -149,10 +149,12 @@ def __init__(self, http: HTTPClient, payload: ScanlationGroupResponse) -> None: self._publish_delay: str = self._attributes["publishDelay"] self._stats: ScanlatorGroupStatistics | None = None self._leader_relationship: UserResponse | None = RelationshipResolver["UserResponse"]( - relationships, "leader" + relationships, + "leader", ).resolve(with_fallback=True)[0] self._member_relationships: list[UserResponse] = RelationshipResolver["UserResponse"]( - relationships, "member" + relationships, + "member", ).resolve() self.__leader: User | None = None self.__members: list[User] | None = None @@ -163,6 +165,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.name + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, ScanlatorGroup) and self.id == other.id @@ -224,6 +229,8 @@ def publish_delay(self) -> datetime.timedelta | None: if self._publish_delay: return iso_to_delta(self._publish_delay) + return None + @property def leader(self) -> User | None: """The leader of this scanlation group, if any. @@ -237,15 +244,17 @@ def leader(self) -> User | None: return self.__leader if not self._leader_relationship: - return + return None if "attributes" in self._leader_relationship: - from .user import User + from .user import User # noqa: PLC0415 # cyclic import cheat user = User(self._http, self._leader_relationship) self.__leader = user return self.__leader + return None + @leader.setter def leader(self, other: User) -> None: self.__leader = other @@ -263,18 +272,18 @@ def members(self) -> list[User] | None: return self.__members if not self._member_relationships: - return + return None fmt: list[User] = [] for relationship in self._member_relationships: - from .user import User + from .user import User # noqa: PLC0415 # cyclic import cheat if "attributes" in relationship: fmt.append(User(self._http, relationship)) if not fmt: - return + return None self.__members = fmt return self.__members @@ -302,12 +311,12 @@ async def get_leader(self) -> User | None: return self.leader if not self._leader_relationship: - return + return None leader_id = self._leader_relationship["id"] data = await self._http.get_user(leader_id) - from .user import User + from .user import User # noqa: PLC0415 # cyclic import cheat leader = User(self._http, data["data"]) self.__leader = leader @@ -335,7 +344,7 @@ async def get_members(self) -> list[User] | None: return self.members if not self._member_relationships: - return + return None ids = [r["id"] for r in self._member_relationships] @@ -453,7 +462,8 @@ async def update( .. note:: - The ``website``, ``irc_server``, ``irc_channel``, ``discord``, ``contact_email``, ``description``, ``twitter``, ``manga_updates``, ``focused_language`` and ``publish_delay`` + The ``website``, ``irc_server``, ``irc_channel``, ``discord``, ``contact_email``, ``description``, + ``twitter``, ``manga_updates``, ``focused_language`` and ``publish_delay`` keys are all nullable in the API. To do so pass ``None`` explicitly to these keys. .. note:: @@ -524,10 +534,10 @@ class ScanlatorGroupStatistics: """ __slots__ = ( - "_http", - "_data", "_comments", "_cs_comments", + "_data", + "_http", "parent_id", ) @@ -551,3 +561,5 @@ def comments(self) -> ScanlatorGroupComments | None: """ if self._comments: return ScanlatorGroupComments(self._http, self._comments, self.parent_id) + + return None diff --git a/hondana/tags.py b/hondana/tags.py index f8bf63e..5e79f30 100644 --- a/hondana/tags.py +++ b/hondana/tags.py @@ -37,8 +37,8 @@ __all__ = ( - "Tag", "QueryTags", + "Tag", ) logger: logging.Logger = logging.getLogger("hondana") @@ -67,15 +67,15 @@ class Tag: """ __slots__ = ( - "_data", "_attributes", - "_relationships", - "_name", + "_cs_relationships", + "_data", "_description", - "id", + "_name", + "_relationships", "group", + "id", "version", - "_cs_relationships", ) def __init__(self, payload: TagResponse) -> None: @@ -94,6 +94,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.name + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, Tag) and self.id == other.id @@ -179,15 +182,16 @@ class QueryTags: """ __slots__ = ( - "tags", "mode", + "tags", ) def __init__(self, *tags: str, mode: Literal["AND", "OR"] = "AND") -> None: self.tags: list[str] = [] self.mode: str = mode.upper() if self.mode not in {"AND", "OR"}: - raise TypeError("Tags mode has to be 'AND' or 'OR'.") + msg = "Tags mode has to be 'AND' or 'OR'." + raise TypeError(msg) self._set_tags(tags) def __repr__(self) -> str: @@ -202,7 +206,8 @@ def _set_tags(self, tags: tuple[str, ...]) -> list[str]: logger.warning("Tag '%s' cannot be found in the local tag cache, skipping.", tag) if not resolved_tags: - raise ValueError("No tags passed matched any valid MangaDex tags.") + msg = "No tags passed matched any valid MangaDex tags." + raise ValueError(msg) self.tags = resolved_tags return self.tags diff --git a/hondana/types_/artist.py b/hondana/types_/artist.py index 218fef1..a5e0b29 100644 --- a/hondana/types_/artist.py +++ b/hondana/types_/artist.py @@ -34,10 +34,10 @@ __all__ = ( - "ArtistResponse", "ArtistAttributesResponse", - "GetSingleArtistResponse", + "ArtistResponse", "GetMultiArtistResponse", + "GetSingleArtistResponse", ) diff --git a/hondana/types_/auth.py b/hondana/types_/auth.py index 94ad2a5..22d9544 100644 --- a/hondana/types_/auth.py +++ b/hondana/types_/auth.py @@ -27,10 +27,10 @@ from typing import Literal, TypedDict __all__ = ( - "TokenResponse", + "CheckPayload", "LoginPayload", "RefreshPayload", - "CheckPayload", + "TokenResponse", ) diff --git a/hondana/types_/author.py b/hondana/types_/author.py index d3ef41b..02935c7 100644 --- a/hondana/types_/author.py +++ b/hondana/types_/author.py @@ -36,8 +36,8 @@ __all__ = ( "AuthorAttributesResponse", "AuthorResponse", - "GetSingleAuthorResponse", "GetMultiAuthorResponse", + "GetSingleAuthorResponse", ) diff --git a/hondana/types_/chapter.py b/hondana/types_/chapter.py index b97c745..88aac58 100644 --- a/hondana/types_/chapter.py +++ b/hondana/types_/chapter.py @@ -34,13 +34,13 @@ __all__ = ( "ChapterAttributesResponse", + "ChapterReadHistoryResponse", + "ChapterReadResponse", "ChapterResponse", - "GetSingleChapterResponse", - "GetMultiChapterResponse", - "GetAtHomeResponse", "GetAtHomeChapterResponse", - "ChapterReadResponse", - "ChapterReadHistoryResponse", + "GetAtHomeResponse", + "GetMultiChapterResponse", + "GetSingleChapterResponse", ) diff --git a/hondana/types_/cover.py b/hondana/types_/cover.py index 406b977..f150e2c 100644 --- a/hondana/types_/cover.py +++ b/hondana/types_/cover.py @@ -34,10 +34,10 @@ __all__ = ( - "CoverResponse", "CoverAttributesResponse", - "GetSingleCoverResponse", + "CoverResponse", "GetMultiCoverResponse", + "GetSingleCoverResponse", ) diff --git a/hondana/types_/custom_list.py b/hondana/types_/custom_list.py index a3ecb4e..4ab04fe 100644 --- a/hondana/types_/custom_list.py +++ b/hondana/types_/custom_list.py @@ -33,8 +33,8 @@ __all__ = ( "CustomListAttributesResponse", "CustomListResponse", - "GetSingleCustomListResponse", "GetMultiCustomListResponse", + "GetSingleCustomListResponse", ) _CustomListVisibility = Literal["public", "private"] diff --git a/hondana/types_/errors.py b/hondana/types_/errors.py index 98ac560..8cc5eaf 100644 --- a/hondana/types_/errors.py +++ b/hondana/types_/errors.py @@ -24,7 +24,7 @@ from typing import Literal, TypedDict -__all__ = ("ErrorType", "APIError") +__all__ = ("APIError", "ErrorType") class ErrorType(TypedDict): diff --git a/hondana/types_/legacy.py b/hondana/types_/legacy.py index 8e56f42..a81c19a 100644 --- a/hondana/types_/legacy.py +++ b/hondana/types_/legacy.py @@ -31,10 +31,10 @@ __all__ = ( - "LegacyMappingType", + "GetLegacyMappingResponse", "LegacyMappingAttributesResponse", "LegacyMappingResponse", - "GetLegacyMappingResponse", + "LegacyMappingType", ) diff --git a/hondana/types_/manga.py b/hondana/types_/manga.py index 1e7db67..136b827 100644 --- a/hondana/types_/manga.py +++ b/hondana/types_/manga.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: from typing import NotRequired - from ..enums import ( + from hondana.enums import ( ContentRating as _ContentRating, MangaRelationType as _MangaRelationType, MangaState as _MangaState, @@ -37,28 +37,29 @@ PublicationDemographic as _PublicationDemographic, ReadingStatus as _ReadingStatus, ) + from .common import LanguageCode, LocalizedString from .relationship import RelationshipResponse from .tags import TagResponse __all__ = ( - "MangaLinks", + "ChaptersResponse", + "GetMangaResponse", + "GetMangaVolumesAndChaptersResponse", "MangaAttributesResponse", + "MangaGroupedReadMarkersResponse", + "MangaLinks", + "MangaMultipleReadingStatusResponse", + "MangaReadMarkersResponse", + "MangaRelation", "MangaRelationAttributesResponse", - "MangaResponse", - "GetMangaResponse", "MangaRelationCreateResponse", - "MangaSearchResponse", - "MangaRelation", "MangaRelationResponse", - "ChaptersResponse", - "VolumesAndChaptersResponse", - "GetMangaVolumesAndChaptersResponse", - "MangaReadMarkersResponse", - "MangaGroupedReadMarkersResponse", + "MangaResponse", + "MangaSearchResponse", "MangaSingleReadingStatusResponse", - "MangaMultipleReadingStatusResponse", + "VolumesAndChaptersResponse", ) diff --git a/hondana/types_/relationship.py b/hondana/types_/relationship.py index 6b524bf..3ce3ca4 100644 --- a/hondana/types_/relationship.py +++ b/hondana/types_/relationship.py @@ -33,7 +33,7 @@ __all__ = ("RelationshipResponse",) -RelationshipResponse: TypeAlias = "artist.ArtistResponse | author.AuthorResponse | chapter.ChapterResponse | cover.CoverResponse | manga.MangaResponse | scanlator_group.ScanlationGroupResponse | user.UserResponse" +RelationshipResponse: TypeAlias = "artist.ArtistResponse | author.AuthorResponse | chapter.ChapterResponse | cover.CoverResponse | manga.MangaResponse | scanlator_group.ScanlationGroupResponse | user.UserResponse" # noqa: E501 # cannot be formatted otherwise """ id: :class:`str` diff --git a/hondana/types_/report.py b/hondana/types_/report.py index 9381cef..1ce1883 100644 --- a/hondana/types_/report.py +++ b/hondana/types_/report.py @@ -27,15 +27,16 @@ from typing import TYPE_CHECKING, Literal, TypedDict if TYPE_CHECKING: - from ..enums import ReportStatus + from hondana.enums import ReportStatus + from .common import LocalizedString from .relationship import RelationshipResponse __all__ = ( "GetReportReasonAttributesResponse", - "ReportReasonResponse", "GetReportReasonResponse", + "ReportReasonResponse", ) diff --git a/hondana/types_/scanlator_group.py b/hondana/types_/scanlator_group.py index f6fd384..cda2a5b 100644 --- a/hondana/types_/scanlator_group.py +++ b/hondana/types_/scanlator_group.py @@ -33,10 +33,10 @@ __all__ = ( + "GetMultiScanlationGroupResponse", + "GetSingleScanlationGroupResponse", "ScanlationGroupAttributesResponse", "ScanlationGroupResponse", - "GetSingleScanlationGroupResponse", - "GetMultiScanlationGroupResponse", ) diff --git a/hondana/types_/settings.py b/hondana/types_/settings.py index a8cf3d7..ffc187f 100644 --- a/hondana/types_/settings.py +++ b/hondana/types_/settings.py @@ -34,8 +34,6 @@ class Settings(TypedDict): This object is currently not documented. """ - ... - class SettingsPayload(TypedDict): """ diff --git a/hondana/types_/statistics.py b/hondana/types_/statistics.py index 4a83aae..8941e3a 100644 --- a/hondana/types_/statistics.py +++ b/hondana/types_/statistics.py @@ -27,16 +27,16 @@ from typing import Literal, TypedDict __all__ = ( - "StatisticsRatingResponse", + "BatchGetStatisticsResponse", "BatchStatisticsRatingResponse", - "MangaStatisticsResponse", "BatchStatisticsResponse", + "CommentMetaData", + "GetCommentsStatisticsResponse", "GetMangaStatisticsResponse", - "BatchGetStatisticsResponse", - "PersonalMangaRatingsResponse", "GetPersonalMangaRatingsResponse", - "GetCommentsStatisticsResponse", - "CommentMetaData", + "MangaStatisticsResponse", + "PersonalMangaRatingsResponse", + "StatisticsRatingResponse", ) diff --git a/hondana/types_/tags.py b/hondana/types_/tags.py index c8b4465..99ee858 100644 --- a/hondana/types_/tags.py +++ b/hondana/types_/tags.py @@ -32,9 +32,9 @@ __all__ = ( - "TagResponse", "GetTagListResponse", "TagAttributesResponse", + "TagResponse", ) diff --git a/hondana/types_/upload.py b/hondana/types_/upload.py index 8176fe8..af28ed1 100644 --- a/hondana/types_/upload.py +++ b/hondana/types_/upload.py @@ -32,15 +32,15 @@ __all__ = ( - "UploadSessionAttributes", - "GetUploadSessionResponse", + "BeginChapterUploadResponse", "ChapterUploadAttributes", "ChapterUploadData", - "BeginChapterUploadResponse", - "UploadedChapterPageAttributes", + "GetCheckApprovalRequired", + "GetUploadSessionResponse", + "UploadSessionAttributes", "UploadedChapterDataResponse", + "UploadedChapterPageAttributes", "UploadedChapterResponse", - "GetCheckApprovalRequired", ) diff --git a/hondana/types_/user.py b/hondana/types_/user.py index d770cbc..7f0a45a 100644 --- a/hondana/types_/user.py +++ b/hondana/types_/user.py @@ -33,10 +33,10 @@ __all__ = ( + "GetMultiUserResponse", + "GetSingleUserResponse", "GetUserAttributesResponse", "UserResponse", - "GetSingleUserResponse", - "GetMultiUserResponse", ) diff --git a/hondana/user.py b/hondana/user.py index a7e5225..5178513 100644 --- a/hondana/user.py +++ b/hondana/user.py @@ -32,7 +32,7 @@ if TYPE_CHECKING: from .http import HTTPClient - from .scanlator_group import ScanlatorGroup # noqa: TCH004 + from .scanlator_group import ScanlatorGroup # noqa: TC004 from .types_.relationship import RelationshipResponse from .types_.scanlator_group import ScanlationGroupResponse from .types_.token import TokenPayload @@ -70,14 +70,14 @@ class UserInfo: """ __slots__ = ( - "type", - "issuer", "audience", - "issued_at", "expires", - "user_id", + "issued_at", + "issuer", "roles", "sid", + "type", + "user_id", ) def __init__(self, payload: TokenPayload) -> None: @@ -91,7 +91,16 @@ def __init__(self, payload: TokenPayload) -> None: self.sid: str = payload["sid"] def __repr__(self) -> str: - return f"" + return ( + "" + ) class User: @@ -111,22 +120,23 @@ class User: .. note:: - Unlike most other api objects, this type does not have related relationship properties due to not returning a full ``relationships`` key. + Unlike most other api objects, this type does not have related relationship properties + due to not returning a full ``relationships`` key. """ __slots__ = ( - "_http", - "_data", + "__groups", "_attributes", + "_data", + "_group_relationships", + "_http", "_relationships", "id", + "roles", "username", "version", - "roles", - "_group_relationships", - "__groups", ) def __init__(self, http: HTTPClient, payload: UserResponse) -> None: @@ -139,7 +149,8 @@ def __init__(self, http: HTTPClient, payload: UserResponse) -> None: self.version: int = self._attributes["version"] self.roles: list[str] = self._attributes["roles"] self._group_relationships: list[ScanlationGroupResponse] = RelationshipResolver["ScanlationGroupResponse"]( - relationships, "scanlation_group" + relationships, + "scanlation_group", ).resolve() self.__groups: list[ScanlatorGroup] | None = None @@ -149,6 +160,9 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.username + def __hash__(self) -> int: + return hash(self.id) + def __eq__(self, other: object) -> bool: return isinstance(other, User) and self.id == other.id @@ -177,17 +191,23 @@ async def get_scanlator_groups(self) -> list[ScanlatorGroup] | None: The list of groups for this user, if any. """ if not self._group_relationships: - return + return None ids = [r["id"] for r in self._group_relationships] data = await self._http.scanlation_group_list( - limit=100, offset=0, ids=ids, name=None, focused_language=None, includes=ScanlatorGroupIncludes(), order=None + limit=100, + offset=0, + ids=ids, + name=None, + focused_language=None, + includes=ScanlatorGroupIncludes(), + order=None, ) fmt: list[ScanlatorGroup] = [ScanlatorGroup(self._http, payload) for payload in data["data"]] if not fmt: - return + return None self.__groups = fmt return self.__groups diff --git a/hondana/utils.py b/hondana/utils.py index c40e663..55fada7 100644 --- a/hondana/utils.py +++ b/hondana/utils.py @@ -42,17 +42,17 @@ except ModuleNotFoundError: def to_json(obj: Any, /) -> str: - """A quick method that dumps a Python type to JSON object.""" + """A quick method that dumps a Python type to JSON object.""" # noqa: DOC201 # not part of the public API. return json.dumps(obj, separators=(",", ":"), ensure_ascii=True, indent=2) _from_json = json.loads else: def to_json(obj: Any, /) -> str: - """A quick method that dumps a Python type to JSON object.""" + """A quick method that dumps a Python type to JSON object.""" # noqa: DOC201 # not part of the public API. return orjson.dumps(obj, option=orjson.OPT_INDENT_2).decode("utf-8") - _from_json = orjson.loads # pyright: ignore[] # this is guarded in an if. + _from_json = orjson.loads from .errors import AuthenticationRequired @@ -85,42 +85,42 @@ class SupportsHTTP(Protocol): LOGGER = logging.getLogger(__name__) __all__ = ( - "MANGADEX_URL_REGEX", "MANGADEX_TIME_REGEX", - "AuthorArtistTag", + "MANGADEX_URL_REGEX", + "MANGA_TAGS", "MISSING", + "AuthorArtistTag", + "RelationshipResolver", "Route", + "as_chunks", "cached_slot_property", - "to_json", - "json_or_text", - "php_query_builder", + "clean_isoformat", + "delta_to_iso", "deprecated", - "to_snake_case", - "to_camel_case", + "from_json", "get_image_mime_type", - "as_chunks", - "delta_to_iso", "iso_to_delta", - "RelationshipResolver", - "clean_isoformat", - "MANGA_TAGS", - "from_json", + "json_or_text", + "php_query_builder", + "to_camel_case", + "to_json", + "to_snake_case", ) _PROJECT_DIR = pathlib.Path(__file__) MAX_DEPTH: int = 10_000 MANGADEX_URL_REGEX = re.compile( - r"(?:http[s]?:\/\/)?mangadex\.org\/(?Ptitle|chapter|author|tag)\/(?P[a-z0-9]{8}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{12})\/?(?P.*)" + r"(?:http[s]?:\/\/)?mangadex\.org\/(?P<type>title|chapter|author|tag)\/(?P<ID>[a-z0-9]{8}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{12})\/?(?P<title>.*)", ) r""" ``r"(?:http[s]?:\/\/)?mangadex\.org\/(?P<type>title|chapter|author|tag)\/(?P<ID>[a-z0-9]{8}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{12})\/?(?P<title>.*)"`` This `regex pattern <https://docs.python.org/3/library/re.html#re-objects>`_ can be used to isolate common elements from a MangaDex URL. This means that Manga, Chapter, Author or Tag urls can be parsed for their ``type``, ``ID`` and ``title``. -""" +""" # noqa: E501 MANGADEX_TIME_REGEX = re.compile( - r"^(P(?P<days>[1-9]|[1-9][0-9])D)?(P?(?P<weeks>[1-9])W)?(P?T((?P<hours>[1-9]|1[0-9]|2[0-4])H)?((?P<minutes>[1-9]|[1-5][0-9]|60)M)?((?P<seconds>[1-9]|[1-5][0-9]|60)S)?)?$" + r"^(P(?P<days>[1-9]|[1-9][0-9])D)?(P?(?P<weeks>[1-9])W)?(P?T((?P<hours>[1-9]|1[0-9]|2[0-4])H)?((?P<minutes>[1-9]|[1-5][0-9]|60)M)?((?P<seconds>[1-9]|[1-5][0-9]|60)S)?)?$", ) """ ``r"^(P([1-9]|[1-9][0-9])D)?(P?([1-9])W)?(P?T(([1-9]|1[0-9]|2[0-4])H)?(([1-9]|[1-5][0-9]|60)M)?(([1-9]|[1-5][0-9]|60)S)?)?$"`` @@ -129,12 +129,11 @@ class SupportsHTTP(Protocol): The pattern *is* usable but more meant as a guideline for your formatting. It matches some things like: ``P1D2W`` (1 day, two weeks), ``P1D2WT3H4M`` (1 day, 2 weeks, 3 hours and 4 minutes) -""" +""" # noqa: E501 class AuthorArtistTag: id: str - pass class Route: @@ -152,15 +151,21 @@ class Route: Defaults to ``False``. parameters: Any This is a special cased kwargs. Anything passed to these will substitute it's key to value in the `path`. - E.g. if your `path` is ``"/manga/{manga_id}"``, and your parameters are ``manga_id="..."``, then it will expand into the path - making ``"manga/..."`` + E.g. if your `path` is ``"/manga/{manga_id}"``, and your parameters are ``manga_id="..."``, + then it will expand into the path making ``"manga/..."`` """ API_BASE_URL: ClassVar[str] = "https://api.mangadex.org" API_DEV_BASE_URL: ClassVar[str] = "https://api.mangadex.dev" def __init__( - self, verb: str, path: str, *, base: str | None = None, authenticate: bool = False, **parameters: Any + self, + verb: str, + path: str, + *, + base: str | None = None, + authenticate: bool = False, + **parameters: Any, ) -> None: self.verb: str = verb self.path: str = path @@ -188,8 +193,8 @@ class AuthRoute(Route): Defaults to ``False``. parameters: Any This is a special cased kwargs. Anything passed to these will substitute it's key to value in the `path`. - E.g. if your `path` is ``"/manga/{manga_id}"``, and your parameters are ``manga_id="..."``, then it will expand into the path - making ``"manga/..."`` + E.g. if your `path` is ``"/manga/{manga_id}"``, and your parameters are ``manga_id="..."``, + then it will expand into the path making ``"manga/..."`` """ API_BASE_URL: ClassVar[str] = "https://auth.mangadex.org/realms/mangadex/protocol/openid-connect" @@ -197,7 +202,7 @@ class AuthRoute(Route): class MissingSentinel: - def __eq__(self, _: Any) -> bool: + def __eq__(self, _: object) -> bool: return False def __bool__(self) -> bool: @@ -213,14 +218,14 @@ def __repr__(self) -> str: MISSING: Any = MissingSentinel() -## This class and subsequent decorator have been copied from Rapptz' Discord.py -## (https://github.com/Rapptz/discord.py) -## Credit goes to Rapptz and contributors +# This class and subsequent decorator have been copied from Rapptz' Discord.py +# (https://github.com/Rapptz/discord.py) +# Credit goes to Rapptz and contributors class CachedSlotProperty(Generic[T, T_co]): def __init__(self, name: str, function: Callable[[T], T_co]) -> None: self.name: str = name self.function: Callable[[T], T_co] = function - self.__doc__ = getattr(function, "__doc__") + self.__doc__ = function.__doc__ @overload def __get__(self, instance: None, owner: type[T]) -> CachedSlotProperty[T, T_co]: ... @@ -248,12 +253,13 @@ def decorator(func: Callable[[T], T_co]) -> CachedSlotProperty[T, T_co]: def require_authentication(func: Callable[Concatenate[C, B], T]) -> Callable[Concatenate[C, B], T]: - """A decorator to raise on authentication methods.""" + """A decorator to raise on authentication methods.""" # noqa: DOC201 # not part of the public API. @wraps(func) def wrapper(item: C, *args: B.args, **kwargs: B.kwargs) -> T: if not item._http._authenticated: # pyright: ignore[reportPrivateUsage] # we're gonna keep this private - raise AuthenticationRequired("This method requires authentication.") + msg = "This method requires authentication." + raise AuthenticationRequired(msg) return func(item, *args, **kwargs) @@ -267,7 +273,7 @@ def deprecated(alternate: str | None = None, /) -> Callable[[Callable[B, T]], Ca ----------- alternate: Optional[:class:`str`] The alternate method to use. - """ + """ # noqa: DOC201 # not part of the public API. def decorator(func: Callable[B, T]) -> Callable[B, T]: @wraps(func) @@ -310,7 +316,8 @@ def calculate_limits(limit: int, offset: int, /, *, max_limit: int = 100) -> tup Tuple[:class:`int`, :class:`int`] """ if offset >= MAX_DEPTH: - raise ValueError(f"An offset of {MAX_DEPTH} will not return results.") + msg = f"An offset of {MAX_DEPTH} will not return results." + raise ValueError(msg) offset = max(offset, 0) @@ -327,7 +334,13 @@ def calculate_limits(limit: int, offset: int, /, *, max_limit: int = 100) -> tup async def json_or_text(response: aiohttp.ClientResponse, /) -> dict[str, Any] | str: - """A quick method to parse a `aiohttp.ClientResponse` and test if it's json or text.""" + """A quick method to parse a `aiohttp.ClientResponse` and test if it's json or text. + + Returns + -------- + Union[Dict[:class:`str`, Any], str] + The parsed json object as a dictionary, or the response text. + """ text = await response.text(encoding="utf-8") try: if response.headers["content-type"] == "application/json": @@ -351,7 +364,7 @@ def php_query_builder(obj: MANGADEX_QUERY_PARAM_TYPE, /) -> multidict.MultiDict[ -------- :class:`multidict.MultiDict` A dictionary/mapping type that allows for duplicate keys. - """ + """ # noqa: E501 # required for formatting fmt = multidict.MultiDict[str | int]() for key, value in obj.items(): if value is None: @@ -373,21 +386,37 @@ def php_query_builder(obj: MANGADEX_QUERY_PARAM_TYPE, /) -> multidict.MultiDict[ def get_image_mime_type(data: bytes, /) -> str: - """Returns the image type from the first few bytes.""" + """Returns the image type from the first few bytes. + + Raises + ------- + :exc:`ValueError` + Unsupported image type used. + + Returns + -------- + :class:`str` + The mime type of the image data. + """ if data.startswith(b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"): return "image/png" - elif data[:3] == b"\xff\xd8\xff" or data[6:10] in (b"JFIF", b"Exif"): + if data[:3] == b"\xff\xd8\xff" or data[6:10] in (b"JFIF", b"Exif"): return "image/jpeg" - elif data.startswith((b"\x47\x49\x46\x38\x37\x61", b"\x47\x49\x46\x38\x39\x61")): + if data.startswith((b"\x47\x49\x46\x38\x37\x61", b"\x47\x49\x46\x38\x39\x61")): return "image/gif" - # elif data.startswith(b"RIFF") and data[8:12] == b"WEBP": - # return "image/webp" - else: - raise ValueError("Unsupported image type given") + + msg = "Unsupported image type given" + raise ValueError(msg) def to_snake_case(string: str, /) -> str: - """Quick function to return snake_case from camelCase.""" + """Quick function to return snake_case from camelCase. + + Returns + -------- + :class:`str` + The formatted string. + """ fmt: list[str] = [] for character in string: if character.isupper(): @@ -398,7 +427,13 @@ def to_snake_case(string: str, /) -> str: def to_camel_case(string: str, /) -> str: - """Quick function to return camelCase from snake_case.""" + """Quick function to return camelCase from snake_case. + + Returns + -------- + :class:`str` + The formatted string. + """ first, *rest = string.split("_") chunks = [first.lower(), *map(str.capitalize, rest)] @@ -475,7 +510,8 @@ def iso_to_delta(iso: str, /) -> datetime.timedelta: The timedelta based on the parsed string. """ if (match := MANGADEX_TIME_REGEX.fullmatch(iso)) is None: - raise TypeError("The passed string does not match the regex pattern.") + msg = "The passed string does not match the regex pattern." + raise TypeError(msg) match_dict = match.groupdict() @@ -498,9 +534,9 @@ def iso_to_delta(iso: str, /) -> datetime.timedelta: class RelationshipResolver(Generic[T]): __slots__ = ( - "relationships", - "limit", "_type", + "limit", + "relationships", ) def __init__(self, relationships: list[RelationshipResponse], relationship_type: RelType, /) -> None: @@ -568,7 +604,8 @@ def upload_file_sort(key: SupportsRichComparison) -> tuple[int, str]: if isinstance(key, pathlib.Path) and (match := _PATH_WITH_EXTRA.fullmatch(key.name)): return (len(match["num"]), match["num"]) - raise ValueError("Invalid filename format given.") + msg = "Invalid filename format given." + raise ValueError(msg) _tags_path: pathlib.Path = _PROJECT_DIR.parent / "extras" / "tags.json" diff --git a/tests/test_chapter.py b/tests/test_chapter.py index 125de77..31eddde 100644 --- a/tests/test_chapter.py +++ b/tests/test_chapter.py @@ -38,7 +38,7 @@ def test_attributes(self) -> None: chapter = clone_chapter() for item in PAYLOAD["data"]["attributes"]: if item == "publishAt": - item = "publishedAt" # special cased because it's the only attribute that is future tense, i.e. created_at, updated_at vs publish_at. + item = "publishedAt" # noqa: PLW2901 # special cased because it's the only attribute that is future tense, i.e. created_at, updated_at vs publish_at. assert hasattr(chapter, to_snake_case(item)) def test_relationship_length(self) -> None: diff --git a/tests/test_collection.py b/tests/test_collection.py index ee5c7b7..52cb3c3 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -106,36 +106,36 @@ def clone_collection(type_: CollectionType, /) -> BaseCollection[Any]: author_payload: GetMultiAuthorResponse = json.load(path.open()) authors = [Author(HTTP, item) for item in author_payload["data"]] return AuthorCollection(HTTP, author_payload, authors=authors) - elif type_ == "chapter_feed": + if type_ == "chapter_feed": chapter_payload: GetMultiChapterResponse = json.load(path.open()) chapters = [Chapter(HTTP, item) for item in chapter_payload["data"]] return ChapterFeed(HTTP, chapter_payload, chapters=chapters) - elif type_ == "cover": + if type_ == "cover": cover_payload: GetMultiCoverResponse = json.load(path.open()) covers = [Cover(HTTP, item) for item in cover_payload["data"]] return CoverCollection(HTTP, cover_payload, covers=covers) - elif type_ == "custom_list": + if type_ == "custom_list": custom_list_payload: GetMultiCustomListResponse = json.load(path.open()) custom_lists = [CustomList(HTTP, item) for item in custom_list_payload["data"]] return CustomListCollection(HTTP, custom_list_payload, lists=custom_lists) - elif type_ == "legacy_mapping": + if type_ == "legacy_mapping": mapping_payload: GetLegacyMappingResponse = json.load(path.open()) mappings = [LegacyItem(HTTP, item) for item in mapping_payload["data"]] return LegacyMappingCollection(HTTP, mapping_payload, mappings=mappings) - elif type_ == "manga": + if type_ == "manga": manga_payload: MangaSearchResponse = json.load(path.open()) manga: list[Manga] = [Manga(HTTP, item) for item in manga_payload["data"]] return MangaCollection(HTTP, manga_payload, manga=manga) - elif type_ == "manga_relation": + if type_ == "manga_relation": relation_payload: MangaRelationResponse = json.load(path.open()) parent_id: str = "" manga_relation: list[MangaRelation] = [MangaRelation(HTTP, parent_id, item) for item in relation_payload["data"]] return MangaRelationCollection(HTTP, relation_payload, relations=manga_relation) - elif type_ == "scanlator_group": + if type_ == "scanlator_group": group_payload: GetMultiScanlationGroupResponse = json.load(path.open()) groups = [ScanlatorGroup(HTTP, item) for item in group_payload["data"]] return ScanlatorGroupCollection(HTTP, group_payload, groups=groups) - elif type_ == "user": + if type_ == "user": user_payload: GetMultiUserResponse = json.load(path.open()) users = [User(HTTP, item) for item in user_payload["data"]] return UserCollection(HTTP, user_payload, users=users) diff --git a/tests/test_manga.py b/tests/test_manga.py index 905caa2..e989e83 100644 --- a/tests/test_manga.py +++ b/tests/test_manga.py @@ -54,17 +54,16 @@ def clone_manga( if type_ == "manga": t = deepcopy(PAYLOAD) return Manga(HTTP, t["data"]) - elif type_ == "relation": + if type_ == "relation": t = deepcopy(RELATIONS_PAYLOAD) return MangaRelation(HTTP, PAYLOAD["data"]["id"], t["data"][0]) - elif type_ == "stats": + if type_ == "stats": t = deepcopy(STATISTICS_PAYLOAD) key = next(iter(t["statistics"])) return MangaStatistics(HTTP, PAYLOAD["data"]["id"], t["statistics"][key]) - elif type_ == "rating": - t = deepcopy(RATING_PAYLOAD) - key = next(iter(t["ratings"])) - return MangaRating(HTTP, PAYLOAD["data"]["id"], t["ratings"][key]) + t = deepcopy(RATING_PAYLOAD) + key = next(iter(t["ratings"])) + return MangaRating(HTTP, PAYLOAD["data"]["id"], t["ratings"][key]) class TestManga: @@ -77,9 +76,9 @@ def test_attributes(self) -> None: for item in PAYLOAD["data"]["attributes"]: if item == "altTitles": # special case sane attribute renaming - item = "alternateTitles" + item = "alternateTitles" # noqa: PLW2901 # renaming raw payload items elif item == "isLocked": # special case sane attribute renaming - item = "locked" + item = "locked" # noqa: PLW2901 # renaming raw payload items assert hasattr(manga, to_snake_case(item)) def test_relationship_length(self) -> None: @@ -100,7 +99,7 @@ def test_cache_slot_property(self) -> None: assert not hasattr(manga, "_cs_tags") - manga.tags + _ = manga.tags assert hasattr(manga, "_cs_tags") diff --git a/tests/test_scanlator_group.py b/tests/test_scanlator_group.py index 0a2785f..039b27b 100644 --- a/tests/test_scanlator_group.py +++ b/tests/test_scanlator_group.py @@ -34,7 +34,7 @@ def test_attributes(self) -> None: for item in PAYLOAD["data"]["attributes"]: if item == "publishDelay": - item = "_publish_delay" # we made this a property to allow manipulation + item = "_publish_delay" # noqa: PLW2901 # we made this a property to allow manipulation assert hasattr(group, to_snake_case(item)) def test_relationship_length(self) -> None: diff --git a/tests/test_tags.py b/tests/test_tags.py index 9f1964a..1a977d2 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import operator import pathlib from copy import deepcopy from typing import TYPE_CHECKING, Any @@ -45,10 +46,13 @@ def test_tag_names(self) -> None: for tag, raw_tag in zip( sorted(tags, key=lambda t: t.name), sorted(raw_tags, key=lambda t: t), + strict=False, ): assert tag.name == raw_tag - def test_tag_relationships(self) -> None: # currently, tags have no relationships, but even so. + def test_tag_relationships( + self, + ) -> None: # currently, tags have no relationships, but even so. tags = clone_tags() tag_rels: list[Relationship] = [r for tag in tags for r in tag.relationships] @@ -57,14 +61,24 @@ def test_tag_relationships(self) -> None: # currently, tags have no relationshi obj for rel in PAYLOAD["data"]["attributes"]["tags"] for obj in rel["relationships"] ] - for a, b in zip(sorted(tag_rels, key=lambda r: r.id), sorted(raw_rels, key=lambda r: r["id"])): + for a, b in zip( + sorted(tag_rels, key=lambda r: r.id), + sorted(raw_rels, key=operator.itemgetter("id")), + strict=True, + ): assert a == b - def test_tag_descriptions(self) -> None: # currently, tags have no descriptions, but even so. + def test_tag_descriptions( + self, + ) -> None: # currently, tags have no descriptions, but even so. tags = clone_tags() raw_tags = PAYLOAD["data"]["attributes"]["tags"] - for tag, raw_tag in zip(sorted(tags, key=lambda t: t.id), sorted(raw_tags, key=lambda t: t["id"])): + for tag, raw_tag in zip( + sorted(tags, key=lambda t: t.id), + sorted(raw_tags, key=operator.itemgetter("id")), + strict=True, + ): _description = tag._description # pyright: ignore[reportPrivateUsage] # sorry, need this for test purposes _raw_descriptions = raw_tag["attributes"]["description"] _raw_descriptions = _raw_descriptions or {} @@ -84,7 +98,10 @@ def test_query_tags(self) -> None: tags = QueryTags("Comedy", "Mecha", mode="AND") assert tags.mode == "AND" - assert tags.tags == ["4d32cc48-9f00-4cca-9b5a-a839f0764984", "50880a9d-5440-4732-9afb-8f457127e836"] + assert tags.tags == [ + "4d32cc48-9f00-4cca-9b5a-a839f0764984", + "50880a9d-5440-4732-9afb-8f457127e836", + ] try: tags = QueryTags("SomethingElse", mode="OR")