From c61d6fc80d7c20f580ad111db59352b8eae7b7da Mon Sep 17 00:00:00 2001 From: Colin Wilk Date: Mon, 9 Oct 2023 11:52:05 +0200 Subject: Rename booru-sync to szuruboorupy Initially the project was intended as a script repository containing scripts for managing my szurubooru instance. Since most of my work was actually writing an API client, I decided to rename this repository to an API client and do the script repository later on separately. Signed-off-by: Colin Wilk --- poetry.lock | 139 ++++----- pyproject.toml | 10 +- requirements.txt | 17 - src/lib/dataclasses.py | 47 --- src/lib/szurubooru.py | 736 -------------------------------------------- szuruboorupy/__init__.py | 10 + szuruboorupy/api.py | 407 ++++++++++++++++++++++++ szuruboorupy/dataclasses.py | 48 +++ szuruboorupy/exceptions.py | 341 ++++++++++++++++++++ 9 files changed, 869 insertions(+), 886 deletions(-) delete mode 100644 src/lib/dataclasses.py delete mode 100644 src/lib/szurubooru.py create mode 100644 szuruboorupy/__init__.py create mode 100644 szuruboorupy/api.py create mode 100644 szuruboorupy/dataclasses.py create mode 100644 szuruboorupy/exceptions.py diff --git a/poetry.lock b/poetry.lock index 6921e85..3b73725 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,30 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.0.dev0 and should not be changed by hand. - -[[package]] -name = "about-time" -version = "4.2.1" -description = "Easily measure timing and throughput of code blocks, with beautiful human friendly representations." -optional = false -python-versions = ">=3.7, <4" -files = [ - {file = "about-time-4.2.1.tar.gz", hash = "sha256:6a538862d33ce67d997429d14998310e1dbfda6cb7d9bbfbf799c4709847fece"}, - {file = "about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341"}, -] - -[[package]] -name = "alive-progress" -version = "3.1.4" -description = "A new kind of Progress Bar, with real-time throughput, ETA, and very cool animations!" -optional = false -python-versions = ">=3.7, <4" -files = [ - {file = "alive-progress-3.1.4.tar.gz", hash = "sha256:74a95d8d0d42bc99d3a3725dbd06ebb852245f1b64e301a7c375b92b22663f7b"}, - {file = "alive_progress-3.1.4-py3-none-any.whl", hash = "sha256:c80ad87ce9c1054b01135a87fae69ecebbfc2107497ae87cbe6aec7e534903db"}, -] - -[package.dependencies] -about-time = "4.2.1" -grapheme = "0.6.0" +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -165,30 +139,6 @@ files = [ {file = "charset_normalizer-3.3.0-py3-none-any.whl", hash = "sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2"}, ] -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "grapheme" -version = "0.6.0" -description = "Unicode grapheme helpers" -optional = false -python-versions = "*" -files = [ - {file = "grapheme-0.6.0.tar.gz", hash = "sha256:44c2b9f21bbe77cfb05835fec230bd435954275267fea1858013b102f8603cca"}, -] - -[package.extras] -test = ["pytest", "sphinx", "sphinx-autobuild", "twine", "wheel"] - [[package]] name = "idna" version = "3.4" @@ -338,6 +288,52 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] +[[package]] +name = "mypy" +version = "1.5.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f33592ddf9655a4894aef22d134de7393e95fcbdc2d15c1ab65828eee5c66c70"}, + {file = "mypy-1.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:258b22210a4a258ccd077426c7a181d789d1121aca6db73a83f79372f5569ae0"}, + {file = "mypy-1.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9ec1f695f0c25986e6f7f8778e5ce61659063268836a38c951200c57479cc12"}, + {file = "mypy-1.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:abed92d9c8f08643c7d831300b739562b0a6c9fcb028d211134fc9ab20ccad5d"}, + {file = "mypy-1.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:a156e6390944c265eb56afa67c74c0636f10283429171018446b732f1a05af25"}, + {file = "mypy-1.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ac9c21bfe7bc9f7f1b6fae441746e6a106e48fc9de530dea29e8cd37a2c0cc4"}, + {file = "mypy-1.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51cb1323064b1099e177098cb939eab2da42fea5d818d40113957ec954fc85f4"}, + {file = "mypy-1.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:596fae69f2bfcb7305808c75c00f81fe2829b6236eadda536f00610ac5ec2243"}, + {file = "mypy-1.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:32cb59609b0534f0bd67faebb6e022fe534bdb0e2ecab4290d683d248be1b275"}, + {file = "mypy-1.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:159aa9acb16086b79bbb0016145034a1a05360626046a929f84579ce1666b315"}, + {file = "mypy-1.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f6b0e77db9ff4fda74de7df13f30016a0a663928d669c9f2c057048ba44f09bb"}, + {file = "mypy-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26f71b535dfc158a71264e6dc805a9f8d2e60b67215ca0bfa26e2e1aa4d4d373"}, + {file = "mypy-1.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fc3a600f749b1008cc75e02b6fb3d4db8dbcca2d733030fe7a3b3502902f161"}, + {file = "mypy-1.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:26fb32e4d4afa205b24bf645eddfbb36a1e17e995c5c99d6d00edb24b693406a"}, + {file = "mypy-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:82cb6193de9bbb3844bab4c7cf80e6227d5225cc7625b068a06d005d861ad5f1"}, + {file = "mypy-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4a465ea2ca12804d5b34bb056be3a29dc47aea5973b892d0417c6a10a40b2d65"}, + {file = "mypy-1.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9fece120dbb041771a63eb95e4896791386fe287fefb2837258925b8326d6160"}, + {file = "mypy-1.5.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d28ddc3e3dfeab553e743e532fb95b4e6afad51d4706dd22f28e1e5e664828d2"}, + {file = "mypy-1.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:57b10c56016adce71fba6bc6e9fd45d8083f74361f629390c556738565af8eeb"}, + {file = "mypy-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:ff0cedc84184115202475bbb46dd99f8dcb87fe24d5d0ddfc0fe6b8575c88d2f"}, + {file = "mypy-1.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8f772942d372c8cbac575be99f9cc9d9fb3bd95c8bc2de6c01411e2c84ebca8a"}, + {file = "mypy-1.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5d627124700b92b6bbaa99f27cbe615c8ea7b3402960f6372ea7d65faf376c14"}, + {file = "mypy-1.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:361da43c4f5a96173220eb53340ace68cda81845cd88218f8862dfb0adc8cddb"}, + {file = "mypy-1.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:330857f9507c24de5c5724235e66858f8364a0693894342485e543f5b07c8693"}, + {file = "mypy-1.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:c543214ffdd422623e9fedd0869166c2f16affe4ba37463975043ef7d2ea8770"}, + {file = "mypy-1.5.1-py3-none-any.whl", hash = "sha256:f757063a83970d67c444f6e01d9550a7402322af3557ce7630d3c957386fa8f5"}, + {file = "mypy-1.5.1.tar.gz", hash = "sha256:b031b9601f1060bf1281feab89697324726ba0c0bae9d7cd7ab4b690940f0b92"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -396,20 +392,6 @@ files = [ [package.extras] test = ["codecov (>=2.0.5)", "coverage (>=4.2)", "flake8 (>=3.0.4)", "pytest (>=4.5.0)", "pytest-cov (>=2.7.1)", "pytest-runner (>=5.1)", "pytest-virtualenv (>=1.7.0)", "virtualenv (>=15.0.3)"] -[[package]] -name = "pybooru" -version = "4.2.2" -description = "Pybooru is a Python package to access to the API of Danbooru/Moebooru based sites." -optional = false -python-versions = "*" -files = [ - {file = "Pybooru-4.2.2-py2.py3-none-any.whl", hash = "sha256:a855cfa9dbb6d641d81d7bbeb378977345edc466e11b0c32346e9209f2ae4d3b"}, - {file = "Pybooru-4.2.2.tar.gz", hash = "sha256:c3e31bb718753b8ee678fc7b87a9f6a9cf18747066f84efe245d3b25ecb2882f"}, -] - -[package.dependencies] -requests = "*" - [[package]] name = "pycnite" version = "2023.10.5" @@ -603,20 +585,6 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] -[[package]] -name = "python-dotenv" -version = "1.0.0" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, - {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - [[package]] name = "pytype" version = "2023.10.5" @@ -755,6 +723,17 @@ files = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + [[package]] name = "typing-extensions" version = "4.8.0" @@ -801,4 +780,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "0569366cc46e30e59708e5aba3b17fe878b3f37b1aaa68900c88f08ed04da98b" +content-hash = "b3493b47ffa295b1352cb8ddb9cb69853296b68c4a46e6d9836928033c4182b1" diff --git a/pyproject.toml b/pyproject.toml index 8af13f3..06aa9db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,18 @@ [tool.poetry] -name = "booru-sync" +name = "szuruboorupy" version = "0.1.0" -description = "Python scripts and library for syncing content between Danbooru and Szurubooru" +description = "An API client written in Python for szurubooru based sites" authors = ["Colin Wilk "] readme = "README.md" [tool.poetry.dependencies] python = "^3.10" -pybooru = "^4.2.2" -alive-progress = "^3.1.4" -colorama = "^0.4.6" -python-dotenv = "^1.0.0" pydantic = "^2.4.2" +requests = "^2.31.0" [tool.poetry.group.dev.dependencies] pytype = "^2023.9.27" +mypy = "^1.5.1" pydocstyle = "^6.3.0" [build-system] diff --git a/requirements.txt b/requirements.txt index 3529f21..a829784 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,3 @@ -about-time==4.2.1 ; python_version >= "3.10" and python_version < "4" \ - --hash=sha256:6a538862d33ce67d997429d14998310e1dbfda6cb7d9bbfbf799c4709847fece \ - --hash=sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341 -alive-progress==3.1.4 ; python_version >= "3.10" and python_version < "4" \ - --hash=sha256:74a95d8d0d42bc99d3a3725dbd06ebb852245f1b64e301a7c375b92b22663f7b \ - --hash=sha256:c80ad87ce9c1054b01135a87fae69ecebbfc2107497ae87cbe6aec7e534903db annotated-types==0.6.0 ; python_version >= "3.10" and python_version < "4.0" \ --hash=sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43 \ --hash=sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d @@ -101,17 +95,9 @@ charset-normalizer==3.3.0 ; python_version >= "3.10" and python_version < "4.0" --hash=sha256:f5969baeaea61c97efa706b9b107dcba02784b1601c74ac84f2a532ea079403e \ --hash=sha256:f8888e31e3a85943743f8fc15e71536bda1c81d5aa36d014a3c0c44481d7db6e \ --hash=sha256:fc52b79d83a3fe3a360902d3f5d79073a993597d48114c29485e9431092905d8 -colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" \ - --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ - --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 -grapheme==0.6.0 ; python_version >= "3.10" and python_version < "4" \ - --hash=sha256:44c2b9f21bbe77cfb05835fec230bd435954275267fea1858013b102f8603cca idna==3.4 ; python_version >= "3.10" and python_version < "4.0" \ --hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \ --hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2 -pybooru==4.2.2 ; python_version >= "3.10" and python_version < "4.0" \ - --hash=sha256:a855cfa9dbb6d641d81d7bbeb378977345edc466e11b0c32346e9209f2ae4d3b \ - --hash=sha256:c3e31bb718753b8ee678fc7b87a9f6a9cf18747066f84efe245d3b25ecb2882f pydantic-core==2.10.1 ; python_version >= "3.10" and python_version < "4.0" \ --hash=sha256:042462d8d6ba707fd3ce9649e7bf268633a41018d6a998fb5fbacb7e928a183e \ --hash=sha256:0523aeb76e03f753b58be33b26540880bac5aa54422e4462404c432230543f33 \ @@ -222,9 +208,6 @@ pydantic-core==2.10.1 ; python_version >= "3.10" and python_version < "4.0" \ pydantic==2.4.2 ; python_version >= "3.10" and python_version < "4.0" \ --hash=sha256:94f336138093a5d7f426aac732dcfe7ab4eb4da243c88f891d65deb4a2556ee7 \ --hash=sha256:bc3ddf669d234f4220e6e1c4d96b061abe0998185a8d7855c0126782b7abc8c1 -python-dotenv==1.0.0 ; python_version >= "3.10" and python_version < "4.0" \ - --hash=sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba \ - --hash=sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a requests==2.31.0 ; python_version >= "3.10" and python_version < "4.0" \ --hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \ --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1 diff --git a/src/lib/dataclasses.py b/src/lib/dataclasses.py deleted file mode 100644 index 6313fca..0000000 --- a/src/lib/dataclasses.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Module for collection of dataclasses that map Szurubooru objects to python classes -""" - - -from pydantic.dataclasses import dataclass -from dataclasses import field -from typing import List, Optional - - -@dataclass -class Tag: - """A single tag. Tags are used to let users search for posts. - - The Szurubooru structure: - .. code-block:: JSON - { - "version": , - "names": , - "category": , - "implications": , - "suggestions": , - "creationTime": , - "lastEditTime": , - "usages": , - "description": - } - - **Field meaning** - - ``: resource version. - - ``: a list of tag names (aliases). Tagging a post with any name will - automatically assign the first name from this list. - - ``: the name of the category the given tag belongs to. - - ``: a list of implied tags, serialized as micro tag resource. - - ``: a list of suggested tags, serialized as micro tag resource. - - ``: time the tag was created, formatted as per RFC 3339. - - ``: time the tag was edited, formatted as per RFC 3339. - - ``: the number of posts the tag was used in. - - ``: the tag description (instructions how to use, history etc.) - """ - - name: str - version: int = -1 - description: Optional[str] = "" - category: str = "General" - implications: List[str] = field(default_factory=list) - suggestions: List[str] = field(default_factory=list) diff --git a/src/lib/szurubooru.py b/src/lib/szurubooru.py deleted file mode 100644 index 2484691..0000000 --- a/src/lib/szurubooru.py +++ /dev/null @@ -1,736 +0,0 @@ -""" -Szurubooru Library -~~~~~~~~~~~~~~~~~~ - -Szurubooru Library is used for communicating with a Szurubooru instance through python. - -:copyright (c) 2023 Colin Wilk. -:license: MIT, see LICENSE for more details. -""" - - -from lib.dataclasses import Tag -import requests as r -from typing import List -import json -from urllib.parse import quote -import math -import functools - - -class SzurubooruException(Exception): - """General Error returned from the Szurubooru REST API. - - The Szurubooru API returns errors together with an http code in a form like this: - .. code-block:: JSON - { - "name": "Name of the error, e.g. 'PostNotFoundError'", - "title": "Generic title of error message, e.g. 'Not found'", - "description": "Detailed description of what went wrong, e.g. - 'User `rr-` not found." - } - - We map this json response to the SzurubooruException class or more specific children - such as MissingRequiredFileError with the fields / args that are provided from the - json response. - - Args: - name: Name of the exception as defined by the Szurubooru REST API - The name is also used in more specific Exceptions such as - MissingRequiredFileError(SzurubooruException). - title: Generic, human readable title of the error message. - description: More detailed description of what went wrong. Often includes - input from the user that caused the error. - """ - - name: str - title: str - description: str - - def __init__(self, name: str, title: str, description: str) -> None: - self.name = name - self.title = title - self.description = description - super().__init__(description) - - @staticmethod - def response_is_exception(res: r.Response) -> bool: - """Check if the Szurubooru REST API response is an error or not. - - Args: - res (r.Response): the Response of the request from the requests module. - - Returns: - bool: True if response is an error and False if it is not. - """ - return res.status_code != 200 - - @staticmethod - def map_exception(res: r.Response) -> Exception: - """Generate an exception for an error response from the Szurubooru REST API. - - Args: - res (r.Response): the Response from the Szurubooru REST API that should be - mapped to a Python Exception. - - Raises: - LookupError: Thrown when the Szurubooru REST API sent an error type (name) - that could not be mapped to one of the SzurubooruException - Python Exceptions. This can happen when there was an unexpected - e.g. an Internal Server Error in the Szurubooru REST API. - - Returns: - Exception: Returns a more specific exception. - """ - content: dict[str, str] = json.loads(res.content) - name: str = content["name"] - title: str = content["title"] - description: str = content["description"] - - match name: - case "MissingRequiredFileError": - return MissingRequiredFileError(name, title, description) - case "MissingRequiredParameterError": - return MissingRequiredParameterError(name, title, description) - case "InvalidParameterError": - return InvalidParameterError(name, title, description) - case "IntegrityError": - return IntegrityError(name, title, description) - case "SearchError": - return SearchError(name, title, description) - case "AuthError": - return AuthError(name, title, description) - case "PostNotFoundError": - return PostNotFoundError(name, title, description) - case "PostAlreadyFeaturedError": - return PostAlreadyFeaturedError(name, title, description) - case "PostAlreadyUploadedError": - return PostAlreadyUploadedError(name, title, description) - case "InvalidPostIdError": - return InvalidPostIdError(name, title, description) - case "InvalidPostSafetyError": - return InvalidPostSafetyError(name, title, description) - case "InvalidPostSourceError": - return InvalidPostSourceError(name, title, description) - case "InvalidPostContentError": - return InvalidPostContentError(name, title, description) - case "InvalidPostRelationError": - return InvalidPostRelationError(name, title, description) - case "InvalidPostNoteError": - return InvalidPostNoteError(name, title, description) - case "InvalidPostFlagError": - return InvalidPostFlagError(name, title, description) - case "InvalidFavoriteTargetError": - return InvalidFavoriteTargetError(name, title, description) - case "InvalidCommentIdError": - return InvalidCommentIdError(name, title, description) - case "CommentNotFoundError": - return CommentNotFoundError(name, title, description) - case "EmptyCommentTextError": - return EmptyCommentTextError(name, title, description) - case "InvalidScoreTargetError": - return InvalidScoreTargetError(name, title, description) - case "InvalidScoreValueError": - return InvalidScoreValueError(name, title, description) - case "TagCategoryNotFoundError": - return TagCategoryNotFoundError(name, title, description) - case "TagCategoryAlreadyExistsError": - return TagCategoryAlreadyExistsError(name, title, description) - case "TagCategoryIsInUseError": - return TagCategoryIsInUseError(name, title, description) - case "InvalidTagCategoryNameError": - return InvalidTagCategoryNameError(name, title, description) - case "InvalidTagCategoryColorError": - return InvalidTagCategoryColorError(name, title, description) - case "TagNotFoundError": - return TagNotFoundError(name, title, description) - case "TagAlreadyExistsError": - return TagAlreadyExistsError(name, title, description) - case "TagIsInUseError": - return TagIsInUseError(name, title, description) - case "InvalidTagNameError": - return InvalidTagNameError(name, title, description) - case "InvalidTagRelationError": - return InvalidTagRelationError(name, title, description) - case "InvalidTagCategoryError": - return InvalidTagCategoryError(name, title, description) - case "InvalidTagDescriptionError": - return InvalidTagDescriptionError(name, title, description) - case "UserNotFoundError": - return UserNotFoundError(name, title, description) - case "UserAlreadyExistsError": - return UserAlreadyExistsError(name, title, description) - case "InvalidUserNameError": - return InvalidUserNameError(name, title, description) - case "InvalidEmailError": - return InvalidEmailError(name, title, description) - case "InvalidPasswordError": - return InvalidPasswordError(name, title, description) - case "InvalidRankError": - return InvalidRankError(name, title, description) - case "InvalidAvatarError": - return InvalidAvatarError(name, title, description) - case "ProcessingError": - return ProcessingError(name, title, description) - case "ValidationError": - return ValidationError(name, title, description) - case _: - raise LookupError(f'Unknown SzurubooruException: "{name}"') - - -class MissingRequiredFileError(SzurubooruException): # noqa: D101 - pass - - -class MissingRequiredParameterError(SzurubooruException): # noqa: D101 - pass - - -class InvalidParameterError(SzurubooruException): # noqa: D101 - pass - - -class IntegrityError(SzurubooruException): # noqa: D101 - pass - - -class SearchError(SzurubooruException): # noqa: D101 - pass - - -class AuthError(SzurubooruException): # noqa: D101 - pass - - -class PostNotFoundError(SzurubooruException): # noqa: D101 - pass - - -class PostAlreadyFeaturedError(SzurubooruException): # noqa: D101 - pass - - -class PostAlreadyUploadedError(SzurubooruException): # noqa: D101 - pass - - -class InvalidPostIdError(SzurubooruException): # noqa: D101 - pass - - -class InvalidPostSafetyError(SzurubooruException): # noqa: D101 - pass - - -class InvalidPostSourceError(SzurubooruException): # noqa: D101 - pass - - -class InvalidPostContentError(SzurubooruException): # noqa: D101 - pass - - -class InvalidPostRelationError(SzurubooruException): # noqa: D101 - pass - - -class InvalidPostNoteError(SzurubooruException): # noqa: D101 - pass - - -class InvalidPostFlagError(SzurubooruException): # noqa: D101 - pass - - -class InvalidFavoriteTargetError(SzurubooruException): # noqa: D101 - pass - - -class InvalidCommentIdError(SzurubooruException): # noqa: D101 - pass - - -class CommentNotFoundError(SzurubooruException): # noqa: D101 - pass - - -class EmptyCommentTextError(SzurubooruException): # noqa: D101 - pass - - -class InvalidScoreTargetError(SzurubooruException): # noqa: D101 - pass - - -class InvalidScoreValueError(SzurubooruException): # noqa: D101 - pass - - -class TagCategoryNotFoundError(SzurubooruException): # noqa: D101 - pass - - -class TagCategoryAlreadyExistsError(SzurubooruException): # noqa: D101 - pass - - -class TagCategoryIsInUseError(SzurubooruException): # noqa: D101 - pass - - -class InvalidTagCategoryNameError(SzurubooruException): # noqa: D101 - pass - - -class InvalidTagCategoryColorError(SzurubooruException): # noqa: D101 - pass - - -class TagNotFoundError(SzurubooruException): # noqa: D101 - pass - - -class TagAlreadyExistsError(SzurubooruException): # noqa: D101 - pass - - -class TagIsInUseError(SzurubooruException): # noqa: D101 - pass - - -class InvalidTagNameError(SzurubooruException): # noqa: D101 - pass - - -class InvalidTagRelationError(SzurubooruException): # noqa: D101 - pass - - -class InvalidTagCategoryError(SzurubooruException): # noqa: D101 - pass - - -class InvalidTagDescriptionError(SzurubooruException): # noqa: D101 - pass - - -class UserNotFoundError(SzurubooruException): # noqa: D101 - pass - - -class UserAlreadyExistsError(SzurubooruException): # noqa: D101 - pass - - -class InvalidUserNameError(SzurubooruException): # noqa: D101 - pass - - -class InvalidEmailError(SzurubooruException): # noqa: D101 - pass - - -class InvalidPasswordError(SzurubooruException): # noqa: D101 - pass - - -class InvalidRankError(SzurubooruException): # noqa: D101 - pass - - -class InvalidAvatarError(SzurubooruException): # noqa: D101 - pass - - -class ProcessingError(SzurubooruException): # noqa: D101 - pass - - -class ValidationError(SzurubooruException): # noqa: D101 - pass - - -class Szurubooru: - """Client class for communicating with the Szurubooru instance. - - Raises: - SzurubooruException: When the Szurubooru REST API returns any kinds of errors. - See: SzurubooruException for mor information. - """ - - url: str - token: str - verify: bool - header: dict[str, str] - - # Session Stats - requests_get: int = 0 - requests_post: int = 0 - requests_put: int = 0 - requests_delete: int = 0 - - def __init__(self, url: str, token: str, verify: bool = True): - self.url = url - self.token = token - self.verify = verify - self.header = { - "Accept": "application/json", - "Authorization": f"Token {self.token}", - } - - @staticmethod - def __raise_szurubooru_exception(func): - @functools.wraps(func) - def wrapper(self, **kwargs): - res = func(self, **kwargs) - if SzurubooruException.response_is_exception(res): - raise SzurubooruException.map_exception(res) - return res - - return wrapper - - @staticmethod - def __count_stats(get: int = 0, post: int = 0, put: int = 0, delete: int = 0): - def function_wrapper(func): - @functools.wraps(func) - def wrapper(self, **kwargs): - self.requests_get += get - self.requests_post += post - self.requests_put += put - self.requests_delete += delete - return func(self, **kwargs) - - return wrapper - - return function_wrapper - - @__count_stats(get=1) - @__raise_szurubooru_exception - def __get(self, path: str) -> r.Response: - return r.get( - f"{self.url}{path}", timeout=15, verify=self.verify, headers=self.header - ) - - @__count_stats(post=1) - @__raise_szurubooru_exception - def __post(self, path: str, params: dict) -> r.Response: - return r.post( - f"{self.url}{path}", - timeout=15, - verify=self.verify, - headers=self.header, - data=json.dumps(params), - ) - - @__count_stats(put=1) - @__raise_szurubooru_exception - def __put(self, path: str, params: dict) -> r.Response: - return r.put( - f"{self.url}{path}", - timeout=15, - verify=self.verify, - headers=self.header, - data=json.dumps(params), - ) - - @__count_stats(delete=1) - @__raise_szurubooru_exception - def __delete(self, path: str, params: dict) -> r.Response: - return r.delete( - f"{self.url}{path}", - timeout=15, - verify=self.verify, - headers=self.header, - data=json.dumps(params), - ) - - def __map_tag_resource_response(self, res: r.Response, name: str = "") -> Tag: - """Map the requests response from the Szurubooru REST API to the Tag dataclass. - - This function is similar to __map_tag_resource and calls it internally. - Instead of receiving a dictionary if the Szurubooru Tag it operates on the - requests Response of the same form. - - To Illustrate, both code yield the same result: - >>> __map_tag_resource_response(res, name) - >>> __map_tag_resource(json.loads(res.content), name) - - - - Args: - res (r.Response): The requests Response from an Szurubooru REST API endpoint - that returns a Szurubooru tag resource in a form like: - .. code-block:: JSON - { - "version": , - "names": , - "category": , - "implications": , - "suggestions": , - "creationTime": , - "lastEditTime": , - "usages": , - "description": - } - - name (str, optional): If you provide a name here, the mapper will use that - name for the Tag dataclass instead of fetching it from - the alias names. This is useful if you have multiple - aliases or names in Szurubooru. - - Returns: - Tag: A Tag dataclass. The version parameter is included if the Szurubooru - REST API response contained a version field, otherwise it defaults to - -1. The name will be the first name returned by the Szurubooru REST API - or the name that was passed to the function as an optional parameter. - """ - return self.__map_tag_resource(json.loads(res.content), name) - - def __map_tag_resource(self, c: dict, name: str = "") -> Tag: - """Map the dict from the Szurubooru REST API to the Tag dataclass. - - This function is similar to __map_tag_resource_response and is called by it. - Instead of receiving a requests Response it operates on the dictionary of the - same form. - - To Illustrate, both code yield the same result: - >>> __map_tag_resource_response(res, name) - >>> __map_tag_resource(json.loads(res.content), name) - - Args: - c (dict): The dict object coming from an Szurubooru REST API Szurubooru tag - resource in a form like: - .. code-block:: JSON - { - "version": , - "names": , - "category": , - "implications": , - "suggestions": , - "creationTime": , - "lastEditTime": , - "usages": , - "description": - } - name (str, optional): If you provide a name here, the mapper will use that - name for the Tag dataclass instead of fetching it from - the alias names. This is useful if you have multiple - aliases or names in Szurubooru. - - Returns: - Tag: A Tag dataclass. The version parameter is included if the Szurubooru - REST API response contained a version field, otherwise it defaults to - -1. The name will be the first name returned by the Szurubooru REST API - or the name that was passed to the function as an optional parameter. - """ - return Tag( - name=name if name != "" else c["names"][0], - version=c.get("version", -1), - category=c["category"], - description=c["description"], - implications=list(map(lambda a: a["names"][0], c["implications"])), - suggestions=list(map(lambda a: a["names"][0], c["suggestions"])), - ) - - def tag_exists(self, name: str) -> bool: - """Check if the tag exists on Szurubooru. - - Args: - name (str): Name of the tag. - - Returns: - bool: True if tag already exists. False if there was no matching tag on - Szurubooru. - """ - try: - res = self.__get(path=f"/api/tag/{quote(name)}") - except TagNotFoundError: - return False - except Exception as e: - raise e - return res.status_code == 200 - - def tag_len(self) -> int: - """Check how many tags there are on the Szurubooru instance. - - Returns: - int: The number of tags that exist on the Szurubooru instance - """ - return json.loads(self.__get(path="/api/tags").content)["total"] - - def tag_list(self, limit: int = 100, offset: int = 0, query: str = "") -> List[Tag]: - """List tags on the Szurubooru instance. - - Args: - limit (int, optional): Limits the amount of tags displayed on one page. - Setting a smaller limit may help with query speed. - Usually the maximum allowed limit is 100. - Defaults to 100. - offset (int, optional): Offset where to start the listing. Required for - Paging the data when searching through many tags. - If you have a page size of 100 you need to set an - offset of 100 to see results on the 2. page. - So your offset should be PAGE_NUM * LIMIT. - Defaults to 0. - query (str, optional): You can use a custom query to filter through tags - better. If you use an empty string (the default) you - will search through all tags on the Szurubooru - instance. - Defaults to ''. - - Returns: - List[Tag]: List of Tag objects in the current page. - """ - path = f"/api/tags?offset={offset}&limit={limit}&query={quote(query)}" - results = json.loads(self.__get(path=path).content)["results"] - return list(map(self.__map_tag_resource, results)) - - def tag_list_all(self, query: str = "") -> List[Tag]: - """List every available Tag (matching the query) on the Szurubooru instance. - - WARNING: This method calls the Szurubooru REST API multiple times depending on - how many tags there are. It can take a while and can put significant load on the - Szurubooru instance. Use with caution. - - In essence this method does the paging for you in tag_list and returns the full - batch of Tags. - - Args: - query (str, optional): You can use a custom query to filter through tags - better. If you use an empty string (the default) you - will search through all tags on the Szurubooru - instance. - Defaults to ''. - - Returns: - List[Tag]: List of all Tag objects matching the query on the Szurubooru - instance. - """ - tags = [] - for i in range(0, math.ceil(self.tag_len() / 100)): - tags.extend(self.tag_list(offset=100 * i, limit=100, query=query)) - return tags - - def tag_get(self, name: str) -> Tag: - """Get a tag form the Szurubooru instance by name. - - Args: - name (str): Name of the tag. - - Raises: - TagNotFoundError: Tag could not be found on the Szurubooru instance. - - Returns: - Tag: Tag object of the requested tag. - """ - res = self.__get(path=f"/api/tag/{quote(name)}") - return self.__map_tag_resource_response(res, name=name) - - def tag_create(self, tag: Tag) -> Tag: - """Create a tag on the Szurubooru instance. - - Args: - tag (Tag): Tag dataclass representing the Tag that should be created. - - Returns: - Tag: Resulting Tag dataclass that was returned by the Szurubooru REST API - representing the created tag as it was created on the Szurubooru - instance. - """ - res = self.__post( - path="/api/tags", - params={ - "names": [tag.name], - "category": tag.category, - "description": tag.description, - "implications": tag.implications, - "suggestions": tag.suggestions, - }, - ) - return self.__map_tag_resource_response(res, name=tag.name) - - def tag_update(self, tag: Tag) -> Tag: - """Update a tag on the Szurubooru instance. - - Args: - tag (Tag): Tag dataclass representing the Tag that should be updated. The - name is the identifying parameter for the Szurubooru instance. - - Returns: - Tag: Resulting Tag dataclass that was returned by the Szurubooru REST API - representing the updated version of the tag as it is now on the - Szurubooru instance. - """ - res = self.__put( - path=f"/api/tag/{quote(tag.name)}", - params={ - "version": tag.version, - "category": tag.category, - "description": tag.description, - "implications": tag.implications, - "suggestions": tag.suggestions, - }, - ) - return self.__map_tag_resource_response(res, name=tag.name) - - def tag_delete(self, tag: Tag) -> None: - """Delete the given tag on the Szurubooru instance. - - Args: - tag (Tag): Tag dataclass of the tag that should be reledte. Needs to contain - the current version number. - """ - self.__delete( - path=f"/api/tag/{quote(tag.name)}", params={"version": tag.version} - ) - return - - def tag_merge(self, tag_to_merge: Tag, tag_to_merge_into: Tag) -> Tag: - """Remove tag_to_merge and merges all uses into tag_to_merge_into. - - Removes source tag and merges all of its usages, suggestions and implications to - the target tag. Other tag properties such as category and aliases do not get - transferred and are discarded. - - Args: - tag_to_merge (Tag): Tag dataclass representing the Tag that will be removed - and merged into the second Tag. The version parameter is - required. - tag_to_merge_into (Tag): Tag dataclass representing the Tag that the first - tag will be merged into. The version parameter is - required. - - Returns: - Tag: Resulting Tag dataclass that was returned by the Szurubooru REST API - representing the new (merged) tag on the Szurubooru instance. - """ - res = self.__post( - path="/api/tag-merge", - params={ - "removeVersion": tag_to_merge.version, - "remove": tag_to_merge.name, - "mergeToVersion": tag_to_merge_into.version, - "mergeTo": tag_to_merge_into.name, - }, - ) - return self.__map_tag_resource_response(res, name=tag_to_merge_into.name) - - def tag_list_siblings(self, tag: Tag) -> List[dict[str, int]]: - """List siblings of the given Tag - - Lists siblings of given tag, e.g. tags that were used in the same posts as the - given tag. `occurrences` field signifies how many times a given sibling appears - with given tag. Results are sorted by occurrences count and the list is - truncated to the first 50 elements. Doesn't use paging. - - Args: - tag (Tag): Tag that will queried. Only uses the tag.name parameter. - - Returns: - List[dict[str, int]]: Returns a List of dictionaries with the primary tag - name as the key and the number of occurrences as the - value. - """ - res = self.__get(path=f"/api/tag-siblings/{tag.name}") - results = json.loads(res.content)["results"] - return list(map(lambda a: {a["tag"]["names"][0]: a["occurrences"]}, results)) diff --git a/szuruboorupy/__init__.py b/szuruboorupy/__init__.py new file mode 100644 index 0000000..44165b1 --- /dev/null +++ b/szuruboorupy/__init__.py @@ -0,0 +1,10 @@ +""" +szuruboorupy + +szuruboorupy is an API client written in Python for szurubooru based sites. + +szuruboorupy modules: + api -- Main module doing the requests to szurubooru + dataclasses -- Contains all szurubooru resources as python dataclasses + exceptions -- Manages and builds szurubooru API exceptions +""" diff --git a/szuruboorupy/api.py b/szuruboorupy/api.py new file mode 100644 index 0000000..3d8380c --- /dev/null +++ b/szuruboorupy/api.py @@ -0,0 +1,407 @@ +""" +Szurubooru API +~~~~~~~~~~~~~~ + +Szurubooru API is used for communicating with a Szurubooru instance through python. + +:copyright (c) 2023 Colin Wilk. +:license: MIT, see LICENSE for more details. +""" + + +import functools +import json +import math +from typing import List +from urllib.parse import quote + +import requests as r + +from szuruboorupy.dataclasses import Tag +from szuruboorupy.exceptions import SzurubooruException, TagNotFoundError + + +class Szurubooru: + """Client class for communicating with the Szurubooru instance. + + Raises: + SzurubooruException: When the Szurubooru REST API returns any kinds of errors. + See: SzurubooruException for mor information. + """ + + url: str + token: str + verify: bool + header: dict[str, str] + + # Session Stats + requests_get: int = 0 + requests_post: int = 0 + requests_put: int = 0 + requests_delete: int = 0 + + def __init__(self, url: str, token: str, verify: bool = True): + self.url = url + self.token = token + self.verify = verify + self.header = { + "Accept": "application/json", + "Authorization": f"Token {self.token}", + } + + @staticmethod + def __raise_szurubooru_exception(func): + @functools.wraps(func) + def wrapper(self, **kwargs): + res = func(self, **kwargs) + if SzurubooruException.response_is_exception(res): + raise SzurubooruException.map_exception(res) + return res + + return wrapper + + @staticmethod + def __count_stats(get: int = 0, post: int = 0, put: int = 0, delete: int = 0): + def function_wrapper(func): + @functools.wraps(func) + def wrapper(self, **kwargs): + self.requests_get += get + self.requests_post += post + self.requests_put += put + self.requests_delete += delete + return func(self, **kwargs) + + return wrapper + + return function_wrapper + + @__count_stats(get=1) + @__raise_szurubooru_exception + def __get(self, path: str) -> r.Response: + return r.get( + f"{self.url}{path}", timeout=15, verify=self.verify, headers=self.header + ) + + @__count_stats(post=1) + @__raise_szurubooru_exception + def __post(self, path: str, params: dict) -> r.Response: + return r.post( + f"{self.url}{path}", + timeout=15, + verify=self.verify, + headers=self.header, + data=json.dumps(params), + ) + + @__count_stats(put=1) + @__raise_szurubooru_exception + def __put(self, path: str, params: dict) -> r.Response: + return r.put( + f"{self.url}{path}", + timeout=15, + verify=self.verify, + headers=self.header, + data=json.dumps(params), + ) + + @__count_stats(delete=1) + @__raise_szurubooru_exception + def __delete(self, path: str, params: dict) -> r.Response: + return r.delete( + f"{self.url}{path}", + timeout=15, + verify=self.verify, + headers=self.header, + data=json.dumps(params), + ) + + def __map_tag_resource_response(self, res: r.Response, name: str = "") -> Tag: + """Map the requests response from the Szurubooru REST API to the Tag dataclass. + + This function is similar to __map_tag_resource and calls it internally. + Instead of receiving a dictionary if the Szurubooru Tag it operates on the + requests Response of the same form. + + To Illustrate, both code yield the same result: + >>> __map_tag_resource_response(res, name) + >>> __map_tag_resource(json.loads(res.content), name) + + + + Args: + res (r.Response): The requests Response from an Szurubooru REST API endpoint + that returns a Szurubooru tag resource in a form like: + .. code-block:: JSON + { + "version": , + "names": , + "category": , + "implications": , + "suggestions": , + "creationTime": , + "lastEditTime": , + "usages": , + "description": + } + + name (str, optional): If you provide a name here, the mapper will use that + name for the Tag dataclass instead of fetching it from + the alias names. This is useful if you have multiple + aliases or names in Szurubooru. + + Returns: + Tag: A Tag dataclass. The version parameter is included if the Szurubooru + REST API response contained a version field, otherwise it defaults to + -1. The name will be the first name returned by the Szurubooru REST API + or the name that was passed to the function as an optional parameter. + """ + return self.__map_tag_resource(json.loads(res.content), name) + + def __map_tag_resource(self, c: dict, name: str = "") -> Tag: + """Map the dict from the Szurubooru REST API to the Tag dataclass. + + This function is similar to __map_tag_resource_response and is called by it. + Instead of receiving a requests Response it operates on the dictionary of the + same form. + + To Illustrate, both code yield the same result: + >>> __map_tag_resource_response(res, name) + >>> __map_tag_resource(json.loads(res.content), name) + + Args: + c (dict): The dict object coming from an Szurubooru REST API Szurubooru tag + resource in a form like: + .. code-block:: JSON + { + "version": , + "names": , + "category": , + "implications": , + "suggestions": , + "creationTime": , + "lastEditTime": , + "usages": , + "description": + } + name (str, optional): If you provide a name here, the mapper will use that + name for the Tag dataclass instead of fetching it from + the alias names. This is useful if you have multiple + aliases or names in Szurubooru. + + Returns: + Tag: A Tag dataclass. The version parameter is included if the Szurubooru + REST API response contained a version field, otherwise it defaults to + -1. The name will be the first name returned by the Szurubooru REST API + or the name that was passed to the function as an optional parameter. + """ + return Tag( + name=name if name != "" else c["names"][0], + version=c.get("version", -1), + category=c["category"], + description=c["description"], + implications=list(map(lambda a: a["names"][0], c["implications"])), + suggestions=list(map(lambda a: a["names"][0], c["suggestions"])), + ) + + def tag_exists(self, name: str) -> bool: + """Check if the tag exists on Szurubooru. + + Args: + name (str): Name of the tag. + + Returns: + bool: True if tag already exists. False if there was no matching tag on + Szurubooru. + """ + try: + res = self.__get(path=f"/api/tag/{quote(name)}") + except TagNotFoundError: + return False + except Exception as e: + raise e + return res.status_code == 200 + + def tag_len(self) -> int: + """Check how many tags there are on the Szurubooru instance. + + Returns: + int: The number of tags that exist on the Szurubooru instance + """ + return json.loads(self.__get(path="/api/tags").content)["total"] + + def tag_list(self, limit: int = 100, offset: int = 0, query: str = "") -> List[Tag]: + """List tags on the Szurubooru instance. + + Args: + limit (int, optional): Limits the amount of tags displayed on one page. + Setting a smaller limit may help with query speed. + Usually the maximum allowed limit is 100. + Defaults to 100. + offset (int, optional): Offset where to start the listing. Required for + Paging the data when searching through many tags. + If you have a page size of 100 you need to set an + offset of 100 to see results on the 2. page. + So your offset should be PAGE_NUM * LIMIT. + Defaults to 0. + query (str, optional): You can use a custom query to filter through tags + better. If you use an empty string (the default) you + will search through all tags on the Szurubooru + instance. + Defaults to ''. + + Returns: + List[Tag]: List of Tag objects in the current page. + """ + path = f"/api/tags?offset={offset}&limit={limit}&query={quote(query)}" + results = json.loads(self.__get(path=path).content)["results"] + return list(map(self.__map_tag_resource, results)) + + def tag_list_all(self, query: str = "") -> List[Tag]: + """List every available Tag (matching the query) on the Szurubooru instance. + + WARNING: This method calls the Szurubooru REST API multiple times depending on + how many tags there are. It can take a while and can put significant load on the + Szurubooru instance. Use with caution. + + In essence this method does the paging for you in tag_list and returns the full + batch of Tags. + + Args: + query (str, optional): You can use a custom query to filter through tags + better. If you use an empty string (the default) you + will search through all tags on the Szurubooru + instance. + Defaults to ''. + + Returns: + List[Tag]: List of all Tag objects matching the query on the Szurubooru + instance. + """ + tags = [] + for i in range(0, math.ceil(self.tag_len() / 100)): + tags.extend(self.tag_list(offset=100 * i, limit=100, query=query)) + return tags + + def tag_get(self, name: str) -> Tag: + """Get a tag form the Szurubooru instance by name. + + Args: + name (str): Name of the tag. + + Raises: + TagNotFoundError: Tag could not be found on the Szurubooru instance. + + Returns: + Tag: Tag object of the requested tag. + """ + res = self.__get(path=f"/api/tag/{quote(name)}") + return self.__map_tag_resource_response(res, name=name) + + def tag_create(self, tag: Tag) -> Tag: + """Create a tag on the Szurubooru instance. + + Args: + tag (Tag): Tag dataclass representing the Tag that should be created. + + Returns: + Tag: Resulting Tag dataclass that was returned by the Szurubooru REST API + representing the created tag as it was created on the Szurubooru + instance. + """ + res = self.__post( + path="/api/tags", + params={ + "names": [tag.name], + "category": tag.category, + "description": tag.description, + "implications": tag.implications, + "suggestions": tag.suggestions, + }, + ) + return self.__map_tag_resource_response(res, name=tag.name) + + def tag_update(self, tag: Tag) -> Tag: + """Update a tag on the Szurubooru instance. + + Args: + tag (Tag): Tag dataclass representing the Tag that should be updated. The + name is the identifying parameter for the Szurubooru instance. + + Returns: + Tag: Resulting Tag dataclass that was returned by the Szurubooru REST API + representing the updated version of the tag as it is now on the + Szurubooru instance. + """ + res = self.__put( + path=f"/api/tag/{quote(tag.name)}", + params={ + "version": tag.version, + "category": tag.category, + "description": tag.description, + "implications": tag.implications, + "suggestions": tag.suggestions, + }, + ) + return self.__map_tag_resource_response(res, name=tag.name) + + def tag_delete(self, tag: Tag) -> None: + """Delete the given tag on the Szurubooru instance. + + Args: + tag (Tag): Tag dataclass of the tag that should be reledte. Needs to contain + the current version number. + """ + self.__delete( + path=f"/api/tag/{quote(tag.name)}", params={"version": tag.version} + ) + return + + def tag_merge(self, tag_to_merge: Tag, tag_to_merge_into: Tag) -> Tag: + """Remove tag_to_merge and merges all uses into tag_to_merge_into. + + Removes source tag and merges all of its usages, suggestions and implications to + the target tag. Other tag properties such as category and aliases do not get + transferred and are discarded. + + Args: + tag_to_merge (Tag): Tag dataclass representing the Tag that will be removed + and merged into the second Tag. The version parameter is + required. + tag_to_merge_into (Tag): Tag dataclass representing the Tag that the first + tag will be merged into. The version parameter is + required. + + Returns: + Tag: Resulting Tag dataclass that was returned by the Szurubooru REST API + representing the new (merged) tag on the Szurubooru instance. + """ + res = self.__post( + path="/api/tag-merge", + params={ + "removeVersion": tag_to_merge.version, + "remove": tag_to_merge.name, + "mergeToVersion": tag_to_merge_into.version, + "mergeTo": tag_to_merge_into.name, + }, + ) + return self.__map_tag_resource_response(res, name=tag_to_merge_into.name) + + def tag_list_siblings(self, tag: Tag) -> List[dict[str, int]]: + """List siblings of the given Tag + + Lists siblings of given tag, e.g. tags that were used in the same posts as the + given tag. `occurrences` field signifies how many times a given sibling appears + with given tag. Results are sorted by occurrences count and the list is + truncated to the first 50 elements. Doesn't use paging. + + Args: + tag (Tag): Tag that will queried. Only uses the tag.name parameter. + + Returns: + List[dict[str, int]]: Returns a List of dictionaries with the primary tag + name as the key and the number of occurrences as the + value. + """ + res = self.__get(path=f"/api/tag-siblings/{tag.name}") + results = json.loads(res.content)["results"] + return list(map(lambda a: {a["tag"]["names"][0]: a["occurrences"]}, results)) diff --git a/szuruboorupy/dataclasses.py b/szuruboorupy/dataclasses.py new file mode 100644 index 0000000..350a876 --- /dev/null +++ b/szuruboorupy/dataclasses.py @@ -0,0 +1,48 @@ +""" +Module for collection of dataclasses that map Szurubooru r. +""" + + +from dataclasses import field +from typing import List, Optional + +from pydantic.dataclasses import dataclass + + +@dataclass +class Tag: + """A single tag. Tags are used to let users search for posts. + + The Szurubooru structure: + .. code-block:: JSON + { + "version": , + "names": , + "category": , + "implications": , + "suggestions": , + "creationTime": , + "lastEditTime": , + "usages": , + "description": + } + + **Field meaning** + - ``: resource version. + - ``: a list of tag names (aliases). Tagging a post with any name will + automatically assign the first name from this list. + - ``: the name of the category the given tag belongs to. + - ``: a list of implied tags, serialized as micro tag resource. + - ``: a list of suggested tags, serialized as micro tag resource. + - ``: time the tag was created, formatted as per RFC 3339. + - ``: time the tag was edited, formatted as per RFC 3339. + - ``: the number of posts the tag was used in. + - ``: the tag description (instructions how to use, history etc.) + """ + + name: str + version: int = -1 + description: Optional[str] = "" + category: str = "General" + implications: List[str] = field(default_factory=list) + suggestions: List[str] = field(default_factory=list) diff --git a/szuruboorupy/exceptions.py b/szuruboorupy/exceptions.py new file mode 100644 index 0000000..bde9c9a --- /dev/null +++ b/szuruboorupy/exceptions.py @@ -0,0 +1,341 @@ +"""Possible Exceptions that are returned by the Szurubooru REST API + +We define the general Exception `SzurubooruException` and possible child exceptions +defined in this module as well as the Szurubooru API docs: +https://github.com/rr-/szurubooru/blob/master/doc/API.md#error-handling +""" + +import requests as r +import json + + +class SzurubooruException(Exception): + """General Error returned from the Szurubooru REST API. + + The Szurubooru API returns errors together with an http code in a form like this: + .. code-block:: JSON + { + "name": "Name of the error, e.g. 'PostNotFoundError'", + "title": "Generic title of error message, e.g. 'Not found'", + "description": "Detailed description of what went wrong, e.g. + 'User `rr-` not found." + } + + We map this json response to the SzurubooruException class or more specific children + such as MissingRequiredFileError with the fields / args that are provided from the + json response. + + Args: + name: Name of the exception as defined by the Szurubooru REST API + The name is also used in more specific Exceptions such as + MissingRequiredFileError(SzurubooruException). + title: Generic, human readable title of the error message. + description: More detailed description of what went wrong. Often includes + input from the user that caused the error. + """ + + name: str + title: str + description: str + + def __init__(self, name: str, title: str, description: str) -> None: + self.name = name + self.title = title + self.description = description + super().__init__(description) + + @staticmethod + def response_is_exception(res: r.Response) -> bool: + """Check if the Szurubooru REST API response is an error or not. + + Args: + res (r.Response): the Response of the request from the requests module. + + Returns: + bool: True if response is an error and False if it is not. + """ + return res.status_code != 200 + + @staticmethod + def map_exception(res: r.Response) -> Exception: + """Generate an exception for an error response from the Szurubooru REST API. + + Args: + res (r.Response): the Response from the Szurubooru REST API that should be + mapped to a Python Exception. + + Raises: + LookupError: Thrown when the Szurubooru REST API sent an error type (name) + that could not be mapped to one of the SzurubooruException + Python Exceptions. This can happen when there was an unexpected + e.g. an Internal Server Error in the Szurubooru REST API. + + Returns: + Exception: Returns a more specific exception. + """ + content: dict[str, str] = json.loads(res.content) + name: str = content["name"] + title: str = content["title"] + description: str = content["description"] + + match name: + case "MissingRequiredFileError": + return MissingRequiredFileError(name, title, description) + case "MissingRequiredParameterError": + return MissingRequiredParameterError(name, title, description) + case "InvalidParameterError": + return InvalidParameterError(name, title, description) + case "IntegrityError": + return IntegrityError(name, title, description) + case "SearchError": + return SearchError(name, title, description) + case "AuthError": + return AuthError(name, title, description) + case "PostNotFoundError": + return PostNotFoundError(name, title, description) + case "PostAlreadyFeaturedError": + return PostAlreadyFeaturedError(name, title, description) + case "PostAlreadyUploadedError": + return PostAlreadyUploadedError(name, title, description) + case "InvalidPostIdError": + return InvalidPostIdError(name, title, description) + case "InvalidPostSafetyError": + return InvalidPostSafetyError(name, title, description) + case "InvalidPostSourceError": + return InvalidPostSourceError(name, title, description) + case "InvalidPostContentError": + return InvalidPostContentError(name, title, description) + case "InvalidPostRelationError": + return InvalidPostRelationError(name, title, description) + case "InvalidPostNoteError": + return InvalidPostNoteError(name, title, description) + case "InvalidPostFlagError": + return InvalidPostFlagError(name, title, description) + case "InvalidFavoriteTargetError": + return InvalidFavoriteTargetError(name, title, description) + case "InvalidCommentIdError": + return InvalidCommentIdError(name, title, description) + case "CommentNotFoundError": + return CommentNotFoundError(name, title, description) + case "EmptyCommentTextError": + return EmptyCommentTextError(name, title, description) + case "InvalidScoreTargetError": + return InvalidScoreTargetError(name, title, description) + case "InvalidScoreValueError": + return InvalidScoreValueError(name, title, description) + case "TagCategoryNotFoundError": + return TagCategoryNotFoundError(name, title, description) + case "TagCategoryAlreadyExistsError": + return TagCategoryAlreadyExistsError(name, title, description) + case "TagCategoryIsInUseError": + return TagCategoryIsInUseError(name, title, description) + case "InvalidTagCategoryNameError": + return InvalidTagCategoryNameError(name, title, description) + case "InvalidTagCategoryColorError": + return InvalidTagCategoryColorError(name, title, description) + case "TagNotFoundError": + return TagNotFoundError(name, title, description) + case "TagAlreadyExistsError": + return TagAlreadyExistsError(name, title, description) + case "TagIsInUseError": + return TagIsInUseError(name, title, description) + case "InvalidTagNameError": + return InvalidTagNameError(name, title, description) + case "InvalidTagRelationError": + return InvalidTagRelationError(name, title, description) + case "InvalidTagCategoryError": + return InvalidTagCategoryError(name, title, description) + case "InvalidTagDescriptionError": + return InvalidTagDescriptionError(name, title, description) + case "UserNotFoundError": + return UserNotFoundError(name, title, description) + case "UserAlreadyExistsError": + return UserAlreadyExistsError(name, title, description) + case "InvalidUserNameError": + return InvalidUserNameError(name, title, description) + case "InvalidEmailError": + return InvalidEmailError(name, title, description) + case "InvalidPasswordError": + return InvalidPasswordError(name, title, description) + case "InvalidRankError": + return InvalidRankError(name, title, description) + case "InvalidAvatarError": + return InvalidAvatarError(name, title, description) + case "ProcessingError": + return ProcessingError(name, title, description) + case "ValidationError": + return ValidationError(name, title, description) + case _: + raise LookupError(f'Unknown SzurubooruException: "{name}"') + + +class MissingRequiredFileError(SzurubooruException): # noqa: D101 + pass + + +class MissingRequiredParameterError(SzurubooruException): # noqa: D101 + pass + + +class InvalidParameterError(SzurubooruException): # noqa: D101 + pass + + +class IntegrityError(SzurubooruException): # noqa: D101 + pass + + +class SearchError(SzurubooruException): # noqa: D101 + pass + + +class AuthError(SzurubooruException): # noqa: D101 + pass + + +class PostNotFoundError(SzurubooruException): # noqa: D101 + pass + + +class PostAlreadyFeaturedError(SzurubooruException): # noqa: D101 + pass + + +class PostAlreadyUploadedError(SzurubooruException): # noqa: D101 + pass + + +class InvalidPostIdError(SzurubooruException): # noqa: D101 + pass + + +class InvalidPostSafetyError(SzurubooruException): # noqa: D101 + pass + + +class InvalidPostSourceError(SzurubooruException): # noqa: D101 + pass + + +class InvalidPostContentError(SzurubooruException): # noqa: D101 + pass + + +class InvalidPostRelationError(SzurubooruException): # noqa: D101 + pass + + +class InvalidPostNoteError(SzurubooruException): # noqa: D101 + pass + + +class InvalidPostFlagError(SzurubooruException): # noqa: D101 + pass + + +class InvalidFavoriteTargetError(SzurubooruException): # noqa: D101 + pass + + +class InvalidCommentIdError(SzurubooruException): # noqa: D101 + pass + + +class CommentNotFoundError(SzurubooruException): # noqa: D101 + pass + + +class EmptyCommentTextError(SzurubooruException): # noqa: D101 + pass + + +class InvalidScoreTargetError(SzurubooruException): # noqa: D101 + pass + + +class InvalidScoreValueError(SzurubooruException): # noqa: D101 + pass + + +class TagCategoryNotFoundError(SzurubooruException): # noqa: D101 + pass + + +class TagCategoryAlreadyExistsError(SzurubooruException): # noqa: D101 + pass + + +class TagCategoryIsInUseError(SzurubooruException): # noqa: D101 + pass + + +class InvalidTagCategoryNameError(SzurubooruException): # noqa: D101 + pass + + +class InvalidTagCategoryColorError(SzurubooruException): # noqa: D101 + pass + + +class TagNotFoundError(SzurubooruException): # noqa: D101 + pass + + +class TagAlreadyExistsError(SzurubooruException): # noqa: D101 + pass + + +class TagIsInUseError(SzurubooruException): # noqa: D101 + pass + + +class InvalidTagNameError(SzurubooruException): # noqa: D101 + pass + + +class InvalidTagRelationError(SzurubooruException): # noqa: D101 + pass + + +class InvalidTagCategoryError(SzurubooruException): # noqa: D101 + pass + + +class InvalidTagDescriptionError(SzurubooruException): # noqa: D101 + pass + + +class UserNotFoundError(SzurubooruException): # noqa: D101 + pass + + +class UserAlreadyExistsError(SzurubooruException): # noqa: D101 + pass + + +class InvalidUserNameError(SzurubooruException): # noqa: D101 + pass + + +class InvalidEmailError(SzurubooruException): # noqa: D101 + pass + + +class InvalidPasswordError(SzurubooruException): # noqa: D101 + pass + + +class InvalidRankError(SzurubooruException): # noqa: D101 + pass + + +class InvalidAvatarError(SzurubooruException): # noqa: D101 + pass + + +class ProcessingError(SzurubooruException): # noqa: D101 + pass + + +class ValidationError(SzurubooruException): # noqa: D101 + pass -- cgit v1.2.3