diff --git a/.python-version b/.python-version index ac957df..92536a9 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10.6 +3.12.0 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7430640 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Enma", + "type": "debugpy", + "request": "launch", + "program": "/home/alexandresenpai/scripts/Enma/main.py", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index ae93530..9426d01 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,9 @@ { "python.analysis.typeCheckingMode": "basic", - "cloudcode.duetAI.inlineSuggestions.enableAuto": false + "cloudcode.duetAI.inlineSuggestions.enableAuto": false, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } \ No newline at end of file diff --git a/README.md b/README.md index 3090fb8..9412960 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ Enma is a Python library designed to fetch manga and doujinshi data from many sources. It provides a unified interface to interact with different manga repositories, making it easier to retrieve manga details, search for manga, paginate through results, and fetch random manga. +## :warning: Warning +> **:exclamation: Important: Enma is not intended for mass querying or placing heavy loads on supported sources. Please use responsibly, adhering to the terms of service of the data sources. Misuse may result in service disruption or access denial.** + ## Requirements - Python 3.9+ @@ -41,17 +44,20 @@ except AssertionError: raise RuntimeError(f"{package_name!r} requires Python {python_major}.{python_minor}+ (You have Python {sys.version})") ``` +## Documentation +You can consult full Enma documentation at https://enma.gitbook.io/enma. + ## Features Comparison -Feature | NHentai | Manganato ------------|---------|----------- -search | ✅ | ✅ -random | ✅ | 🚫 -get | ✅ | ✅ -paginate | ✅ | ✅ -download | ✅ | ✅ -author_page| ✅ | 🚫 -set_config | ✅ | 🚫 +Feature | NHentai | Manganato | Mangadex +-----------|---------|-----------|----------- +search | ✅ | ✅ | ✅ +random | ✅ | 🚫 | ✅ +get | ✅ | ✅ | ✅ +paginate | ✅ | ✅ | ✅ +download | ✅ | ✅ | ✅ +author_page| ✅ | 🚫 | 🚫 +set_config | ✅ | 🚫 | 🚫 ## Usage @@ -256,6 +262,18 @@ We welcome contributions! If you'd like to contribute: Ensure you follow the coding standards and write tests for new features. +## Disclaimer + +This software is provided "as is", without warranty of any kind, express or implied. The developers and contributors of the Enma library shall not be liable for any misuse, damages, or other consequences arising from the use of this software. + +It is important to emphasize that the Enma library was developed with the goal of facilitating efficient and responsible access and manipulation of data. We do not encourage or support the use of this tool for conducting mass queries or accesses that could overload, harm, or in any way negatively affect the servers or services of the supported sources. + +Users of the Enma library must always follow the guidelines, terms of use, and limitations imposed by the accessed data sources. We strongly recommend the implementation of responsible rate limiting practices and obtaining appropriate permissions when necessary, to ensure that the use of the library complies with all applicable laws and regulations, in addition to respecting ethical principles of data use. + +By using the Enma library, you agree to use the tool in an ethical and responsible manner, acknowledging that the developers of Enma will not be responsible for any use that violates these guidelines. + +We remind you that respect for the services and APIs of the supported sources is fundamental for the sustainability and longevity of both the Enma and the services used. We value the community and the development ecosystem and encourage all users to contribute to a safer, more respectful, and collaborative digital environment. + ## License MIT diff --git a/enma/__init__.py b/enma/__init__.py index 2d43d74..0d2af74 100644 --- a/enma/__init__.py +++ b/enma/__init__.py @@ -1,18 +1,20 @@ import sys from enma.application.core.utils.logger import LogMode, logger from enma.application.use_cases.download_chapter import Threaded -from enma.infra.entrypoints.lib import Enma, SourcesEnum, DefaultAvailableSources -from enma.infra.adapters.repositories.nhentai import CloudFlareConfig, NHentai, Sort +from enma.infra.entrypoints.lib import Enma, SourcesEnum +from enma.infra.adapters.repositories.nhentai import CloudFlareConfig, NHentai, Sort as NHentaiSort +from enma.infra.adapters.repositories.mangadex import Mangadex, Sort as MangadexSort from enma.infra.adapters.repositories.manganato import Manganato from enma.infra.adapters.downloaders.default import DefaultDownloader from enma.infra.adapters.downloaders.manganato import ManganatoDownloader from enma.application.core.interfaces.downloader_adapter import IDownloaderAdapter from enma.application.core.interfaces.saver_adapter import ISaverAdapter from enma.infra.adapters.storage.local import LocalStorage -from enma.domain.entities.manga import Manga +from enma.domain.entities.manga import Manga, Chapter, SymbolicLink from enma.domain.entities.search_result import SearchResult from enma.domain.entities.pagination import Pagination from enma.domain.entities.author_page import AuthorPage +from enma.application.core.interfaces.manga_repository import IMangaRepository package_name = "enma" python_major = "3" diff --git a/enma/_version.py b/enma/_version.py index ba0fb3f..54660c3 100644 --- a/enma/_version.py +++ b/enma/_version.py @@ -1 +1 @@ -__version__ = '2.3.0' \ No newline at end of file +__version__ = '2.4.0' \ No newline at end of file diff --git a/enma/application/core/handlers/error.py b/enma/application/core/handlers/error.py index a409f10..8b729d2 100644 --- a/enma/application/core/handlers/error.py +++ b/enma/application/core/handlers/error.py @@ -7,6 +7,30 @@ def __init__(self, message: str) -> None: self.desc: str = 'This error occurs when the client chooses nonexistent source.' self.critical: bool = False +class Unknown(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + self.code: str = 'UNKNOWN' + self.desc: str = 'This error occours when was not possible to determine the error root cause.' + self.critical: bool = True + +class NotFound(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + self.code: str = 'NOT_FOUND' + self.desc: str = 'This error occours when was not possible to find the requested resource.' + self.critical: bool = True + +class Forbidden(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + self.code: str = 'FORBIDDEN' + self.desc: str = 'This error occours when the client can\'t perform a request to the source due lack of credentials.' + self.critical: bool = True + class InvalidRequest(Exception): def __init__(self, message: str) -> None: super().__init__(message) @@ -67,4 +91,13 @@ def __init__(self, message: str) -> None: self.message: str = message self.code: str = 'EXCEED_RETRY_COUNT' self.desc: str = 'This error occurs when enma tries perform some action but something went wrong.' - self.critical: bool = True \ No newline at end of file + self.critical: bool = True + +class ExceedRateLimit(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + self.message: str = message + self.code: str = 'EXCEED_RATE_EXCEED' + self.desc: str = 'This error occurs when enma perform more requests than a server can handle. Cool down your requests to this source!' + self.critical: bool = False \ No newline at end of file diff --git a/enma/application/use_cases/get_manga.py b/enma/application/use_cases/get_manga.py index a0c0c19..ba78f9c 100644 --- a/enma/application/use_cases/get_manga.py +++ b/enma/application/use_cases/get_manga.py @@ -2,7 +2,7 @@ from typing import Union from pydantic import BaseModel, Field, validator -from enma.application.core.handlers.error import InvalidRequest +from enma.application.core.handlers import error from enma.application.core.interfaces.manga_repository import IMangaRepository from enma.application.core.interfaces.use_case import DTO, IUseCase from enma.application.core.utils.logger import logger @@ -24,9 +24,10 @@ def __init__(self, manga_repository: IMangaRepository): def execute(self, dto: DTO[GetMangaRequestDTO]) -> GetMangaResponseDTO: logger.info(f'Fetching manga with identifier: {dto.data.identifier}.') - manga = self.__manga_repository.get(identifier=dto.data.identifier, - with_symbolic_links=dto.data.with_symbolic_links) - - if manga is None: return GetMangaResponseDTO(found=False, manga=None) - - return GetMangaResponseDTO(found=True, manga=manga) \ No newline at end of file + try: + manga = self.__manga_repository.get(identifier=dto.data.identifier, + with_symbolic_links=dto.data.with_symbolic_links) + return GetMangaResponseDTO(found=True, manga=manga) + except error.NotFound: + logger.error(f'Could not find the manga using the provided identifier: {dto.data.identifier}') + return GetMangaResponseDTO(found=False, manga=None) \ No newline at end of file diff --git a/enma/domain/entities/base.py b/enma/domain/entities/base.py index 64762c5..fbfe30a 100644 --- a/enma/domain/entities/base.py +++ b/enma/domain/entities/base.py @@ -1,6 +1,6 @@ from datetime import datetime -import json from typing import Generic, TypeVar, Union +from uuid import uuid4 T = TypeVar('T') @@ -23,12 +23,12 @@ def __init__(self, """Initializes an Entity with given or default values. Args: - id: A Union of int, str, and None representing the entity's ID. Defaults to 0. + id: A Union of int, str, and None representing the entity's ID. Defaults to uuidv4. created_at: A Union of datetime and None representing when the entity was created. Defaults to current UTC time. updated_at: A Union of datetime and None representing when the entity was last updated. Defaults to current UTC time. """ - self.id = id if id is not None else 0 + self.id = id if id is not None else uuid4() self.created_at = created_at if created_at is not None else datetime.utcnow() self.updated_at = updated_at if updated_at is not None else datetime.utcnow() diff --git a/enma/domain/entities/manga.py b/enma/domain/entities/manga.py index f2a777c..8cbfadd 100644 --- a/enma/domain/entities/manga.py +++ b/enma/domain/entities/manga.py @@ -1,14 +1,18 @@ from dataclasses import dataclass, field from datetime import datetime from enum import Enum -from typing import TypedDict, Union +from typing import Literal, TypedDict, Union from enma.domain.entities.base import Entity class MIME(Enum): + JPG = 'jpg' J = 'jpg' + PNG = 'png' P = 'png' + GIF = 'gif' G = 'gif' + @dataclass class Image: @@ -59,6 +63,55 @@ class Genre: class Author(Genre): ... +class ILanguage(TypedDict): + ja: Literal['japanese'] + jp: Literal['japanese'] + japanese: Literal['japanese'] + portuguese: Literal['portuguese'] + pt: Literal['portuguese'] + pt_br: Literal['portuguese'] + english: Literal['english'] + en: Literal['english'] + en_us: Literal['english'] + chinese: Literal['chinese'] + cn: Literal['chinese'] + zh: Literal['chinese'] + russian: Literal['russian'] + ru: Literal['russian'] + turkish: Literal['turkish'] + tr: Literal['turkish'] + spanish: Literal['spanish'] + es_la: Literal['spanish'] + malay: Literal['malay'] + ms: Literal['malay'] + korean: Literal['korean'] + ko: Literal['korean'] + +Language: ILanguage = { + 'ja': 'japanese', + 'jp': 'japanese', + 'japanese': 'japanese', + 'portuguese': 'portuguese', + 'pt': 'portuguese', + 'pt_br': 'portuguese', + 'english': 'english', + 'en': 'english', + 'en_us': 'english', + 'chinese': 'chinese', + 'cn': 'chinese', + 'zh': 'chinese', + 'ru': 'russian', + 'russian': 'russian', + 'turkish': 'turkish', + 'tr': 'turkish', + 'spanish': 'spanish', + 'es_la': 'spanish', + 'malay': 'malay', + 'ms': 'malay', + 'korean': 'korean', + 'ko': 'korean' +} + class Manga(Entity[IMangaProps]): def __init__(self, title: Title, @@ -85,3 +138,8 @@ def __init__(self, self.chapters = chapters or [] self.chapters_count = len(self.chapters if self.chapters else []) + + def add_chapter(self, + chapter: Chapter): + self.chapters.append(chapter) + self.chapters_count += 1 diff --git a/enma/infra/adapters/repositories/mangadex.py b/enma/infra/adapters/repositories/mangadex.py new file mode 100644 index 0000000..abf1d37 --- /dev/null +++ b/enma/infra/adapters/repositories/mangadex.py @@ -0,0 +1,540 @@ +""" +This module provides an adapter for the mangadex repository. +It contains functions and classes to interact with the mangadex API and retrieve manga data. +""" +from datetime import datetime +from enum import Enum +from typing import Any, Optional, Union, cast +from urllib.parse import urljoin, urlparse + +from requests import Response + +import requests + +from enma.application.core.handlers.error import (ExceedRateLimit, + Forbidden, + NotFound, + Unknown) +from enma.application.core.interfaces.manga_repository import IMangaRepository +from enma.application.core.utils.logger import logger +from enma.domain.entities.author_page import AuthorPage +from enma.domain.entities.manga import (MIME, + Chapter, + Genre, + Author, + Image, + Language, + Manga, + SymbolicLink, + Title) +from enma.domain.entities.search_result import Pagination, SearchResult, Thumb +from enma.infra.core.interfaces.mangadex_response import (AuthorRelation, + CoverArtRelation, IAltTitles, + IGetResult, + IHash, IManga, IMangaTag, IRelations, + ISearchResult, + IVolumesResponse) + + +class Sort(Enum): + ALL_TIME = 'relevance' + RECENT = 'createdAt' + +class Mangadex(IMangaRepository): + """ + Repository class for interacting with the Mangadex API. + Provides methods to fetch manga details, search for manga, etc. + """ + + def __init__(self) -> None: + self.__API_URL = 'https://api.mangadex.org/' + self.__COVER_URL = 'https://mangadex.org/covers/' + self.__HASH_URL = 'https://api.mangadex.org/at-home/server/' + self.__CHAPTER_PAGE_URL = 'https://cmdxd98sb0x3yprd.mangadex.network/data/' + + def __handle_source_response(self, response: Response): + """ + Evaluates the HTTP response from the Mangadex API, raising specific exceptions based on the HTTP status code + to indicate various error conditions such as rate limits exceeded, forbidden access, or resource not found. + + Args: + response (Response): The HTTP response object from a request to the Mangadex API. + + Raises: + Forbidden: Indicates a 403 Forbidden HTTP status code. + NotFound: Indicates a 404 Not Found HTTP status code. + ExceedRateLimit: Indicates a 429 Too Many Requests HTTP status code. + Unknown: Indicates any other unexpected HTTP status code. + """ + + logger.debug(f'Fetched {response.url} with response status code {response.status_code} and text {response.text}') + + if response.status_code == 200: + return + if response.status_code == 403: + raise Forbidden(message='Could not perform a successful request to the source due to credentials issues. Check your credentials and try again.') + if response.status_code == 404: + raise NotFound(message=f'Could not find the requested resource at "{response.url}". Check the provided request parameters and try again.') + if response.status_code == 429: + raise ExceedRateLimit(message='You have exceeded the Mangadex rate limit!') + raise Unknown(message='Something unexpected happened while trying to fetch source content. Set the logging mode to debug and try again.') + + def __make_request(self, + url: str, + headers: Union[dict[str, Any], None] = None, + params: Optional[Union[dict[str, Union[str, int]], list[tuple[str, Union[str, int]]]]] = None) -> requests.Response: + """ + Makes a request to the specified URL with the given headers and parameters. + + Args: + url (str): The URL to make the request to. + headers (dict[str, Any], optional): The headers to include in the request. Defaults to None. + params (Optional[Union[dict[str, Union[str, int]], list[tuple[str, Union[str, int]]]]], optional): The parameters to include in the request. Defaults to None. + + Returns: + requests.Response: The response object from the API request. + """ + headers = headers if headers is not None else {} + params = params if params is not None else {} + + logger.debug(f'Fetching {url} with headers {headers} and params {params}') + + response = requests.get(url=urlparse(url).geturl(), + headers={**headers, "User-Agent": "Enma/2.4.0"}, + params=params) + + self.__handle_source_response(response) + + return response + + def set_config(self, config) -> None: + raise NotImplementedError('Manganato does not support set config') + + def __create_cover_uri(self, + manga_id: str, + file_name: str) -> str: + """ + Constructs a URL for a manga's cover image based on its identifier and the file name of the cover image. + + Args: + manga_id (str): The unique identifier of the manga. + file_name (str): The file name of the cover image. + + Returns: + str: The fully qualified URL to the cover image. + """ + return urljoin(self.__COVER_URL, f'{manga_id}/{file_name}.512.jpg') + + def fetch_chapter_by_symbolic_link(self, + link: SymbolicLink) -> Chapter: + """ + Retrieves manga chapter details including pages and images by following a symbolic link. This method is particularly + useful for fetching chapters that have been directly linked. + + Args: + link (SymbolicLink): An object representing the symbolic link to the chapter. + + Returns: + Chapter: An object containing the fetched chapter details such as pages and images. + """ + response = self.__make_request(url=link.link) + + ch: IHash = response.json() + chapter = Chapter() + + for index, page in enumerate(ch.get('chapter').get('data')): + extension = page.split('.')[-1] + chapter.add_page(Image(uri=self.__create_chapter_page_uri(ch.get('chapter').get('hash'), page), + name=f'{index}.{extension}', + mime=MIME[extension.upper()])) + return chapter + + def __fetch_chapter_hashes(self, chapter_id: str) -> tuple[str, list[str]]: + """ + Fetches the chapter hashes and page file names for a given chapter ID. These details are necessary to construct + the URLs for individual chapter pages. + + Args: + chapter_id (str): The unique identifier of the chapter. + + Returns: + tuple[str, list[str]]: A tuple containing the chapter hash and a list of page file names. + """ + response = self.__make_request(url=urljoin(self.__HASH_URL, chapter_id)) + data: IHash = response.json() + + return (data.get('chapter').get('hash'), + data.get('chapter').get('data')) + + def __create_chapter_page_uri(self, hash: str, filename: str) -> str: + """ + Constructs the URL for a chapter page given the chapter hash and the page file name. + + Args: + hash (str): The hash of the chapter, used as part of the URL path. + filename (str): The file name of the chapter page. + + Returns: + str: The fully qualified URL to the chapter page. + """ + return urljoin(self.__CHAPTER_PAGE_URL, f'{hash}/{filename}') + + def __create_chapter(self, + chapter: tuple[int, str], + with_symbolic_links: bool = False) -> Chapter: + """ + Constructs a Chapter object for a given chapter tuple, optionally using symbolic links. If symbolic links are + used, chapter pages are not pre-fetched but are instead represented as links. + + Args: + chapter (tuple[int, str]): A tuple containing the chapter number and the chapter ID. + with_symbolic_links (bool, optional): A flag indicating whether to use symbolic links for chapter pages. Defaults to False. + + Returns: + Chapter: The constructed Chapter object. + """ + curr_chapter, chapter_id = chapter + + if with_symbolic_links: + return Chapter(id=curr_chapter, + link=SymbolicLink(link=urljoin(self.__HASH_URL, chapter_id))) + else: + ch = Chapter(id=curr_chapter) + hash, files = self.__fetch_chapter_hashes(chapter_id) + + for index, page in enumerate(files): + extension = page.split('.')[-1] + ch.add_page(Image(uri=self.__create_chapter_page_uri(hash, page), + name=f'{index}.{extension}', + mime=MIME[extension.upper()])) + + return ch + + def __list_chapters(self, manga_id: str) -> list[tuple[int, str]]: + """ + Retrieves a list of chapters for a given manga ID. Each chapter is represented as a tuple containing the chapter + number and the chapter ID. + + Args: + manga_id (str): The unique identifier of the manga. + + Returns: + list[tuple[int, str]]: A list of tuples, each representing a chapter of the manga. + """ + response = self.__make_request(url=urljoin(self.__API_URL, f'manga/{manga_id}/aggregate')) + + data: IVolumesResponse = response.json() + + chapters = [] + for volume in data.get('volumes'): + volume = data.get('volumes').get(volume) + + if volume is None: continue + + volume_chapters = volume.get('chapters') + + for volume_key in volume_chapters: + current_vol = volume_chapters.get(volume_key) + + if current_vol is None: continue + + chapters.append((current_vol.get('chapter'), current_vol.get('id'))) + + return chapters + + def __extract_authors(self, relations: IRelations) -> list[Author]: + """ + Extracts author information from a list of relationships within manga metadata, constructing Author objects + for each author found. + + Args: + relations (IRelations): A list of relationship objects from the manga metadata. + + Returns: + list[Author]: A list of Author objects extracted from the relationships. + """ + authors_data = [relationship for relationship in relations if relationship.get('type') == 'author'] + authors: list[Author] = [] + + if len(authors_data) > 0: + for author in authors_data: + author = cast(AuthorRelation, author) + authors.append(Author(name=author.get('attributes').get('name'), + id=author.get('id'))) + + return authors + + def __extract_genres(self, tags: list[IMangaTag]) -> list[Genre]: + """ + Extracts genre information from a list of tags within manga metadata, constructing Genre objects for each tag + that represents a genre. + + Args: + tags (list[IMangaTag]): A list of tag objects from the manga metadata. + + Returns: + list[Genre]: A list of Genre objects extracted from the tags. + """ + return [Genre(id=tag.get('id'), + name=tag.get('attributes', {}).get('name', {}).get('en', 'unknown')) + for tag in tags or [] + if tag.get('type') == 'tag'] + + def __get_cover(self, + manga_id: str, + relations: IRelations) -> Image: + """ + Retrieves the cover image for a given manga ID from a list of relationships. If a cover image is found, an + Image object is constructed and returned. + + Args: + manga_id (str): The unique identifier of the manga. + relations (IRelations): A list of relationship objects from the manga metadata. + + Returns: + Image: An Image object representing the manga's cover image. Returns an Image object with an empty URI if no cover is found. + """ + covers = [tag for tag in relations if tag.get('type') == 'cover_art'] + if len(covers) == 0: return Image(uri='') + cover = cast(CoverArtRelation, covers[0]) + return Image(uri=self.__create_cover_uri(manga_id, cover.get("attributes").get("fileName")), + width=512) + + def __get_title(self, alt_titles: IAltTitles, title: str) -> Title: + """ + Constructs a Title object for the manga, incorporating the English title, a Japanese title if available, + and an alternative title. + + Args: + alt_titles (IAltTitles): A list of alternative titles for the manga. + title (str): The primary English title of the manga. + + Returns: + Title: A Title object containing the English, Japanese, and an alternative title for the manga. + """ + japanese_titles = [ title.get('ja-ro') for title in alt_titles if title.get('ja-ro') is not None ] + japanese_title = japanese_titles[0] if len(japanese_titles) > 0 else None + + other_keys = list(alt_titles[-1].keys()) + other_key = other_keys[0] if len(other_keys) > 0 else '' + + return Title(english=title, + japanese=japanese_title or '', + other=alt_titles[-1].get(other_key) or '') + + def __parse_full_manga(self, + manga_data: IManga, + with_symbolic_links: bool = False) -> Manga: + """ + Parses the complete manga data retrieved from the Mangadex API, constructing a Manga object that includes + details such as title, authors, genres, cover image, and chapters. + + Args: + manga_data (IManga): The raw manga data from the Mangadex API. + with_symbolic_links (bool, optional): Indicates whether to use symbolic links for chapters. Defaults to False. + + Returns: + Manga: A fully constructed Manga object. + """ + attrs = manga_data.get('attributes', dict()) + + thumbnail = self.__get_cover(manga_data.get('id'), + manga_data.get('relationships')) + + + manga = Manga(title=self.__get_title(alt_titles=attrs.get('altTitles'), + title=attrs.get('title', dict()).get('en') or ''), + id=manga_data.get('id'), + created_at=datetime.fromisoformat(attrs.get('createdAt')), + updated_at=datetime.fromisoformat(attrs.get('updatedAt')), + language=Language.get(attrs.get('originalLanguage').strip().lower().replace('-', '_'), 'unknown'), + authors=self.__extract_authors(manga_data.get('relationships', list())), + genres=self.__extract_genres(attrs.get('tags', list())), + thumbnail=thumbnail, + cover=thumbnail) + + chapter_list = self.__list_chapters(manga_id=str(manga.id)) + + for chapter in chapter_list: + manga.add_chapter(self.__create_chapter(chapter=chapter, + with_symbolic_links=with_symbolic_links)) + + return manga + + def __parse_thumb(self, manga: IManga) -> Thumb: + """ + Extracts minimal manga information to construct a Thumb object, primarily used for search results + where detailed information is not necessary. + + Args: + manga (IManga): The raw manga data from the Mangadex API. + + Returns: + Thumb: A Thumb object containing the manga's ID, title, and cover image. + """ + + title = manga.get('attributes').get('title').get('en') + return Thumb(id=manga.get('id'), + title=title, + cover=self.__get_cover(manga_id=manga.get('id'), + relations=manga.get('relationships', list()))) + + def get(self, + identifier: str, + with_symbolic_links: bool = False) -> Manga: + """ + Retrieves detailed information for a specific manga identified by its ID, constructing a Manga object. + + Args: + identifier (str): The unique identifier of the manga to retrieve. + with_symbolic_links (bool, optional): Indicates whether to construct the Manga object with symbolic links for chapters. Defaults to False. + + Returns: + Manga: The Manga object containing detailed information about the specified manga. + """ + response = self.__make_request(url=urljoin(self.__API_URL, f'manga/{identifier}'), + params=[('includes[]', 'cover_art'), + ('includes[]', 'author'), + ('includes[]', 'artist')]) + + result: IGetResult = response.json() + manga_data = result.get('data') + + return self.__parse_full_manga(manga_data=manga_data, + with_symbolic_links=with_symbolic_links) + + def __make_sort_query(self, sort: Sort) -> dict[str, str]: + """ + Constructs a query parameter dictionary to define the sorting order for search results based on a Sort enumeration value. + + Args: + sort (Sort): An enumeration value specifying the desired sort order for search results. + + Returns: + dict[str, str]: A dictionary of query parameters to define the sorting order. + """ + return { f'order[{sort.value if isinstance(sort, Sort) else sort}]': 'desc' } + + def search(self, + query: str, + page: int, + sort: Sort = Sort.RECENT, + per_page: int = 25) -> SearchResult: + """ + Searches the Mangadex API for manga that match the given query string, optionally sorting the results + and paginating them. Constructs and returns a SearchResult object containing the search results. + + Args: + query (str): The search query string. + page (int): The page number of the search results to retrieve. + sort (Sort, optional): The sorting order of the search results. Defaults to Sort.RECENT. + per_page (int, optional): The number of results per page. Defaults to 25. + + Returns: + SearchResult: An object containing the paginated search results, including manga thumbnails. + """ + logger.debug(f'Searching into Mangadex with args query={query};page={page};sort={sort}') + + params = [('title', query), *tuple(self.__make_sort_query(sort).items()), + ('includes[]', 'cover_art'), ('limit', per_page), + ('offset', per_page * (page - 1) if page > 1 else 0), ('contentRating[]', 'safe'), + ('contentRating[]', 'suggestive'), ('contentRating[]', 'erotica'), + ('order[createdAt]', 'desc'), ('hasAvailableChapters', 'true')] + + request_response = self.__make_request(url=urljoin(self.__API_URL, 'manga'), + params=params) + + response: ISearchResult = request_response.json() + + total_results = response.get('total') + total_pages = int(total_results / per_page) + + search_result = SearchResult(query=query, + total_pages=total_pages, + page=page, + total_results=total_results) + + for result in response.get('data', []): + search_result.results.append(self.__parse_thumb(manga=result)) + + return search_result + + def paginate(self, page: int) -> Pagination: + """ + Retrieves a specific page of manga listings from the Mangadex API, returning a Pagination object + that includes a list of manga thumbnails for that page. + + Args: + page (int): The page number of manga listings to retrieve. + + Returns: + Pagination: An object containing the paginated list of manga thumbnails and pagination details. + """ + logger.debug(f'Paginating with args page={page}') + per_page = 25 + request_response = self.__make_request(url=urljoin(self.__API_URL, 'manga'), + params=[('limit', per_page), + ('offset', per_page * (page - 1) if page > 1 else 0), + ('order[createdAt]', 'desc'), + ('includes[]', 'cover_art'), + ('contentRating[]', 'safe'), + ('contentRating[]', 'suggestive'), + ('contentRating[]', 'erotica'), + ('order[createdAt]', 'desc'), + ('hasAvailableChapters', 'true')]) + + response: ISearchResult = request_response.json() + + pagination = Pagination(page=page, + total_pages=int(response.get('total') / per_page), + total_results=response.get('total')) + + for result in response.get('data', []): + pagination.results.append(self.__parse_thumb(manga=result)) + + return pagination + + def random(self, retry=0) -> Manga: + """ + Fetches a random manga from the Mangadex API. If the first attempt fails, it will retry up to a specified number of times. + + Args: + retry (int, optional): The number of retries to attempt in case of failure. Defaults to 0. + + Returns: + Manga: A Manga object for the randomly selected manga. + """ + response = self.__make_request(url=urljoin(self.__API_URL, f'manga/random'), + params=[('includes[]', 'cover_art'), + ('contentRating[]', 'safe'), + ('contentRating[]', 'suggestive'), + ('contentRating[]', 'erotica'), + ('includes[]', 'author'), + ('includes[]', 'artist'), + ('hasAvailableChapters', 'true')]) + + result: IGetResult = response.json() + + manga = result.get('data') + + return self.__parse_full_manga(manga_data=manga, + with_symbolic_links=True) + + def author_page(self, + author: str, + page: int) -> AuthorPage: + """ + Fetches manga authored by a specific author. This method is not currently implemented for Mangadex + and serves as a placeholder for potential future functionality. + + Args: + author (str): The name or identifier of the author. + page (int): The page number of results to retrieve. + + Raises: + NotImplementedError: Indicates that this method is not supported or implemented. + + Returns: + AuthorPage: An object containing a list of manga by the specified author. This is currently not implemented. + """ + raise NotImplementedError('Mangadex does not support author page.') \ No newline at end of file diff --git a/enma/infra/adapters/repositories/manganato.py b/enma/infra/adapters/repositories/manganato.py index 105c435..5a8c116 100644 --- a/enma/infra/adapters/repositories/manganato.py +++ b/enma/infra/adapters/repositories/manganato.py @@ -45,12 +45,21 @@ def __create_title(self, main_title: str, alternative: str) -> Title: logger.debug(f'Building manga title main: {main_title} and alternative: {alternative}') + + has_many_alternatives = alternative.find(';') != -1 or alternative.find(',') != -1 + + if not has_many_alternatives: + jp = alternative + return Title(english=main_title.strip(), + japanese=jp.strip(), + other=main_title.strip()) + jp, cn, *_ = alternative.split(';') if alternative.find(';') != -1 else alternative.split(',') return Title(english=main_title.strip(), japanese=jp.strip(), other=cn.strip()) - def __find_chapets_list(self, html: BeautifulSoup) -> list[str]: + def __find_chapters_list(self, html: BeautifulSoup) -> list[str]: chapter_list = cast(Tag, html.find('ul', {'class': 'row-content-chapter'})) chapters = chapter_list.find_all('li') if chapter_list else [] return [chapter.find('a')['href'] for chapter in chapters] @@ -67,7 +76,6 @@ def __create_chapter(self, url: str, symbolic: bool = False) -> Union[Chapter, N logger.error(f'Could not fetch the chapter with url: {url}. status code: {response.status_code}') return - chapter = Chapter(id=response.url.split('/')[-1]) html = BeautifulSoup(response.text, 'html.parser') images_container = cast(Tag, html.find('div', {'class': 'container-chapter-reader'})) @@ -134,14 +142,14 @@ def get(self, updated_at = updated_at_field.find('span', {'class': 'stre-value'}).text if with_symbolic_links: - chapters_links = self.__find_chapets_list(html=soup) + chapters_links = self.__find_chapters_list(html=soup) chapters = [self.__create_chapter(link, symbolic=True) for link in chapters_links] else: workers = cpu_count() logger.debug(f'Initializing {workers} workers to fetch chapters of {identifier}.') with ThreadPoolExecutor(max_workers=workers) as executor: - chapters = executor.map(self.__create_chapter, self.__find_chapets_list(html=soup)) + chapters = executor.map(self.__create_chapter, self.__find_chapters_list(html=soup)) chapters = list(filter(lambda x: isinstance(x, Chapter), list(chapters))) executor.shutdown() @@ -149,8 +157,8 @@ def get(self, authors=[Author(name=author)] if author is not None else None, genres=[Genre(name=genre_name) for genre_name in genres], id=identifier, - created_at=datetime.strptime(updated_at, "%b %d,%Y - %I:%M %p") if updated_at else None, - updated_at=datetime.strptime(updated_at, "%b %d,%Y - %I:%M %p") if updated_at else None, + created_at=datetime.strptime(updated_at, "%b %d,%Y - %H:%M %p") if updated_at else None, + updated_at=datetime.strptime(updated_at, "%b %d,%Y - %H:%M %p") if updated_at else None, thumbnail=Image(uri=cover), # type: ignore cover=Image(uri=cover), # type: ignore chapters=chapters) # type: ignore diff --git a/enma/infra/adapters/repositories/nhentai.py b/enma/infra/adapters/repositories/nhentai.py index da22d26..724b473 100644 --- a/enma/infra/adapters/repositories/nhentai.py +++ b/enma/infra/adapters/repositories/nhentai.py @@ -11,8 +11,8 @@ import requests from bs4 import BeautifulSoup, Tag -from enma.application.core.handlers.error import (ExceedRetryCount, InvalidConfig, InvalidRequest, - NhentaiSourceWithoutConfig) +from enma.application.core.handlers.error import (ExceedRetryCount, Forbidden, InvalidConfig, InvalidRequest, + NhentaiSourceWithoutConfig, NotFound, Unknown) from enma.application.core.interfaces.manga_repository import IMangaRepository from enma.application.core.utils.logger import logger from enma.domain.entities.author_page import AuthorPage @@ -58,6 +58,18 @@ def __init__(self, self.__AVATAR_URL = 'https://i5.nhentai.net/' self.__TINY_IMAGE_BASE_URL = self.__IMAGE_BASE_URL.replace('/i.', '/t.') + def __handle_source_response(self, response: requests.Response): + logger.debug(f'Fetched {response.url} with response status code {response.status_code} and text {response.text}') + if response.status_code == 200: return + if response.status_code == 403: + raise Forbidden(message='Could not perform a successfull request to the source due credentials issues. \ +Check your credentials and try again.') + if response.status_code == 404: + raise NotFound(message=f'Could not find the requested resource at "{response.url}". \ +Check the provided request parameters and try again.') + raise Unknown(message='Something unexpected happened while trying to fetch source content. \ +Set the logging mode to debug and try again.') + def __make_request(self, url: str, headers: Union[dict[str, Any], None] = None, @@ -72,21 +84,17 @@ def __make_request(self, logger.debug(f'Fetching {url} with headers {headers} and params {params} the current config cf_clearance: {self.__config.cf_clearance}') response = requests.get(url=urlparse(url).geturl(), - headers={**headers, 'User-Agent': self.__config.user_agent}, + headers={**headers, 'User-Agent': f'{self.__config.user_agent}'}, params={**params}, cookies={'cf_clearance': self.__config.cf_clearance}) - logger.debug(f'Fetched {url} with response status code {response.status_code} and text {response.text}') + self.__handle_source_response(response) return response def set_config(self, config: CloudFlareConfig) -> None: if not isinstance(config, CloudFlareConfig): raise InvalidConfig(message='You must provide a CloudFlareConfig object.') self.__config = config - - def __handle_request_error(self, msg: str) -> None: - logger.error(msg) - return None def __make_page_uri(self, type: Union[Literal['cover'], Literal['page'], Literal['thumbnail']], @@ -111,10 +119,6 @@ def __make_page_uri(self, def fetch_chapter_by_symbolic_link(self, link: SymbolicLink) -> Chapter: response = self.__make_request(url=link.link) - - if response.status_code != 200: - self.__handle_request_error(msg=f'Could not fetch {link.link} because nhentai\'s request ends up with {response.status_code} status code.') - return Chapter() doujin: NHentaiResponse = response.json() @@ -155,9 +159,6 @@ def get(self, url = f'{self.__API_URL}/gallery/{identifier}' response = self.__make_request(url=url) - if response.status_code != 200: - return self.__handle_request_error(msg=f'Could not fetch {identifier} because nhentai\'s request ends up with {response.status_code} status code.') - doujin: NHentaiResponse = response.json() media_id = doujin.get('media_id') @@ -221,10 +222,6 @@ def search(self, total_results=0, results=[]) - if request_response.status_code != 200: - self.__handle_request_error(f'Could not search by {query} because nhentai\'s request ends up with {request_response.status_code} status code.') - return search_result - soup = BeautifulSoup(request_response.text, 'html.parser') search_results_container = soup.find('div', {'class': 'container'}) @@ -290,10 +287,6 @@ def paginate(self, page: int) -> Pagination: response = self.__make_request(url=urljoin(self.__API_URL, f'galleries/all'), params={'page': page}) - if response.status_code != 200: - self.__handle_request_error(f'Could not paginate to page {page} because nhentai\'s request ends up with {response.status_code} status code.') - return Pagination(page=page) - data = response.json() PAGES = data.get('num_pages', 0) @@ -315,9 +308,6 @@ def paginate(self, page: int) -> Pagination: def random(self, retry=0) -> Manga: response = self.__make_request(url=urljoin(self.__BASE_URL, 'random')) - if response.status_code != 200: - self.__handle_request_error(f'Could not fetch a random manga because nhentai\'s request ends up with {response.status_code} status code.') - soup = BeautifulSoup(response.text, 'html.parser') id = cast(Tag, soup.find('h3', id='gallery_id')).text.replace('#', '') @@ -345,11 +335,7 @@ def author_page(self, total_pages=0, page=page, total_results=0, - results=[]) - - if request_response.status_code != 200: - logger.error('Could not fetch author page properly') - return result + results=[]) soup = BeautifulSoup(request_response.text, 'html.parser') diff --git a/enma/infra/core/interfaces/mangadex_response.py b/enma/infra/core/interfaces/mangadex_response.py new file mode 100644 index 0000000..777ed0c --- /dev/null +++ b/enma/infra/core/interfaces/mangadex_response.py @@ -0,0 +1,134 @@ +from typing import Any, Literal, TypedDict, Union, TypeVar + + +class Title(TypedDict): + en: str + +class MangaDesc(TypedDict): + en: str + +class TagAttrs(TypedDict): + name: dict[str, str] + description: dict[str, str] + group: Union[Literal["theme"], Literal["genre"], + Literal["format"]] + version: int + +class IMangaTag(TypedDict): + id: str + type: str + attributes: TagAttrs + relationships: list[Any] + +IAltTitles = list[dict[str, str]] + +class MangaAttrs(TypedDict): + title: Title + altTitles: IAltTitles + description: MangaDesc + isLocked: bool + links: dict[str, str] + originalLanguage: str + lastVolume: str + lastChapter: str + publicationDemographic: Any + status: str + year: int + contentRating: str + tags: list[IMangaTag] + state: str + chapterNumbersResetOnNewVolume: bool + createdAt: str + updatedAt: str + version: int + availableTranslatedLanguages: list[str] + latestUploadedChapter: str + +class CoverAttrs(TypedDict): + description: str + volume: int + fileName: str + locale: str + createdAt: str + updatedAt: str + version: int + +class CoverArtRelation(TypedDict): + id: str + type: Literal["cover_art"] + attributes: CoverAttrs + +class PersonAttrs(TypedDict): + name: str + imageUrl: str + biography: dict[str, str] + twitter: str + pixiv: str + melonBook: str + fanBox: str + booth: str + namicomi: str + nicoVideo: str + skeb: str + fantia: str + tumblr: str + youtube: str + weibo: str + naver: str + website: str + createdAt: str + updatedAt: str + version: int + +class AuthorRelation(TypedDict): + id: str + type: Union[Literal["author"], Literal["artist"]] + attributes: PersonAttrs + +IRelations = list[Union[CoverArtRelation, + AuthorRelation, + dict[str, str]]] + +class IManga(TypedDict): + id: str + type: str + attributes: MangaAttrs + relationships: IRelations + +class IGetResult(TypedDict): + result: str + response: str + data: IManga + +class ISearchResult(IGetResult): + limit: int + offset: int + total: int + result: str + response: str + data: list[IManga] + +class IChapter(TypedDict): + chapter: str + id: str + others: list + count: int + +class IVolume(TypedDict): + volume: str + count: int + chapters: dict[str, IChapter] + +class IVolumesResponse(TypedDict): + result: str + volumes: dict[str, IVolume] + +class IChapterHash(TypedDict): + hash: str + data: list[str] + dataSaver: list[str] + +class IHash(TypedDict): + result: str + baseUrl: str + chapter: IChapterHash \ No newline at end of file diff --git a/enma/infra/entrypoints/lib/__init__.py b/enma/infra/entrypoints/lib/__init__.py index ad4e952..9d5e4d3 100644 --- a/enma/infra/entrypoints/lib/__init__.py +++ b/enma/infra/entrypoints/lib/__init__.py @@ -5,7 +5,7 @@ from enum import Enum from typing import Any, Generic, Optional, TypeVar, TypedDict, Union -from enma.application.core.handlers.error import InstanceError, SourceNotAvailable, SourceWasNotDefined +from enma.application.core.handlers.error import InstanceError, InvalidResource, SourceNotAvailable, SourceWasNotDefined from enma.application.core.interfaces.downloader_adapter import IDownloaderAdapter from enma.application.core.interfaces.manga_repository import IMangaRepository from enma.application.core.interfaces.saver_adapter import ISaverAdapter @@ -18,18 +18,17 @@ from enma.application.use_cases.paginate import PaginateRequestDTO, PaginateResponseDTO, PaginateUseCase from enma.application.use_cases.search_manga import SearchMangaRequestDTO, SearchMangaResponseDTO, SearchMangaUseCase from enma.domain.entities.author_page import AuthorPage -from enma.domain.entities.manga import Chapter, Manga, SymbolicLink +from enma.domain.entities.manga import Chapter, Manga from enma.domain.entities.pagination import Pagination from enma.domain.entities.search_result import SearchResult +from enma.infra.adapters.repositories.mangadex import Mangadex from enma.infra.adapters.repositories.manganato import Manganato from enma.infra.adapters.repositories.nhentai import NHentai, CloudFlareConfig from enma.infra.core.interfaces.lib import IEnma -class SourcesEnum(str, Enum): - ... - -class DefaultAvailableSources(SourcesEnum): +class SourcesEnum(Enum): NHENTAI = 'nhentai' + MANGADEX = 'mangadex' MANGANATO = 'manganato' class ExtraConfigs(TypedDict): @@ -38,15 +37,37 @@ class ExtraConfigs(TypedDict): AvailableSources = TypeVar('AvailableSources', bound=SourcesEnum) class SourceManager(Generic[AvailableSources]): + """ + Manages manga source repositories available to the Enma application, allowing for dynamic source selection at runtime. + + Attributes: + source (Union[IMangaRepository, None]): The currently selected manga repository source. + source_name (str): The name of the currently selected source. + """ + def __init__(self, **kwargs) -> None: - self.__SOURCES: dict[str, IMangaRepository] = {'nhentai': NHentai(config=kwargs.get('cloudflare_config')), - 'manganato': Manganato()} + """ + Initializes the SourceManager with empty sources and no selected source. + """ + self.__SOURCES: dict[str, IMangaRepository] = {} self.source: Union[IMangaRepository, None] = None self.source_name = '' def get_source(self, source_name: Union[AvailableSources, str]) -> IMangaRepository: - + """ + Retrieves a source repository by name. + + Args: + source_name (Union[AvailableSources, str]): The name of the source to retrieve, either as a string or an enum. + + Returns: + IMangaRepository: The manga repository source. + + Raises: + SourceNotAvailable: If the requested source is not available. + """ + source_name = source_name.value if isinstance(source_name, Enum) else source_name source = self.__SOURCES.get(source_name) @@ -57,20 +78,45 @@ def get_source(self, def set_source(self, source_name: Union[AvailableSources, str]) -> None: + """ + Sets the currently active source to the specified source name. + + Args: + source_name (Union[AvailableSources, str]): The name of the source to activate, either as a string or an enum. + """ source = self.get_source(source_name=source_name) self.source = source self.source_name = source_name def add_source(self, - source_name: str, + source_name: Union[str, SourcesEnum], source: IMangaRepository) -> None: + """ + Adds a new source repository to the available sources. + + Args: + source_name (Union[str, SourcesEnum]): The name of the source to add. + source (IMangaRepository): The manga repository source instance. + Raises: + InstanceError: If the provided source is not an instance of IMangaRepository. + """ if not isinstance(source, IMangaRepository): raise InstanceError('Provided source is not an instance of IMangaRepository.') - self.__SOURCES[source_name] = source + self.__SOURCES[source_name if isinstance(source_name, str) else source_name.value] = source def instantiate_source(callable): + """ + Decorator function to ensure the current use case is instantiated with the current source. + This is used to decorate methods of the Enma class that require a source to have been set. + + Args: + callable: The method to be decorated. + + Returns: + The wrapped method with source initialization logic. + """ def wrapper(self, *args, **kwargs): if self.source_manager.source is not None and \ self._Enma__current_source_name != self.source_manager.source_name: @@ -81,10 +127,22 @@ def wrapper(self, *args, **kwargs): return wrapper class Enma(IEnma, Generic[AvailableSources]): + """ + Main application class for Enma, providing interfaces to execute various manga-related use cases. + Allows dynamic selection of manga sources and performs actions like fetching manga, searching, and downloading chapters. + + Attributes: + source_manager (SourceManager[AvailableSources]): Manages the available sources and the current source selection. + """ def __init__(self, source: Optional[AvailableSources] = None, **kwargs) -> None: + """ + Initializes the Enma application with optional default source selection and extra configurations. + Args: + source (Optional[AvailableSources], optional): The default source to be used. If provided, use cases will be initialized with this source. + """ self.__get_manga_use_case: Optional[IUseCase[GetMangaRequestDTO, GetMangaResponseDTO]] = None self.__search_manga_use_case: Optional[IUseCase[SearchMangaRequestDTO, SearchMangaResponseDTO]] = None self.__paginate_use_case: Optional[IUseCase[PaginateRequestDTO, PaginateResponseDTO]] = None @@ -92,12 +150,27 @@ def __init__(self, self.__downloader_use_case: Optional[IUseCase[DownloadChapterRequestDTO, DownloadChapterResponseDTO]] = None self.__get_author_page_use_case: Optional[IUseCase[GetAuthorPageRequestDTO, GetAuthorPageResponseDTO]] = None self.__fetch_chapter_by_symbolic_link_use_case: Optional[IUseCase[FetchChapterBySymbolicLinkRequestDTO, FetchChapterBySymbolicLinkResponseDTO]] = None - self.__current_source_name = None + self.__current_source_name: Optional[str] = None self.source_manager = SourceManager[AvailableSources](**kwargs) + self.__create_default_sources() if source is not None: self.__initialize_use_case(source=self.source_manager.get_source(source_name=source)) + def __create_default_sources(self) -> None: + """ + Creates and adds the default manga sources to the source manager. Currently, NHentai, Manganato, and Mangadex are added. + """ + self.source_manager.add_source(SourcesEnum.NHENTAI, NHentai()) + self.source_manager.add_source(SourcesEnum.MANGANATO, Manganato()) + self.source_manager.add_source(SourcesEnum.MANGADEX, Mangadex()) + def __initialize_use_case(self, source: IMangaRepository) -> None: + """ + Initializes the use cases with the given source repository. This method sets up all use cases available in Enma. + + Args: + source (IMangaRepository): The source repository to initialize use cases with. + """ self.__get_manga_use_case = GetMangaUseCase(manga_repository=source) self.__search_manga_use_case = SearchMangaUseCase(manga_repository=source) self.__paginate_use_case = PaginateUseCase(manga_repository=source) @@ -110,7 +183,20 @@ def __initialize_use_case(self, source: IMangaRepository) -> None: @instantiate_source def get(self, identifier: str, - with_symbolic_links: bool = False) -> Union[Manga, None]: + with_symbolic_links: bool = True) -> Union[Manga, None]: + """ + Retrieves detailed information for a specific manga identified by its ID. + + Args: + identifier (str): The unique identifier of the manga to retrieve. + with_symbolic_links (bool, optional): If True, fetches the manga with symbolic links to chapters. Defaults to True. + + Returns: + Union[Manga, None]: The Manga object if found, None otherwise. + + Raises: + SourceWasNotDefined: If no source has been defined prior to calling this method. + """ if self.__get_manga_use_case is None: raise SourceWasNotDefined('You must define a source before of performing actions.') @@ -121,7 +207,24 @@ def get(self, return response.manga @instantiate_source - def search(self, query: str, page: int=1, **kwargs) -> SearchResult: + def search(self, + query: str, + page: int=1, + **kwargs) -> SearchResult: + """ + Searches for manga that match the given query string. + + Args: + query (str): The search query string. + page (int, optional): The page number of the search results to retrieve. Defaults to 1. + **kwargs: Additional parameters for search customization. + + Returns: + SearchResult: An object containing the paginated search results, including manga thumbnails. + + Raises: + SourceWasNotDefined: If no source has been defined prior to calling this method. + """ if self.__search_manga_use_case is None: raise SourceWasNotDefined('You must define a source before of performing actions.') @@ -132,7 +235,20 @@ def search(self, query: str, page: int=1, **kwargs) -> SearchResult: return response.result @instantiate_source - def paginate(self, page: int) -> Pagination: + def paginate(self, + page: int) -> Pagination: + """ + Retrieves a specific page of manga listings. + + Args: + page (int): The page number of manga listings to retrieve. + + Returns: + Pagination: An object containing the paginated list of manga thumbnails and pagination details. + + Raises: + SourceWasNotDefined: If no source has been defined prior to calling this method. + """ if self.__paginate_use_case is None: raise SourceWasNotDefined('You must define a source before of performing actions.') @@ -142,7 +258,20 @@ def paginate(self, page: int) -> Pagination: @instantiate_source def random(self) -> Manga: - response = self.__random_use_case.execute() # type: ignore + """ + Fetches a random manga from the currently selected source. + + Returns: + Manga: A Manga object for the randomly selected manga. + + Raises: + SourceWasNotDefined: If no source has been defined prior to calling this method. + NotImplementedError: If the current source does not support fetching an author's page. + """ + if self.__random_use_case is None: + raise SourceWasNotDefined('You must define a source before of performing actions.') + + response = self.__random_use_case.execute() return response.result @@ -153,6 +282,19 @@ def download_chapter(self, downloader: IDownloaderAdapter, saver: ISaverAdapter, threaded: Threaded) -> None: + """ + Downloads a manga chapter to the specified path using the provided downloader and saver adapters. + + Args: + path (str): The filesystem path where the chapter should be saved. + chapter (Chapter): The manga chapter to download. + downloader (IDownloaderAdapter): The adapter to use for downloading the chapter pages. + saver (ISaverAdapter): The adapter to use for saving the downloaded pages. + threaded (Threaded): Determines whether the download should be performed in a threaded manner for concurrency. + + Raises: + SourceWasNotDefined: If no source has been defined prior to calling this method. + """ if self.__downloader_use_case is None: raise SourceWasNotDefined('You must define a source before of performing actions.') @@ -164,6 +306,20 @@ def download_chapter(self, @instantiate_source def author_page(self, author: str, page: int) -> AuthorPage: + """ + Fetches manga authored by a specific author. + + Args: + author (str): The name or identifier of the author. + page (int): The page number of results to retrieve. + + Returns: + AuthorPage: An object containing a list of manga by the specified author. + + Raises: + SourceWasNotDefined: If no source has been defined prior to calling this method. + NotImplementedError: If the current source does not support fetching an author's page. + """ if self.__get_author_page_use_case is None: raise SourceWasNotDefined('You must define a source before of performing actions.') @@ -171,10 +327,29 @@ def author_page(self, author: str, page: int) -> AuthorPage: page=page))).result @instantiate_source - def fetch_chapter_by_symbolic_link(self, link: SymbolicLink) -> Chapter: + def fetch_chapter_by_symbolic_link(self, + chapter: Chapter) -> Chapter: + """ + Fetches a manga chapter's details including pages and images by its symbolic link. + + Args: + chapter (Chapter): The manga chapter to fetch, which must include a valid symbolic link. + + Returns: + Chapter: An object containing the fetched chapter details such as pages and images. + + Raises: + SourceWasNotDefined: If no source has been defined prior to calling this method. + InvalidResource: If the provided chapter does not have a valid symbolic link. + """ if self.__fetch_chapter_by_symbolic_link_use_case is None: raise SourceWasNotDefined('You must define a source before of performing actions.') - response = self.__fetch_chapter_by_symbolic_link_use_case.execute(dto=DTO(data=FetchChapterBySymbolicLinkRequestDTO(link=link))) + if chapter.link is None or chapter.link.link is None: + raise InvalidResource('Chapter does not have a symbolic link.') + response = self.__fetch_chapter_by_symbolic_link_use_case.execute(dto=DTO(data=FetchChapterBySymbolicLinkRequestDTO(link=chapter.link))) + + response.chapter.id = chapter.id + return response.chapter diff --git a/requirements.txt b/requirements.txt index c42522e..a0cda0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests==2.31.0 beautifulsoup4==4.10.0 -pydantic==2.5.3 \ No newline at end of file +pydantic==2.5.3 +pytest==8.0.2 diff --git a/tests/data/mangadex_empty_chapters.json b/tests/data/mangadex_empty_chapters.json new file mode 100644 index 0000000..901b32f --- /dev/null +++ b/tests/data/mangadex_empty_chapters.json @@ -0,0 +1,8 @@ +{ + "result": "ok", + "response": "collection", + "data": [], + "limit": 32, + "offset": 0, + "total": 0 +} \ No newline at end of file diff --git a/tests/data/mangadex_empty_pagination_mock.json b/tests/data/mangadex_empty_pagination_mock.json new file mode 100644 index 0000000..901b32f --- /dev/null +++ b/tests/data/mangadex_empty_pagination_mock.json @@ -0,0 +1,8 @@ +{ + "result": "ok", + "response": "collection", + "data": [], + "limit": 32, + "offset": 0, + "total": 0 +} \ No newline at end of file diff --git a/tests/data/mocked_doujins.py b/tests/data/mocked_doujins.py index dbdde8f..c3eb030 100644 --- a/tests/data/mocked_doujins.py +++ b/tests/data/mocked_doujins.py @@ -1,5 +1,6 @@ import json +from enma.infra.core.interfaces.mangadex_response import ISearchResult from enma.infra.core.interfaces.nhentai_response import NHentaiPaginateResponse, NHentaiResponse with open('./tests/data/get.json', 'r') as get: @@ -24,4 +25,7 @@ manganato_manga_page_empty_chapters_mocked = empty_ch.read() with open('./tests/data/manganato_manga_page_empty_pagination_mock.txt', 'r') as empty_pag: - manganato_manga_page_empty_pagination_mocked = empty_pag.read() \ No newline at end of file + manganato_manga_page_empty_pagination_mocked = empty_pag.read() + +with open('./tests/data/mangadex_empty_pagination_mock.json', 'r') as paginate: + mangadex_paginate_mocked: ISearchResult = json.loads(paginate.read()) \ No newline at end of file diff --git a/tests/test_mangadex_source.py b/tests/test_mangadex_source.py new file mode 100644 index 0000000..8f73ccb --- /dev/null +++ b/tests/test_mangadex_source.py @@ -0,0 +1,206 @@ +from unittest.mock import MagicMock, Mock, patch +import sys +import os + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) + +from enma.application.core.handlers.error import Forbidden, NotFound, Unknown +from enma.domain.entities.pagination import Thumb +from enma.infra.adapters.repositories.mangadex import Mangadex +from enma.domain.entities.manga import MIME, Author, Chapter, Genre, Image, SymbolicLink + +from tests.data.mocked_doujins import mangadex_paginate_mocked + +class TestMangadexSourceGetMethod: + + sut = Mangadex() + + def test_success_doujin_retrieve(self): + + res = self.sut.get(identifier='65498ee8-3c32-4228-b433-73a4d08f8927', + with_symbolic_links=True) + + assert res is not None + assert res.id == '65498ee8-3c32-4228-b433-73a4d08f8927' + assert res.title.english == "Monster Musume no Iru Nichijou" + assert res.title.japanese == "Monmusu" + assert res.title.other != '' + + for genre in res.genres: + assert isinstance(genre, Genre) + + for author in res.authors: + assert isinstance(author, Author) + + assert isinstance(res.thumbnail, Image) + assert isinstance(res.cover, Image) + + for chapter in res.chapters: + assert isinstance(chapter, Chapter) + assert isinstance(chapter.link, SymbolicLink) + + def test_response_when_it_could_not_get_doujin(self): + with pytest.raises(NotFound): + self.sut.get(identifier='manga-kb951984', with_symbolic_links=True) + + @patch('requests.get') + def test_raise_forbidden_in_case_of_403_status_code(self, mock_method: MagicMock): + mock = Mock() + mock.status_code = 403 + mock_method.return_value = mock + + with pytest.raises(Forbidden): + self.sut.get(identifier='manga-kb951984') + mock_method.assert_called_with(url='https://chapMangadex.com/manga-kb951984', + headers={'Referer': 'https://chapMangadex.com/'}, + params={}) + + @patch.object(sut, '_Mangadex__list_chapters') + def test_return_empty_chapters(self, mock_method: MagicMock): + mock_method.return_value = [] + + doujin = self.sut.get(identifier='65498ee8-3c32-4228-b433-73a4d08f8927', + with_symbolic_links=True) + + assert doujin is not None + + assert len(doujin.chapters) == 0 + assert doujin.chapters_count == 0 + assert doujin.id == '65498ee8-3c32-4228-b433-73a4d08f8927' + assert doujin.title.english == "Monster Musume no Iru Nichijou" + assert doujin.title.japanese == "Monmusu" + + for genre in doujin.genres: + assert isinstance(genre, Genre) + + for author in doujin.authors: + assert isinstance(author, Author) + + assert isinstance(doujin.thumbnail, Image) + assert isinstance(doujin.cover, Image) + + def test_get_with_symbolic_link(self): + + doujin = self.sut.get(identifier='65498ee8-3c32-4228-b433-73a4d08f8927', with_symbolic_links=True) + + assert doujin is not None + assert isinstance(doujin.chapters[0], Chapter) + assert doujin.chapters[0].link is not None + assert doujin.chapters[0].link != "" + +class TestMangadexSourcePaginationMethod: + sut = Mangadex() + + def test_success_pagination(self): + res = self.sut.paginate(page=2) + + assert res is not None + assert res.id is not None + assert res.page == 2 + assert res.total_pages > 0 + assert res.total_results > 0 + assert len(res.results) == 25 + + @patch('requests.get') + def test_must_return_empty_search_result(self, mock_method: MagicMock): + mock = Mock() + mock.status_code = 200 + mock.json.return_value = mangadex_paginate_mocked + mock_method.return_value = mock + + res = self.sut.paginate(page=2) + + assert res is not None + assert res.id is not None + assert res.page == 2 + assert res.total_pages > 0 + assert res.total_results == 0 + assert len(res.results) == 0 + + @patch('requests.get') + def test_response_when_forbidden(self, mock_method: MagicMock): + mock = Mock() + mock.status_code = 403 + mock_method.return_value = mock + + with pytest.raises(Forbidden): + self.sut.paginate(page=2) + +class TestMangadexSourceSearchMethod: + + sut = Mangadex() + + def test_success_searching(self): + + res = self.sut.search(query='GATE', page=1) + + assert res is not None + assert res.query == 'GATE' + assert res.id is not None + assert res.page == 1 + assert res.total_pages > 0 + assert len(res.results) > 0 + + for result in res.results: + assert isinstance(result, Thumb) + assert result.cover is not None + assert isinstance(result.cover, Image) + assert isinstance(result.cover.width, int) + assert isinstance(result.cover.height, int) + assert result.cover.width == 512 + assert result.cover.height == 0 + assert result.title is not None + assert result.id != 0 + + def test_success_searching_using_per_page_param(self): + + res = self.sut.search(query='GATE', page=1, per_page=1) + + assert res is not None + assert res.query == 'GATE' + assert res.id is not None + assert res.page == 1 + assert res.total_pages > 0 + assert len(res.results) == 1 + + for result in res.results: + assert isinstance(result, Thumb) + assert result.cover is not None + assert isinstance(result.cover, Image) + assert isinstance(result.cover.width, int) + assert isinstance(result.cover.height, int) + assert result.cover.width == 512 + assert result.cover.height == 0 + assert result.title is not None + assert result.id != 0 + + @patch('requests.get') + def test_response_when_forbidden(self, mock_method: MagicMock): + mock = Mock() + mock.status_code = 403 + mock_method.return_value = mock + + with pytest.raises(Forbidden): + self.sut.search(query='Monster Musume no Iru Nichijou', page=1) + + @patch('requests.get') + def test_response_when_not_found(self, mock_method: MagicMock): + mock = Mock() + mock.status_code = 404 + mock_method.return_value = mock + + with pytest.raises(NotFound): + self.sut.search(query='Monster Musume no Iru Nichijou', page=1) + + @patch('requests.get') + def test_response_when_unknown(self, mock_method: MagicMock): + mock = Mock() + mock.status_code = 500 + mock_method.return_value = mock + + with pytest.raises(Unknown): + self.sut.search(query='Monster Musume no Iru Nichijou', page=1) + + \ No newline at end of file diff --git a/tests/test_manganato_source.py b/tests/test_manganato_source.py index 4eb9426..9b01c92 100644 --- a/tests/test_manganato_source.py +++ b/tests/test_manganato_source.py @@ -98,10 +98,10 @@ def test_success_searching(self): res = self.sut.paginate(page=2) assert res is not None - assert res.id == 0 + assert res.id is not None assert res.page == 2 - assert res.total_pages == 1698 - assert res.total_results == 40752 + assert res.total_pages > 0 + assert res.total_results > 0 assert len(res.results) == 24 @patch('requests.get') @@ -116,10 +116,10 @@ def test_must_return_empty_search_result(self, mock_method: MagicMock): res = self.sut.paginate(page=2) assert res is not None - assert res.id == 0 + assert res.id is not None assert res.page == 2 - assert res.total_pages == 1698 - assert res.total_results == 40752 + assert res.total_pages > 0 + assert res.total_results > 0 assert len(res.results) == 0 @patch('requests.get') @@ -131,7 +131,7 @@ def test_response_when_forbidden(self, mock_method: MagicMock): res = self.sut.paginate(page=2) assert res is not None - assert res.id == 0 + assert res.id is not None assert res.page == 2 assert res.total_pages == 1 assert res.total_results == 0 @@ -147,7 +147,7 @@ def test_success_searching(self): assert res is not None assert res.query == 'GATE' - assert res.id == 0 + assert res.id is not None assert res.page == 1 assert res.total_pages == 4 assert len(res.results) == 20 @@ -173,7 +173,7 @@ def test_response_when_forbidden(self, mock_method: MagicMock): assert search is not None assert search.query == 'Monster Musume no Iru Nichijou' - assert search.id == 0 + assert search.id is not None assert search.page == 1 assert search.total_pages == 1 - assert len(search.results) == 0 \ No newline at end of file + assert len(search.results) == 0 diff --git a/tests/test_nhentai_fetch_chapter_by_symbolic_link.py b/tests/test_nhentai_fetch_chapter_by_symbolic_link.py index 31886a7..3551249 100644 --- a/tests/test_nhentai_fetch_chapter_by_symbolic_link.py +++ b/tests/test_nhentai_fetch_chapter_by_symbolic_link.py @@ -8,6 +8,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) +from enma.application.core.handlers.error import NotFound from enma.application.use_cases.fetch_chapter_by_symbolic_link import FetchChapterBySymbolicLinkRequestDTO, FetchChapterBySymbolicLinkUseCase from enma.application.core.interfaces.use_case import DTO from enma.infra.adapters.repositories.nhentai import CloudFlareConfig, NHentai @@ -51,7 +52,7 @@ def test_fetch_chapter_by_symbolic_link(self): assert response.chapter.id == 0 assert len(response.chapter.pages) == 14 - def test_should_return_empty_chapter_for_broken_link(self): + def test_should_raise_exception_for_broken_link(self): with patch('requests.get') as mock_method: mock = Mock() mock.status_code = 404 @@ -59,14 +60,10 @@ def test_should_return_empty_chapter_for_broken_link(self): mock_method.return_value = mock link = SymbolicLink(link='https://nhentai.net') - response = self.sut.execute(dto=DTO(data=FetchChapterBySymbolicLinkRequestDTO(link=link))) - assert isinstance(response.chapter, Chapter) - assert response.chapter.link is None - assert response.chapter.pages_count == 0 - assert response.chapter.id == 0 - assert len(response.chapter.pages) == 0 - + with pytest.raises(NotFound): + self.sut.execute(dto=DTO(data=FetchChapterBySymbolicLinkRequestDTO(link=link))) + def test_should_return_empty_chapter_for_broken_response(self): with patch('requests.get') as mock_method: mock = Mock() diff --git a/tests/test_nhentai_get_manga_use_case.py b/tests/test_nhentai_get_manga_use_case.py index 295f716..9195b27 100644 --- a/tests/test_nhentai_get_manga_use_case.py +++ b/tests/test_nhentai_get_manga_use_case.py @@ -9,7 +9,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from enma.application.core.handlers.error import InvalidRequest +from enma.application.core.handlers.error import Forbidden, InvalidRequest, NotFound from enma.infra.core.interfaces.nhentai_response import NHentaiResponse from enma.application.use_cases.get_manga import GetMangaRequestDTO, GetMangaUseCase from enma.application.core.interfaces.use_case import DTO @@ -69,8 +69,8 @@ def test_must_return_other_titles_as_none_if_doesnt_exists(self): with open('./tests/data/get.json', 'r') as get: data: NHentaiResponse = json.loads(get.read()) - data['title']['japanese'] = None - data['title']['pretty'] = None + data['title']['japanese'] = None # type: ignore + data['title']['pretty'] = None # type: ignore mock.json.return_value = data @@ -86,27 +86,23 @@ def test_must_return_other_titles_as_none_if_doesnt_exists(self): assert res.manga.title.other == None def test_response_when_it_could_not_get_doujin(self): - with patch('enma.infra.adapters.repositories.nhentai.NHentai.get') as mock_method: - mock_method.return_value = None - + with patch('enma.infra.adapters.repositories.nhentai.NHentai.get', side_effect=NotFound("Could not find the manga")) as mock_method: doujin = self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='1'))) - assert doujin.found == False assert doujin.manga is None - def test_return_none_when_not_receive_200_status_code(self): + def test_raise_forbidden_in_case_of_403(self): with patch('requests.get') as mock_method: mock = Mock() mock.status_code = 403 mock_method.return_value = mock - doujin = self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='1'))) - assert doujin.found == False - assert doujin.manga is None - mock_method.assert_called_with(url=f'https://nhentai.net/api//gallery/1', - headers={'User-Agent': 'mocked'}, - params={}, - cookies={'cf_clearance': 'mocked'}) + with pytest.raises(Forbidden): + self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='1'))) + mock_method.assert_called_with(url=f'https://nhentai.net/api//gallery/1', + headers={'User-Agent': 'mocked'}, + params={}, + cookies={'cf_clearance': 'mocked'}) def test_return_empty_chapters(self): with patch('requests.get') as mock_method: @@ -207,8 +203,12 @@ def test_images_mime_types_must_be_correct(self): cover_mime = data['images']['cover']['t'] thumb_mime = data['images']['thumbnail']['t'] - assert cover_mime.upper() == doujin.manga.cover.mime.name - assert thumb_mime.upper() == doujin.manga.thumbnail.mime.name + assert doujin.manga.thumbnail is not None + assert doujin.manga.cover is not None + assert cover_mime.upper() == 'P' + assert doujin.manga.cover.mime.value == 'png' + assert thumb_mime.upper() == 'J' + assert doujin.manga.thumbnail.mime.value == 'jpg' @patch('enma.application.use_cases.get_manga.GetMangaUseCase.execute') def test_symbolic_links_must_be_disabled_by_default(self, use_case_mock: MagicMock): @@ -217,4 +217,4 @@ def test_symbolic_links_must_be_disabled_by_default(self, use_case_mock: MagicMo def test_must_raise_an_exception_case_user_has_provided_wrong_data_type(self): with pytest.raises(ValidationError) as _: - self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='420719', with_symbolic_links='nao'))) \ No newline at end of file + self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='420719', with_symbolic_links='nao'))) # type: ignore \ No newline at end of file diff --git a/tests/test_nhentai_source.py b/tests/test_nhentai_source.py index 6b4688b..6fca3e8 100644 --- a/tests/test_nhentai_source.py +++ b/tests/test_nhentai_source.py @@ -9,7 +9,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) from enma.infra.core.interfaces.nhentai_response import NHentaiImage -from enma.application.core.handlers.error import InvalidConfig, InvalidRequest, NhentaiSourceWithoutConfig +from enma.application.core.handlers.error import Forbidden, InvalidConfig, InvalidRequest, NhentaiSourceWithoutConfig, NotFound from enma.domain.entities.pagination import Thumb from tests.data.mocked_doujins import (nhentai_doujin_mocked, nhentai_search_mocked, @@ -18,17 +18,11 @@ nhentai_author_mocked, nhentai_author_not_found_mocked) from enma.infra.adapters.repositories.nhentai import CloudFlareConfig, NHentai, Sort -from enma.domain.entities.manga import MIME, Author, Chapter, Genre, Image, Manga, SymbolicLink +from enma.domain.entities.manga import MIME, Author, Chapter, Genre, Image, SymbolicLink from enma.application.core.utils.logger import logger class TestNHentaiUtils: sut = NHentai(config=CloudFlareConfig(user_agent='mock', cf_clearance='mock')) - - @patch.object(logger, 'error') - def test_request_error_handler(self, logger_mock: MagicMock): - res = self.sut._NHentai__handle_request_error('teste') # type: ignore - assert res is None - logger_mock.assert_called_with('teste') def test_raise_error_if_passing_wrong_config(self): with pytest.raises(InvalidConfig) as err: @@ -91,7 +85,7 @@ def test_chapter_creator(self): assert isinstance(chapter.pages[0], Image) assert chapter.pages[0].width == 123 assert chapter.pages[0].height == 123 - assert chapter.pages[0].mime.name == 'J' + assert chapter.pages[0].mime.value == 'jpg' assert chapter.link is None def test_chapter_creator_with_symbolic_links(self): @@ -161,25 +155,25 @@ def test_must_return_other_titles_as_none_if_doesnt_exists(self, mock_method: Ma @patch('requests.get') def test_response_when_it_could_not_get_doujin(self, mock_method: MagicMock): - mock_method.status_code = 404 + mock = Mock() + mock.status_code = 404 + mock_method.return_value = mock - doujin = self.sut.get(identifier='1') - - assert doujin is None + with pytest.raises(NotFound): + self.sut.get(identifier='1') @patch('requests.get') - def test_return_none_when_not_receive_200_status_code(self, mock_method: MagicMock): + def test_raise_forbidden_when_receive_403_status_code(self, mock_method: MagicMock): mock = Mock() mock.status_code = 403 mock_method.return_value = mock - doujin = self.sut.get(identifier='1') - - assert doujin is None - mock_method.assert_called_with(url=f'https://nhentai.net/api//gallery/1', - headers={'User-Agent': 'mock'}, - params={}, - cookies={'cf_clearance': 'mock'}) + with pytest.raises(Forbidden): + self.sut.get(identifier='1') + mock_method.assert_called_with(url=f'https://nhentai.net/api//gallery/1', + headers={'User-Agent': 'mock'}, + params={}, + cookies={'cf_clearance': 'mock'}) @patch('requests.get') def test_return_empty_chapters(self, mock_method: MagicMock): @@ -266,8 +260,12 @@ def test_images_mime_types_must_be_correct(self, mock_method: MagicMock): cover_mime = nhentai_doujin_mocked['images']['cover']['t'] thumb_mime = nhentai_doujin_mocked['images']['thumbnail']['t'] - assert cover_mime.upper() == doujin.cover.mime.name - assert thumb_mime.upper() == doujin.thumbnail.mime.name + assert doujin.thumbnail is not None + assert doujin.cover is not None + assert cover_mime.upper() == 'P' + assert doujin.cover.mime.value == 'png' + assert thumb_mime.upper() == 'J' + assert doujin.thumbnail.mime.value == 'jpg' class TestNHentaiSourcePaginationMethod: sut = NHentai(config=CloudFlareConfig(user_agent='mock', cf_clearance='mock')) @@ -283,7 +281,7 @@ def test_success_searching(self, mock_method: MagicMock): res = self.sut.paginate(page=2) assert res is not None - assert res.id == 0 + assert res.id is not None assert res.page == 2 assert res.total_pages == nhentai_paginate_mocked['num_pages'] assert res.total_results == 25 * 19163 @@ -303,21 +301,15 @@ def test_must_return_empty_search_result(self, mock_method: MagicMock): res = self.sut.paginate(page=2) assert res is not None - assert res.id == 0 + assert res.id is not None assert res.page == 2 assert res.total_pages == nhentai_paginate_mocked['num_pages'] assert res.total_results == 25 * 19163 assert len(res.results) == 0 def test_response_when_forbidden(self): - res = self.sut.paginate(page=2) - - assert res is not None - assert res.id == 0 - assert res.page == 2 - assert res.total_pages == 1 - assert res.total_results == 0 - assert len(res.results) == 0 + with pytest.raises(Forbidden): + self.sut.paginate(page=2) class TestNHentaiSourceSearchMethod: @@ -335,7 +327,7 @@ def test_success_searching(self, mock_method: MagicMock): assert res is not None assert res.query == 'GATE' - assert res.id == 0 + assert res.id is not None assert res.page == 2 assert res.total_pages == 3 assert len(res.results) == 25 @@ -369,21 +361,15 @@ def test_must_return_empty_search_result(self, mock_method: MagicMock): assert res is not None assert res.query == 'GATE' - assert res.id == 0 + assert res.id is not None assert res.page == 2 assert res.total_pages == 1 assert len(res.results) == 0 def test_response_when_forbidden(self): - search = self.sut.search(query='Monster Musume no Iru Nichijou', page=4) - - assert search is not None - assert search is not None - assert search.query == 'Monster Musume no Iru Nichijou' - assert search.id == 0 - assert search.page == 4 - assert search.total_pages == 1 - assert len(search.results) == 0 + with pytest.raises(Forbidden): + self.sut.search(query='Monster Musume no Iru Nichijou', page=4) + class TestNHentaiSourceAuthorPageMethod: @@ -401,7 +387,7 @@ def test_success_author_page_fetching(self, mock_method: MagicMock): assert res is not None assert res.author == 'akaneman' - assert res.id == 0 + assert res.id is not None assert res.page == 2 assert res.total_pages == 2 assert len(res.results) == 25 @@ -433,18 +419,11 @@ def test_must_return_empty_author_page_result(self, mock_method: MagicMock): assert res is not None assert res.author == 'asdsadadasd' - assert res.id == 0 + assert res.id is not None assert res.page == 1 assert res.total_pages == 1 assert len(res.results) == 0 def test_response_when_forbidden(self): - search = self.sut.author_page(author='akaneman', page=1) - - assert search is not None - assert search is not None - assert search.author == 'akaneman' - assert search.id == 0 - assert search.page == 1 - assert search.total_pages == 1 - assert len(search.results) == 0 + with pytest.raises(Forbidden): + self.sut.author_page(author='akaneman', page=1)