From efb0e5d4832cdb8badfb301f082268479120a042 Mon Sep 17 00:00:00 2001 From: Neki <84998222+Nekidev@users.noreply.github.com> Date: Fri, 6 Jan 2023 23:51:44 -0300 Subject: [PATCH] Added Nekos API wrapper --- README.md | 9 +- anime_api/__init__.py | 10 + anime_api/apis/__init__.py | 3 +- anime_api/apis/animechan/__init__.py | 52 ++-- anime_api/apis/nekobot/__init__.py | 2 + anime_api/apis/nekos_api/__init__.py | 235 ++++++++++++++++++ anime_api/apis/nekos_api/objects.py | 110 ++++++++ anime_api/apis/nekos_api/types.py | 16 ++ anime_api/apis/waifu_im/__init__.py | 4 +- anime_api/exceptions.py | 2 +- anime_api/utils.py | 18 ++ docs/README.md | 5 + poetry.lock | 29 ++- pyproject.toml | 3 +- tests/{hmtai.py => _test_hmtai.py} | 0 ...st_api.py => test_anime_facts_rest_api.py} | 0 tests/{animechan.py => test_animechan.py} | 24 +- tests/{animu.py => test_animu.py} | 0 tests/{kyoko.py => test_kyoko.py} | 0 tests/{neko_love.py => test_neko_love.py} | 0 tests/{nekobot.py => test_nekobot.py} | 4 +- tests/test_nekos_api.py | 127 ++++++++++ tests/{nekos_best.py => test_nekos_best.py} | 4 + tests/{nekos_life.py => test_nekos_life.py} | 2 + tests/{nekos_moe.py => test_nekos_moe.py} | 0 ...hibli_api.py => test_studio_ghibli_api.py} | 0 tests/{trace_moe.py => test_trace_moe.py} | 0 tests/{waifu_im.py => test_waifu_im.py} | 0 tests/{waifu_pics.py => test_waifu_pics.py} | 0 29 files changed, 617 insertions(+), 42 deletions(-) create mode 100644 anime_api/apis/nekos_api/__init__.py create mode 100644 anime_api/apis/nekos_api/objects.py create mode 100644 anime_api/apis/nekos_api/types.py create mode 100644 anime_api/utils.py rename tests/{hmtai.py => _test_hmtai.py} (100%) rename tests/{anime_facts_rest_api.py => test_anime_facts_rest_api.py} (100%) rename tests/{animechan.py => test_animechan.py} (76%) rename tests/{animu.py => test_animu.py} (100%) rename tests/{kyoko.py => test_kyoko.py} (100%) rename tests/{neko_love.py => test_neko_love.py} (100%) rename tests/{nekobot.py => test_nekobot.py} (85%) create mode 100644 tests/test_nekos_api.py rename tests/{nekos_best.py => test_nekos_best.py} (91%) rename tests/{nekos_life.py => test_nekos_life.py} (94%) rename tests/{nekos_moe.py => test_nekos_moe.py} (100%) rename tests/{studio_ghibli_api.py => test_studio_ghibli_api.py} (100%) rename tests/{trace_moe.py => test_trace_moe.py} (100%) rename tests/{waifu_im.py => test_waifu_im.py} (100%) rename tests/{waifu_pics.py => test_waifu_pics.py} (100%) diff --git a/README.md b/README.md index e40694d..2c3dc6f 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ These are the currently supported and planned to add support for APIs: | Danbooru | [Documentation](https://danbooru.donmai.us/wiki_pages/help:api) | ❌ | | Yandere | [Documentation](https://yande.re/help/api) | ❌ | | Konachan | [Documentation](https://konachan.com/help/api) | ❌ | -| Waifu.im | [Documentation](https://waifu.im/) | ✅ | +| Waifu.im | [Documentation](https://waifu.im/) | ✅ | | Catboys | [Documentation](https://catboys.com/api) | ❌ | | Anime Character Database | [Documentation](http://wiki.animecharactersdatabase.com/index.php?title=API_Access) | ❌ | | AniBase | Not released | ❌ | -| Nekos API | Not released | ❌ | +| Nekos API | [Documentation](https://nekos.nekidev.com/docs/rest-api/endpoints) | ✅ | ### APIs by feature @@ -77,6 +77,11 @@ You know what you want to do, but have no idea of what API will work for you? Th #### Images +- Nekos API: + - Thousands of anime images + - Lots of image metadata + - Actively developed (frequent new features, images, categories, and more) + - Completely free - Animu: - Tons of anime gifs and images - Get reaction gifs from +60 different categories diff --git a/anime_api/__init__.py b/anime_api/__init__.py index 654c24a..be29536 100644 --- a/anime_api/__init__.py +++ b/anime_api/__init__.py @@ -59,4 +59,14 @@ "http://wiki.animecharactersdatabase.com/index.php?title=API_Access", False, ), + ( + "Nekos API", + apis.NekosAPI, + "https://nekos.nekidev.com/docs/rest-api/endpoints", + True, + ), ] + +__version__ = '0.15.0' +__authors__ = ['Nekidev '] +__license__ = "MIT License" diff --git a/anime_api/apis/__init__.py b/anime_api/apis/__init__.py index 7825d0c..f0f7a71 100644 --- a/anime_api/apis/__init__.py +++ b/anime_api/apis/__init__.py @@ -14,4 +14,5 @@ from .neko_love import NekoLoveAPI from .nekos_moe import NekosMoeAPI from .nekos_best import NekosBest -from .waifu_im import WaifuImAPI \ No newline at end of file +from .waifu_im import WaifuImAPI +from .nekos_api import NekosAPI diff --git a/anime_api/apis/animechan/__init__.py b/anime_api/apis/animechan/__init__.py index bd42098..f46fa25 100644 --- a/anime_api/apis/animechan/__init__.py +++ b/anime_api/apis/animechan/__init__.py @@ -2,6 +2,8 @@ Base module for the Animechan API. The documentation is available at https://animechan.vercel.app/guide """ +from urllib.parse import quote_plus + import typing import requests @@ -19,13 +21,27 @@ class AnimechanAPI: def __init__(self, endpoint: typing.Optional[str] = None): self.endpoint = endpoint or self.endpoint - def get_random_quote(self) -> Quote: + def get_random_quote( + self, + anime_title: typing.Optional[str] = None, + character_name: typing.Optional[str] = None, + ) -> Quote: """ Get a random quote from the API. """ - response = requests.get(f"{self.endpoint}/random") + response = requests.get( + f"{self.endpoint}/random" + + ( + "/character?name=" + quote_plus(character_name) + if character_name + else "/anime?title=" + quote_plus(anime_title) + if anime_title + else "" + ), + timeout=5 + ) - AnimechanAPI.__check_response_code(response.status_code) + AnimechanAPI._check_response_code(response.status_code) return Quote(**response.json()) @@ -35,11 +51,13 @@ def get_many_random_quotes(self) -> typing.List[Quote]: """ response = requests.get(f"{self.endpoint}/quotes") - AnimechanAPI.__check_response_code(response.status_code) + AnimechanAPI._check_response_code(response.status_code) return [Quote(**quote) for quote in response.json()] - def search_by_anime_title(self, anime_title: str, page: int = 1) -> typing.List[Quote]: + def search_by_anime_title( + self, anime_title: str, page: int = 1 + ) -> typing.List[Quote]: """ Return a list of quotes from the given anime. """ @@ -47,11 +65,13 @@ def search_by_anime_title(self, anime_title: str, page: int = 1) -> typing.List[ f"{self.endpoint}/quotes/anime", params={"title": anime_title, "page": page} ) - AnimechanAPI.__check_response_code(response.status_code) + AnimechanAPI._check_response_code(response.status_code) return [Quote(**quote) for quote in response.json()] - def search_by_character_name(self, character_name: str, page: int = 1) -> typing.List[Quote]: + def search_by_character_name( + self, character_name: str, page: int = 1 + ) -> typing.List[Quote]: """ Return a list of quotes from the given character. """ @@ -60,25 +80,13 @@ def search_by_character_name(self, character_name: str, page: int = 1) -> typing params={"name": character_name, "page": page}, ) - AnimechanAPI.__check_response_code(response.status_code) + AnimechanAPI._check_response_code(response.status_code) return [Quote(**quote) for quote in response.json()] - def get_animes(self, page: int = 1) -> typing.List[str]: - """ - Return a list of animes. - """ - response = requests.get(f"{self.endpoint}/available/anime", params={"page": page}) - - AnimechanAPI.__check_response_code(response.status_code) - - AnimechanAPI.__check_response_code(response.status_code) - - return response.json() - @staticmethod - def __check_response_code(status_code: int, msg: str = ''): + def _check_response_code(status_code: int, msg: str = ""): if status_code == 404: raise exceptions.NotFound() - elif status_code != 200: + if status_code != 200: raise exceptions.ServerError(status_code=status_code, msg=msg) diff --git a/anime_api/apis/nekobot/__init__.py b/anime_api/apis/nekobot/__init__.py index 4e6e413..8325696 100644 --- a/anime_api/apis/nekobot/__init__.py +++ b/anime_api/apis/nekobot/__init__.py @@ -2,6 +2,8 @@ Base module for the NekoBot API. Documentation can be found at https://docs.nekobot.xyz """ +from urllib.parse import quote_plus + import typing import requests diff --git a/anime_api/apis/nekos_api/__init__.py b/anime_api/apis/nekos_api/__init__.py new file mode 100644 index 0000000..dd91438 --- /dev/null +++ b/anime_api/apis/nekos_api/__init__.py @@ -0,0 +1,235 @@ +# pylint: disable=expression-not-assigned, missing-timeout +""" +Base module for Nekos API. Documentation can be found at +https://nekos.nekidev.com/docs/rest-api/endpoints +""" +from urllib.parse import quote +from datetime import datetime +from functools import wraps + +import typing +import time +import re + +import requests + +from anime_api import exceptions +from anime_api.apis.nekos_api.objects import Image, Artist, Character, Category +from anime_api.utils import to_snake + + +last_request: typing.Optional[datetime] = None + + +def prevent_ratelimit(func): + """ + Sleeps necessary time before running a class method to prevent ratelimiting. + """ + + @wraps(func) + def decorator(*args, **kwargs): + global last_request + now = datetime.now() + if last_request is None: + # No request has been made. + result = func(*args, **kwargs) + last_request = datetime.now() + return result + + elapsed_time = now - last_request + wait_time = 1 - (elapsed_time.microseconds / 1000000) + + time.sleep(wait_time) + + result = func(*args, **kwargs) + last_request = datetime.now() + return result + + return decorator + + +class NekosAPI: + """ + Docs: https://nekos.nekidev.com/docs/rest-api/endpoints + """ + + endpoint: str = "https://nekos.nekidev.com/api" + token: typing.Optional[str] = None + + def __init__( + self, endpoint: typing.Optional[str] = None, token: typing.Optional[str] = None + ): + self.endpoint = endpoint or self.endpoint + + if token: + NekosAPI._validate_token(token) + + self.token = token + + @prevent_ratelimit + def get_images(self, limit: int = 10, offset: int = 0): + """ + Returns a list of images. + * Requires a valid access token. + """ + if not self.token: + raise ValueError("You need a valid access token to use this method.") + + headers = {"Authorization": "Bearer " + self.token} + params = {"limit": limit, "offset": offset} + + response = requests.get( + self.endpoint + "/image", headers=headers, params=params + ) + + NekosAPI._check_response_code(response) + + data = response.json() + + return [Image.from_json(image) for image in data["data"]] + + @prevent_ratelimit + def get_random_image( + self, categories: typing.Optional[typing.List[str]] = None + ) -> Image: + """ + Returns a random image with the specified categories or completely + random if categories are not specified. + """ + params = {"categories": ",".join(categories)} if categories else {} + + response = requests.get(self.endpoint + "/image/random", params=params) + + NekosAPI._check_response_code(response) + + data = response.json() + + return Image.from_json(data["data"][0]) + + @prevent_ratelimit + def get_random_images( + self, count: int = 10, categories: typing.Optional[typing.List[str]] = None + ) -> typing.List[Image]: + """ + Returns a certain amount of random images with the specified categories or completely + random if categories are not specified. + """ + params = {"limit": count} + params.update({"categories": ",".join(categories)}) if categories else None + + response = requests.get(self.endpoint + "/image/random", params=params) + + NekosAPI._check_response_code(response) + + data = response.json() + + return [Image.from_json(image) for image in data["data"]] + + @prevent_ratelimit + def get_image_by_id(self, image_id: str) -> Image: + """ + Returns an image by its ID + """ + response = requests.get(self.endpoint + "/image/" + quote(image_id)) + + NekosAPI._check_response_code(response) + + data = response.json() + + return Image.from_json(data["data"]) + + @prevent_ratelimit + def get_artist_by_id(self, artist_id: str) -> Artist: + """ + Returns an artist by its ID + """ + response = requests.get(self.endpoint + "/artist/" + quote(artist_id)) + + NekosAPI._check_response_code(response) + + data = response.json() + + return Artist(**to_snake(data["data"])) + + @prevent_ratelimit + def get_images_by_artist_id( + self, artist_id: str, limit: int = 10, offset: int = 0 + ) -> typing.List[Image]: + """ + Returns all artist's images. + """ + response = requests.get( + self.endpoint + "/artist/" + quote(artist_id) + "/images", + params={"limit": limit, "offset": offset}, + ) + + NekosAPI._check_response_code(response) + + data = response.json() + + return [Image.from_json(image) for image in data["data"]] + + @prevent_ratelimit + def get_categories(self, limit: int = 10, offset: int = 0) -> typing.List[Category]: + """ + Returns a list of all categories. + """ + response = requests.get( + self.endpoint + "/category", params={"limit": limit, "offset": offset} + ) + + NekosAPI._check_response_code(response) + + data = response.json() + + return [Category(**to_snake(category)) for category in data["data"]] + + @prevent_ratelimit + def get_category_by_id(self, category_id: str) -> Category: + """ + Returns a category by it's ID. + """ + response = requests.get(self.endpoint + "/category/" + quote(category_id)) + + NekosAPI._check_response_code(response) + + data = response.json() + + return Category(**to_snake(data["data"])) + + @prevent_ratelimit + def get_character_by_id(self, character_id: str) -> Character: + """ + Returns a character by it's ID. + """ + response = requests.get(self.endpoint + "/character/" + quote(character_id)) + + NekosAPI._check_response_code(response) + + data = response.json() + + return Character(**to_snake(data["data"])) + + @staticmethod + def _check_response_code(res) -> None: + """ + Check if the request was successful. + """ + data = res.json() + success = data["success"] + if res.status_code not in range(200, 300) or not success: + raise exceptions.ServerError( + res.status_code, + f"An error occurred while fetching the data from the server{'. ' + data['message'] if 'message' in data else ''}", + ) + + @staticmethod + def _validate_token(token) -> None: + """ + Validate the API token. + """ + exp = r"^[0-9a-zA-Z]{100}$" + if len(re.findall(exp, token)) == 0: + raise ValueError( + "The token is invalid. It should be 100 characters long and contain numbers and lowercase/uppercase characters." + ) diff --git a/anime_api/apis/nekos_api/objects.py b/anime_api/apis/nekos_api/objects.py new file mode 100644 index 0000000..cef976c --- /dev/null +++ b/anime_api/apis/nekos_api/objects.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass +from datetime import datetime + +import typing + +from dateutil import parser + +from anime_api.apis.nekos_api.types import NsfwLevel, ImageOrientation +from anime_api.utils import to_snake + + +@dataclass +class Artist: + """ + Object representation of an artist + """ + + id: str + name: str + url: typing.Optional[str] + images: typing.Optional[int] = None + + +@dataclass +class _Source: + name: str + url: str + + +@dataclass +class Category: + """ + Object representation of a category + """ + + id: str + name: str + description: str + nsfw: bool + type: str + created_at: datetime + images: typing.Optional[int] = None + + +@dataclass +class Character: + """ + Object representation of a character + """ + + id: str + name: str + description: str + source: str + created_at: datetime + images: typing.Optional[int] = None + + +@dataclass +class _Dimens: + height: int + width: int + aspect_ratio: str + orientation: ImageOrientation + +@dataclass +class Image: + """ + Object representation of an image + """ + + id: str + url: str + artist: typing.Optional[Artist] + source: typing.Optional[_Source] + original: typing.Optional[bool] + nsfw: NsfwLevel + categories: typing.List[Category] + characters: typing.List[Character] + created_at: datetime + etag: str + size: int + mimetype: str + color: str + expires: datetime + dimens: _Dimens + + def from_json(data: dict) -> 'Image': + return Image( + id=data["id"], + url=data["url"], + artist=Artist(**data["artist"]) if data["artist"] else None, + source=_Source(**data["source"]) if data["source"] else None, + original=data["original"], + nsfw=NsfwLevel(data["nsfw"]), + categories=[Category(**to_snake(c)) for c in data["categories"]], + characters=[Character(**to_snake(c)) for c in data["characters"]], + created_at=parser.parse(data["createdAt"]), + etag=data["meta"]["eTag"], + size=data["meta"]["size"], + mimetype=data["meta"]["mimetype"], + color=data["meta"]["color"], + expires=parser.parse(data["meta"]["expires"]), + dimens=_Dimens( + height=data["meta"]["dimens"]["height"], + width=data["meta"]["dimens"]["width"], + aspect_ratio=data["meta"]["dimens"]["height"], + orientation=ImageOrientation(data["meta"]["dimens"]["orientation"]), + ), + ) diff --git a/anime_api/apis/nekos_api/types.py b/anime_api/apis/nekos_api/types.py new file mode 100644 index 0000000..c38596d --- /dev/null +++ b/anime_api/apis/nekos_api/types.py @@ -0,0 +1,16 @@ +import typing + +from enum import Enum + + +class NsfwLevel(Enum): + UNKNOWN = None + SFW = "sfw" + QUESTIONABLE = "questionable" + NSFW = "nsfw" + + +class ImageOrientation(Enum): + LANDSCAPE = "landscape" + PORTRAIT = "portrait" + SQUARE = "square" diff --git a/anime_api/apis/waifu_im/__init__.py b/anime_api/apis/waifu_im/__init__.py index 947c114..93dbedc 100644 --- a/anime_api/apis/waifu_im/__init__.py +++ b/anime_api/apis/waifu_im/__init__.py @@ -89,7 +89,7 @@ def get_random_image( excluded_tags: typing.Optional[ typing.List[typing.Union[ImageTag.SFW, ImageTag.NSFW]] ] = None, - selected_file: typing.Optional[str] = None, + selected_file: typing.Optional[typing.List[str]] = None, excluded_files: typing.Optional[typing.List[str]] = None, is_nsfw: typing.Optional[bool] = None, is_gif: bool = False, @@ -104,7 +104,7 @@ def get_random_image( many=False, tags=tags, excluded_tags=excluded_tags, - included_files=[selected_file], + included_files=selected_file, excluded_files=excluded_files, is_nsfw=is_nsfw, is_gif=is_gif, diff --git a/anime_api/exceptions.py b/anime_api/exceptions.py index 3694aec..2c620bd 100644 --- a/anime_api/exceptions.py +++ b/anime_api/exceptions.py @@ -13,7 +13,7 @@ def __init__( self, status_code: int, msg: typing.Optional[str] = None, *args, **kwargs ): super().__init__( - f"The server returned an error{': ' + msg if msg else ''}. (Status code: {status_code})", + f"The server returned an error{': ' + msg if msg else '.'} (Status code: {status_code})", *args, **kwargs, ) diff --git a/anime_api/utils.py b/anime_api/utils.py new file mode 100644 index 0000000..4f3a0d7 --- /dev/null +++ b/anime_api/utils.py @@ -0,0 +1,18 @@ +import re +import typing + + +def camel_to_snake(s) -> str: + return re.sub("([A-Z]\w+$)", "_\\1", s).lower() + + +def to_snake(d) -> typing.Union[dict, list]: + """ + Converts a dict or list to snake case from camel case + """ + if isinstance(d, list): + return [to_snake(i) if isinstance(i, (dict, list)) else i for i in d] + return { + camel_to_snake(a): to_snake(b) if isinstance(b, (dict, list)) else b + for a, b in d.items() + } diff --git a/docs/README.md b/docs/README.md index 1328fcd..1bec45f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,7 @@ All API wrappers can be imported from `anime_api.apis`. For example, the Anime Facts Rest API's wrapper class can be imported from `anime_api.apis.anime_facts_rest_api` or directly from `anime_api.apis`. +- [Nekos API](#nekos-api) - [Anime Facts Rest API](#anime-facts-rest-api) - [Trace.moe API](#tracemoe-api) - [Animechan API](#animechan-api) @@ -40,6 +41,10 @@ from anime_api import api_list The list contains a `tuple` with 4 items for each API. For each `api` in the list, `api[0]` is the API name, `api[1]` is the wrapper class (or `None` if not available), `api[2]` is the documentation URL, and `api[3]` is a boolean that will be true or false depending of the availability of the API (`True` if available, otherwise `False`). +## Nekos API + +Nekos API is an actively developed free open-source anime images API that serves anime images. The project is mantained by [Nekidev](https://github.com/Nekidev) and the source code can be found in it's [GitHub repository](https://github.com/Nekidev/nekos-api). You can join the [official Discord server]() + ## Anime Facts Rest API The Anime Facts Rest API is an API written in Node.js to get anime facts. The project is mantained by [Chadan-02](https://github.com/chandan-02) and the API documentation can be found [here](https://chandan-02.github.io/anime-facts-rest-api/). diff --git a/poetry.lock b/poetry.lock index bbb6d66..9d9c8a4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -178,6 +178,17 @@ tomli = ">=1.0.0" [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + [[package]] name = "requests" version = "2.28.1" @@ -196,6 +207,14 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "tomli" version = "2.0.1" @@ -248,7 +267,7 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "1.1" python-versions = ">=3.7,<4" -content-hash = "5786e159c6f2b42632a8d13972e331484ae7b7d48e4fb5ee24652a1fbbb2f67c" +content-hash = "6f7f888f6ce83e0eaaddac557710f188ba1eae463959fc3115067b6a4479d5e8" [metadata.files] attrs = [ @@ -310,10 +329,18 @@ pytest = [ {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, ] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] requests = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, ] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, diff --git a/pyproject.toml b/pyproject.toml index 0f0734d..fa41ee0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "anime-api" -version = "0.14.0" +version = "0.15.0" description = "A collection of wrappers for anime-related APIs" authors = ["Neki <84998222+Nekidev@users.noreply.github.com>"] readme = "README.md" @@ -10,6 +10,7 @@ packages = [{include = "anime_api"}] python = ">=3.7,<4" requests = "^2.28.1" pykakasi = "^2.2.1" +python-dateutil = "^2.8.2" [tool.poetry.dev-dependencies] pytest = "^7.1.3" diff --git a/tests/hmtai.py b/tests/_test_hmtai.py similarity index 100% rename from tests/hmtai.py rename to tests/_test_hmtai.py diff --git a/tests/anime_facts_rest_api.py b/tests/test_anime_facts_rest_api.py similarity index 100% rename from tests/anime_facts_rest_api.py rename to tests/test_anime_facts_rest_api.py diff --git a/tests/animechan.py b/tests/test_animechan.py similarity index 76% rename from tests/animechan.py rename to tests/test_animechan.py index ffe98e9..8192865 100644 --- a/tests/animechan.py +++ b/tests/test_animechan.py @@ -17,6 +17,20 @@ def test_get_quote(): assert isinstance(quote, Quote), "The return type of get_quote() is not a Quote." + quote = AnimechanAPI().get_random_quote(anime_title="naruto") + + assert isinstance(quote, Quote), "The return type of get_quote() is not a Quote." + assert ( + quote.anime.lower() == "naruto" + ), "The returned quote is not from the selected anime." + + quote = AnimechanAPI().get_random_quote(character_name="naruto uzumaki") + + assert isinstance(quote, Quote), "The return type of get_quote() is not a Quote." + assert ( + quote.character.lower() == "naruto uzumaki" + ), "The returned quote is not from the selected character." + def test_get_many_random_quotes(): """ @@ -60,13 +74,3 @@ def test_search_quote_by_character_name(): quotes, list ), "The return type of search_quote_by_character_name() is not a list." assert len(quotes) > 0, "The list of quotes is empty." - - -def test_get_animes(): - """ - Test the get_animes method. Should return a list of anime titles. - """ - animes = AnimechanAPI().get_animes() - - assert isinstance(animes, list), "The return type of get_animes() is not a list." - assert len(animes) > 0, "The list of animes is empty." diff --git a/tests/animu.py b/tests/test_animu.py similarity index 100% rename from tests/animu.py rename to tests/test_animu.py diff --git a/tests/kyoko.py b/tests/test_kyoko.py similarity index 100% rename from tests/kyoko.py rename to tests/test_kyoko.py diff --git a/tests/neko_love.py b/tests/test_neko_love.py similarity index 100% rename from tests/neko_love.py rename to tests/test_neko_love.py diff --git a/tests/nekobot.py b/tests/test_nekobot.py similarity index 85% rename from tests/nekobot.py rename to tests/test_nekobot.py index f2a171d..1fa62a0 100644 --- a/tests/nekobot.py +++ b/tests/test_nekobot.py @@ -26,8 +26,8 @@ def test_generate_image(): """ api = NekoBotAPI() image = api.generate_image( - ImageGenType.THREATS, - url="https://i.pinimg.com/originals/6b/8d/86/6b8d866222ce86cda7e176c0f17cb676.jpg", + ImageGenType.CLYDE, + text="Hi!" ) assert isinstance(image, Image) assert image.nsfw is False diff --git a/tests/test_nekos_api.py b/tests/test_nekos_api.py new file mode 100644 index 0000000..7f9a483 --- /dev/null +++ b/tests/test_nekos_api.py @@ -0,0 +1,127 @@ +""" +Run tests for the NekosAPI class + +Usage: + cd tests + poetry run python -m pytest nekos_api.py +""" +import time + +from anime_api.apis import NekosAPI +from anime_api.apis.nekos_api.objects import Image, Category, Character, Artist +from anime_api.apis.nekos_api.types import NsfwLevel, ImageOrientation + + +# If you have an access token, replace it here to test endpoints that require +# authentication. +TOKEN = "a" * 100 + +api = NekosAPI(token=TOKEN) + + +def check_image(image): + assert isinstance(image, Image), "Returned obeject is not an image" + for category in image.categories: + assert isinstance( + category, Category + ), "Image categories are not category objects" + for character in image.characters: + assert isinstance( + character, Character + ), "Image characters are not character objects" + assert "kemonomimi" in [ + c.name.lower() for c in image.categories + ], "Selected category is not in image categories" + assert isinstance(image.artist, Artist) | isinstance( + image.artist, type(None) + ), "Artist is not an artist object" + + +def test_get_images(): + """ + Tests the get_images method. This will fail if you do not have an access + token. + """ + images = api.get_images(limit=10, offset=0) + assert len(images) == 10, "There are not as many images as specified" + for image in images: + check_image(image) + + +def test_get_random_image(): + """ + Tests the get_random_image method + """ + image = api.get_random_image(categories=["kemonomimi"]) + check_image(image) + + +def test_get_random_images(): + """ + Tests the get_random_images method + """ + images = api.get_random_images(10, categories=["kemonomimi"]) + assert len(images) == 10, "There are not as many images as specified" + for image in images: + check_image(image) + + +def test_get_image_by_id(): + """ + Tests the get_image_by_id method + """ + image = api.get_image_by_id(image_id="e80cc896-a51e-4b71-bbad-e4137e15c30d") + check_image(image) + + +def test_get_artist_by_id(): + """ + Tests the get_artist_by_id method + """ + artist = api.get_artist_by_id(artist_id="ea6e23ab-d0fc-4ede-984a-350a07e41ced") + assert isinstance(artist, Artist) + + +def test_get_images_by_artist_id(): + """ + Tests the get_images_by_artist_id method + """ + images = api.get_images_by_artist_id( + artist_id="ea6e23ab-d0fc-4ede-984a-350a07e41ced", limit=10, offset=0 + ) + assert isinstance(images, list), "Result is not a list" + for image in images: + assert isinstance(image, Image), "Result contains non-Image items" + + +def test_get_categories(): + """ + Tests the get_categories method + """ + categories = api.get_categories(limit=10, offset=0) + assert isinstance(categories, list), "Result is not a list" + assert ( + len(categories) == 10 + ), "Result does not contain the requested amount of categories" + for category in categories: + assert isinstance(category, Category), "Result contains non-category items" + + +def test_get_category_by_id(): + """ + Tests the get_category_by_id method + """ + category = api.get_category_by_id( + category_id="05140510-d7e8-43bc-a66f-7afaf1fcdf29" + ) + assert isinstance(category, Category) + + +def test_get_character_by_id(): + """ + Tests the get_character_by_id method + """ + character = api.get_character_by_id( + character_id="25af3b5c-4671-4017-8794-e5caf7078e59" + ) + assert isinstance(character, Character), "Result is not a Character object" diff --git a/tests/nekos_best.py b/tests/test_nekos_best.py similarity index 91% rename from tests/nekos_best.py rename to tests/test_nekos_best.py index 8865bcb..a5f53c6 100644 --- a/tests/nekos_best.py +++ b/tests/test_nekos_best.py @@ -1,5 +1,9 @@ """ Run tests for the NekosBest class + +Usage: + cd tests + poetry run python -m pytest nekos_best.py """ from anime_api.apis import NekosBest from anime_api.apis.nekos_best.objects import Image diff --git a/tests/nekos_life.py b/tests/test_nekos_life.py similarity index 94% rename from tests/nekos_life.py rename to tests/test_nekos_life.py index 08cd12c..ee052b9 100644 --- a/tests/nekos_life.py +++ b/tests/test_nekos_life.py @@ -79,6 +79,8 @@ def test_owoify(): def test_spoiler(): """ Test the spoiler method. + Warning: The API endpoint is broken and it will probably won't be fixed. + This means that this test WILL FAIL and thats ok. """ api = NekosLifeAPI() spoilered = api.spoiler("hello world") diff --git a/tests/nekos_moe.py b/tests/test_nekos_moe.py similarity index 100% rename from tests/nekos_moe.py rename to tests/test_nekos_moe.py diff --git a/tests/studio_ghibli_api.py b/tests/test_studio_ghibli_api.py similarity index 100% rename from tests/studio_ghibli_api.py rename to tests/test_studio_ghibli_api.py diff --git a/tests/trace_moe.py b/tests/test_trace_moe.py similarity index 100% rename from tests/trace_moe.py rename to tests/test_trace_moe.py diff --git a/tests/waifu_im.py b/tests/test_waifu_im.py similarity index 100% rename from tests/waifu_im.py rename to tests/test_waifu_im.py diff --git a/tests/waifu_pics.py b/tests/test_waifu_pics.py similarity index 100% rename from tests/waifu_pics.py rename to tests/test_waifu_pics.py