From 88cc93fd50f60e6440cc23c9b5e08ff02a27e358 Mon Sep 17 00:00:00 2001 From: WorldTeacher Date: Thu, 24 Apr 2025 18:40:03 +0200 Subject: [PATCH] initial commit --- .bumpversion.toml | 27 +++ .gitignore | 234 ++++++++++++++++++++++++ .vscode/settings.json | 7 + LICENSE | 21 +++ README.md | 0 pyproject.toml | 21 +++ src/anilistapi/__init__.py | 3 + src/anilistapi/api.py | 44 +++++ src/anilistapi/py.typed | 0 src/anilistapi/queries/__init__.py | 0 src/anilistapi/queries/manga.py | 54 ++++++ src/anilistapi/schemas/__init__.py | 0 src/anilistapi/schemas/externalLinks.py | 8 + src/anilistapi/schemas/manga.py | 46 +++++ src/anilistapi/schemas/tag.py | 20 ++ src/anilistapi/schemas/title.py | 26 +++ tests/__init__.py | 0 tests/conftest.py | 8 + tests/test_manga.py | 10 + 19 files changed, 529 insertions(+) create mode 100644 .bumpversion.toml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/anilistapi/__init__.py create mode 100644 src/anilistapi/api.py create mode 100644 src/anilistapi/py.typed create mode 100644 src/anilistapi/queries/__init__.py create mode 100644 src/anilistapi/queries/manga.py create mode 100644 src/anilistapi/schemas/__init__.py create mode 100644 src/anilistapi/schemas/externalLinks.py create mode 100644 src/anilistapi/schemas/manga.py create mode 100644 src/anilistapi/schemas/tag.py create mode 100644 src/anilistapi/schemas/title.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_manga.py diff --git a/.bumpversion.toml b/.bumpversion.toml new file mode 100644 index 0000000..7424653 --- /dev/null +++ b/.bumpversion.toml @@ -0,0 +1,27 @@ +[tool.bumpversion] +current_version = "0.1.3" +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" +serialize = ["{major}.{minor}.{patch}"] +search = "{current_version}" +replace = "{new_version}" +regex = false +ignore_missing_version = false +ignore_missing_files = false +tag = true +sign_tags = false +tag_name = "v{new_version}" +tag_message = "Bump version: {current_version} → {new_version}" +allow_dirty = false +commit = true +message = "Bump version: {current_version} → {new_version}" +moveable_tags = [] +commit_args = "" +setup_hooks = [] +pre_commit_hooks = [] +post_commit_hooks = [] + +[[tool.bumpversion.files]] +filename = "pyproject.toml" + +[[tool.bumpversion.files]] +filename = "src/anilistapi/__init__.py" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed468fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,234 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# ---> Qt +# C++ objects and libs +*.slo +*.lo +*.o +*.a +*.la +*.lai +*.so +*.so.* +*.dll +*.dylib + +# Qt-es +object_script.*.Release +object_script.*.Debug +*_plugin_import.cpp +/.qmake.cache +/.qmake.stash +*.pro.user +*.pro.user.* +*.qbs.user +*.qbs.user.* +*.moc +moc_*.cpp +moc_*.h +qrc_*.cpp +ui_*.h +*.qmlc +*.jsc +Makefile* +*build-* +*.qm +*.prl + +# Qt unit tests +target_wrapper.* + +# QtCreator +*.autosave + +# QtCreator Qml +*.qmlproject.user +*.qmlproject.user.* + +# QtCreator CMake +CMakeLists.txt.user* + +# QtCreator 4.8< compilation database +compile_commands.json + +# QtCreator local machine specific files for imported projects +*creator.user* + +*_qmlcache.qrc + + +.history +depend +output/output/LOGtoJSON.exe + +.pytest_cache +output +docs/ +config.yaml +**/tempCodeRunnerFile.py + +uv.lock +.history +.venv +venv +*.log diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9b38853 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aec92ac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 WorldTeacher + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a50b091 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "anilistapi" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [{ name = "WorldTeacher", email = "coding_contact@pm.me" }] +requires-python = ">=3.13" +dependencies = ["limit>=0.2.3", "requests>=2.32.3"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +test = ["pytest>=8.3.4"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--cov=src --cov-report=term-missing" +[tool.coverage.run] +omit = ["main.py", "test.py", "tests/*", "__init__.py"] diff --git a/src/anilistapi/__init__.py b/src/anilistapi/__init__.py new file mode 100644 index 0000000..49a1d97 --- /dev/null +++ b/src/anilistapi/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.1.3" +__contact__ = "coding_contact@pm.me" +from .api import AnilistAPI diff --git a/src/anilistapi/api.py b/src/anilistapi/api.py new file mode 100644 index 0000000..3508ba9 --- /dev/null +++ b/src/anilistapi/api.py @@ -0,0 +1,44 @@ +import requests +from .schemas.manga import Manga +from .queries.manga import MANGA_QUERY, MANGA_ID_QUERY +from limit import limit +from anilistapi import __version__, __contact__ +import os + +REQUEST_LIMIT = 1 +REQUEST_PERIOD = 2 + + +class AnilistAPI: + def __init__(self): + # add system information to the user agent + self.userAgent = f"AnilistAPI/{__version__} {os.uname().sysname}/{os.uname().machine} Python/{os.uname().release} - {__contact__}" + pass + + @limit(REQUEST_LIMIT, REQUEST_PERIOD) + def request(self, query: str, variables: dict) -> dict: + url = "https://graphql.anilist.co" + response = requests.post(url, json={"query": query, "variables": variables}) + if response.status_code != 200: + return {} + # raise Exception(f"Error: {response}, response: {response.json()}, query: {query}, variables: {variables}") + return response.json() + + def search_manga(self, search: str) -> list[Manga]: + variables = {"search": search} + response = self.request(MANGA_QUERY, variables) + # check if reponse has data Page and media + if not response.get("data", {}).get("Page", {}).get("media"): + return [] + res = [] + for manga in response["data"]["Page"]["media"]: + res.append(Manga(**manga)) + return res + + def get_manga(self, id: int) -> Manga: + assert isinstance(id, int), "id must be an integer" + variables = {"id": id} + response = self.request(MANGA_ID_QUERY, variables) + if not response.get("data", {}).get("Media"): + return None + return Manga(**response["data"]["Media"]) diff --git a/src/anilistapi/py.typed b/src/anilistapi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/anilistapi/queries/__init__.py b/src/anilistapi/queries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/anilistapi/queries/manga.py b/src/anilistapi/queries/manga.py new file mode 100644 index 0000000..d557b7f --- /dev/null +++ b/src/anilistapi/queries/manga.py @@ -0,0 +1,54 @@ +MANGA_QUERY = """ +query media($search: String) { +Page { + pageInfo { + hasNextPage + } + media(type: MANGA, search: $search) { + id + title { + romaji + english + native + } + synonyms + } +} +} +""" + +MANGA_ID_QUERY = """ +query media($id: Int) { # Define which variables will be used in the query (id) +Media (type: MANGA, id:$id) { # Insert our variables into the query arguments (id) + id + title { + romaji + english + native + } + synonyms + format + type + status(version:2) + genres + tags{ + name + isAdult + } + description + coverImage { + large + + } + isAdult + chapters + volumes + externalLinks { + site + url + type + } + countryOfOrigin + } + } +""" diff --git a/src/anilistapi/schemas/__init__.py b/src/anilistapi/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/anilistapi/schemas/externalLinks.py b/src/anilistapi/schemas/externalLinks.py new file mode 100644 index 0000000..dc43859 --- /dev/null +++ b/src/anilistapi/schemas/externalLinks.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class ExternalLinks: + url: str = None + type: str = None + site: str = None diff --git a/src/anilistapi/schemas/manga.py b/src/anilistapi/schemas/manga.py new file mode 100644 index 0000000..2e4b56d --- /dev/null +++ b/src/anilistapi/schemas/manga.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from .title import Title +from .externalLinks import ExternalLinks +from .tag import Tag +from typing import Union, Any + + +@dataclass +class Manga: + id: int = None + title: Title = None + status: str = None + synonyms: list = None + type: str = None + format: str = None + genres: list[str] = None + tags: list | list[Tag] = None + description: str = None + coverImage: dict = None + isAdult: bool = False + chapters: int = None + volumes: int = None + externalLinks: list | list[ExternalLinks] = None + countryOfOrigin: str = None + + def __post_init__(self): + self.title = Title(**self.title) if self.title else None + self.externalLinks = ( + [ExternalLinks(**link) for link in self.externalLinks] + if self.externalLinks + else None + ) + self.tags = [Tag(**tag) for tag in self.tags] if self.tags else None + self.description = self.description if self.description else None + + @property + def url(self): + return self.externalLinks[0].url + + @property + def isManga(self): + return self.format == "MANGA" + + @property + def isLightNovel(self): + return self.format == "NOVEL" diff --git a/src/anilistapi/schemas/tag.py b/src/anilistapi/schemas/tag.py new file mode 100644 index 0000000..aa60150 --- /dev/null +++ b/src/anilistapi/schemas/tag.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass +class Tag: + userID: int = None + rank: int = None + name: str = None + isMediaSpoiler: bool = False + isGeneralSpoiler: bool = False + isAdult: bool = False + id: int = None + description: str = None + category: str = None + + def __repr__(self): + return self.name + + def __str__(self): + return self.name diff --git a/src/anilistapi/schemas/title.py b/src/anilistapi/schemas/title.py new file mode 100644 index 0000000..8e29102 --- /dev/null +++ b/src/anilistapi/schemas/title.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + + +@dataclass +class Title: + romaji: str = None + english: str = None + native: str = None + + def __repr__(self): + return self.english if self.english else self.native + + def __str__(self): + return self.english if self.english else self.native + + @property + def alternateTitles(self): + return [ + {"romaji": self.romaji}, + {"english": self.english}, + {"native": self.native}, + ] + + @property + def titles(self): + return diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dc8732b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from anilistapi import AnilistAPI + + +@pytest.fixture +def api(): + return AnilistAPI() diff --git a/tests/test_manga.py b/tests/test_manga.py new file mode 100644 index 0000000..be56368 --- /dev/null +++ b/tests/test_manga.py @@ -0,0 +1,10 @@ +import pytest + +from anilistapi.schemas.manga import Manga +from anilistapi.api import AnilistAPI + + +def test_search_manga(): + api = AnilistAPI() + manga = api.search_manga("Berserk") + assert manga[0].title.english == "Berserk"