From 5a0502b748dc34df4da5151ebff29c193a6d028a Mon Sep 17 00:00:00 2001 From: WorldTeacher Date: Fri, 23 May 2025 16:42:33 +0200 Subject: [PATCH] Add issue templates, enhance API functionality, and update dependencies - Introduced bug report and feature request templates for better issue tracking. - Added release workflow for automated versioning and package publishing. - Updated dependencies in `pyproject.toml` for improved functionality. - Refactored API endpoints to use `httpx` for better performance and error handling. - Added new methods in `BookController` and `KOMGAPI_REST` for enhanced book and series management. - Updated schemas to include optional fields for better data handling. --- .bumpversion.toml | 3 + .gitea/ISSUE_TEMPLATE/bug.yml | 34 ++++ .gitea/ISSUE_TEMPLATE/feature.yml | 23 +++ .gitea/ISSUE_TEMPLATE/workflows/release.yml | 88 +++++++++ .version | 0 pyproject.toml | 5 + src/komgapi/endpoints/baseapi.py | 197 ++++++++++---------- src/komgapi/endpoints/book_controller.py | 12 ++ src/komgapi/endpoints/series_controller.py | 13 +- src/komgapi/komgapi.py | 37 ++++ src/komgapi/schemas/Library.py | 2 + src/komgapi/schemas/Series.py | 34 ++-- 12 files changed, 335 insertions(+), 113 deletions(-) create mode 100644 .gitea/ISSUE_TEMPLATE/bug.yml create mode 100644 .gitea/ISSUE_TEMPLATE/feature.yml create mode 100644 .gitea/ISSUE_TEMPLATE/workflows/release.yml create mode 100644 .version diff --git a/.bumpversion.toml b/.bumpversion.toml index a27ec9d..4473625 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -24,3 +24,6 @@ post_commit_hooks = [] filename = "src/komgapi/__init__.py" [[tool.bumpversion.files]] filename = "pyproject.toml" +[[tool.bumpversion.files]] +filename = ".version" + diff --git a/.gitea/ISSUE_TEMPLATE/bug.yml b/.gitea/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..8f78e72 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,34 @@ +name: Bug Report +description: Report a bug in this project +labels: + - kind/bug + - triage +body: + + - type: textarea + id: bug + attributes: + label: Describe the bug + description: | + A clear and concise description of what the bug is. + What did you expect to happen? What happened instead? + Include screenshots if applicable. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: | + A clear and concise description of how to reproduce the bug. + Include steps, code snippets, or screenshots if applicable. + validations: + required: true + - type: textarea + id: additional-info + attributes: + label: Additional information + description: | + Add any other context or screenshots about the bug here. + + \ No newline at end of file diff --git a/.gitea/ISSUE_TEMPLATE/feature.yml b/.gitea/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..cb6338e --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Suggest an idea for this project +labels: + - kind/feature + - triage + +body: + - type: textarea + id: feature + attributes: + label: Describe the feature + description: | + A clear and concise description of what the feature is. + What is the problem it solves? What are you trying to accomplish? + Include screenshots if applicable. + validations: + required: true + - type: textarea + id: additional-info + attributes: + label: Additional information + description: | + Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.gitea/ISSUE_TEMPLATE/workflows/release.yml b/.gitea/ISSUE_TEMPLATE/workflows/release.yml new file mode 100644 index 0000000..8373538 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/workflows/release.yml @@ -0,0 +1,88 @@ +on: + workflow_dispatch: + inputs: + release_notes: + description: Release notes (use \n for newlines) + type: string + required: false + github_release: + description: 'Create Gitea Release' + default: true + type: boolean + bump: + description: 'Bump type' + required: true + default: 'patch' + type: choice + options: + - 'major' + - 'minor' + - 'patch' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@master + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Set up Python + run: uv python install + - name: Set Git identity + run: | + git config user.name "Gitea CI" + git config user.email "ci@git.theprivateserver.de" + - name: Bump version + id: bump + run: | + uv tool install bump-my-version + uv tool run bump-my-version bump ${{ github.event.inputs.bump }} + # echo the version to github env, the version is shown by using uv tool run bump-my-version show current_version + echo "VERSION<> $GITHUB_ENV + echo "$(uv tool run bump-my-version show current_version)" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + branch: ${{ github.ref }} + + - name: Add release notes to environment + id: add_release_notes + run: | + echo "RELEASE_NOTES<> $GITHUB_ENV + echo "${{ github.event.inputs.release_notes }}" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + - name: Build package + run: uv build + - name: Publish package + env: + USERNAME: ${{ github.repository_owner }} + run: uv publish --publish-url https://git.theprivateserver.de/api/packages/$USERNAME/pypi/ -t ${{ secrets.TOKEN }} + - name: Generate changelog + id: changelog + uses: metcalfc/changelog-generator@v4.6.2 + with: + myToken: ${{ secrets.GITHUB_TOKEN }} + - name: Get the changelog + run: | + cat << "EOF" + ${{ steps.changelog.outputs.changelog }} + EOF + + - name: Create release + id: create_release + if: ${{ github.event.inputs.github_release == 'true' }} + uses: softprops/action-gh-release@master + with: + tag_name: ${{ env.VERSION }} + release_name: Release ${{ env.VERSION }} + body: ${{ steps.changelog.outputs.changelog }} + draft: false + prerelease: false + make_latest: true + files: | + dist/* + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} \ No newline at end of file diff --git a/.version b/.version new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 60e6dc1..404513d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ authors = [ ] requires-python = ">=3.13" dependencies = [ + "httpx>=0.28.1", "komconfig", + "loguru>=0.7.3", "typing-extensions>=4.12.2", ] @@ -17,6 +19,9 @@ requires = ["hatchling"] build-backend = "hatchling.build" [dependency-groups] +dev = [ + "komconfig", +] test = [ "pytest>=8.3.4", ] diff --git a/src/komgapi/endpoints/baseapi.py b/src/komgapi/endpoints/baseapi.py index d66b968..84f7007 100644 --- a/src/komgapi/endpoints/baseapi.py +++ b/src/komgapi/endpoints/baseapi.py @@ -1,10 +1,12 @@ -import requests -from komgapi.errors import KomgaError, LoginError, ResultErrror +import httpx +from httpx_retries import Retry, RetryTransport +from komgapi.errors import KomgaError, ResultErrror from typing import Any, Union -from limit import limit +from limit import limit # type:ignore import loguru import sys +import json log = loguru.logger log.remove() @@ -13,29 +15,37 @@ log.add(sys.stdout, level="INFO") class BaseAPI: - def __init__(self, username, password, url, timeout=20, api_version=1) -> None: + def __init__( + self, + username: str, + password: str, + url: str, + timeout: int = 20, + api_version: int = 1, + ) -> None: self._username = username self._password = password self.url = url + f"api/v{api_version}/" self.timeout = timeout + self.headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } - def setParams(self, locals: dict) -> dict: + def setParams(self, locals: dict[Any, Any]) -> dict[Any, Any]: return { param_name: param for param_name, param in locals.items() - if param is not None and param_name not in ["self", "series_idurl"] + if param is not None + and param_name not in ["self", "series_idurl", "query", "url"] } def test_connection(self): - """Test the connection to the server. - - Returns: - bool: True if the connection is successful, False otherwise. - """ try: - requests.get(self.url, timeout=self.timeout) + with httpx.Client(timeout=self.timeout) as client: + client.get(self.url, headers=self.headers) return True - except requests.exceptions.RequestException: + except httpx.RequestError: return False def overwriteVersion(self, version: int): @@ -43,126 +53,123 @@ class BaseAPI: return self @limit(1, 1) - def getRequest(self, url, params: Union[dict, None] = None) -> Any: + def getRequest(self, url: str, params: Union[dict[Any, Any], None] = None) -> Any: if params is None: params = {} try: - response = requests.get( - url, - auth=(self._username, self._password), - params=params, + with httpx.Client( timeout=self.timeout, - ) + auth=(self._username, self._password), + transport=RetryTransport(retry=Retry(total=5, backoff_factor=0.5)), + ) as client: + response = client.get(url, params=params, headers=self.headers) if response.status_code != 200: - self.getRequest(url, params) - # print(response.content) - log.debug(f"Response: {response.content}") + return self.getRequest(url, params) + return response.json() - except ConnectionError as e: - message = f"Connection Error: {e}" - raise KomgaError(message) from e - except requests.exceptions.Timeout as e: + except httpx.ConnectError as e: + raise KomgaError(f"Connection Error: {e}") from e + except httpx.TimeoutException as e: raise KomgaError(f"Timeout Error: {e}") from e - def postRequest(self, url, data: Union[dict, None] = None, body: dict = None): + def postRequest( + self, + url: str, + data: Union[dict[Any, Any], None] = None, + body: Union[dict[Any, Any], None] = None, + ): if data is None: data = {} try: - if body is not None: - response = requests.post( + with httpx.Client( + timeout=self.timeout, auth=(self._username, self._password), transport=RetryTransport(retry=Retry(total=5, backoff_factor=0.5)) + ) as client: + response = client.post( url, - auth=(self._username, self._password), - json=body, - params=data, - timeout=self.timeout, - ) - else: - response = requests.post( - url, - auth=(self._username, self._password), - json=data, - timeout=self.timeout, params=data, + json=body if body is not None else {}, + headers=self.headers, ) + log.debug( + "POST request to {} with data: {}, json: {}", + url, + json.dumps(data), + json.dumps(body), + ) response.raise_for_status() status_code = response.status_code if status_code == 202: - log.debug(f"Response: {response}") - # raise ResultErrror(f"Result Error: {response}") + return None elif status_code == 200: - log.debug(f"Response: {response}") return response.json() else: - log.debug(f"Response: {response}") raise ResultErrror(f"Result Error: {response.content}") - except ConnectionError as e: - message = f"Connection Error: {e}" - raise KomgaError(message) from e - except requests.exceptions.Timeout as e: + except httpx.ConnectError as e: + raise KomgaError(f"Connection Error: {e}") from e + except httpx.TimeoutException as e: raise KomgaError(f"Timeout Error: {e}") from e - def patchRequest(self, url, data: Union[dict, None] = None): + def patchRequest(self, url: str, data: Union[dict[Any, Any], None] = None): + """Send PATCH request to API endpoint. + + Args: + url (str): API endpoint URL + data (Union[dict[Any, Any], None]): Data to send in request body + + Returns: + dict: JSON response from server + + Raises: + KomgaError: For connection/timeout errors + ResultError: For invalid responses + """ if data is None: data = {} + try: - print("patching data", data, url) - response = requests.patch( - url, - auth=(self._username, self._password), - json=data, + log.debug("PATCH request to {} with data: {}", url, json.dumps(data)) + + with httpx.Client( timeout=self.timeout, - ) + auth=(self._username, self._password), + headers=self.headers, + ) as client: + response = client.patch(url, json=data) + response.raise_for_status() - log.debug( - f"Response: {response}, {response.status_code}, {response.content}" - ) - print(response.status_code, response.content) - if response.status_code != 204: - raise ResultErrror(f"Result Error: {response.json()}") - except ConnectionError as e: - message = f"Connection Error: {e}" - raise KomgaError(message) from e - except requests.exceptions.Timeout as e: + + if response.status_code == 204: + return None + + return response.json() if response.content else None + + except httpx.ConnectError as e: + log.error("Connection error during PATCH to {}: {}", url, str(e)) + raise KomgaError(f"Connection Error: {e}") from e + + except httpx.TimeoutException as e: + log.error("Timeout during PATCH to {}: {}", url, str(e)) raise KomgaError(f"Timeout Error: {e}") from e - def putRequest(self, url, data: Union[dict, None] = None): - if data is None: - data = {} - try: - response = requests.put( - url, - auth=(self._username, self._password), - json=data, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except ConnectionError as e: - message = f"Connection Error: {e}" - raise KomgaError(message) from e - except requests.exceptions.Timeout as e: - raise KomgaError(f"Timeout Error: {e}") from e + except httpx.HTTPStatusError as e: + log.error("HTTP error during PATCH to {}: {}", url, e.response.text) + raise ResultErrror(f"Result Error: {e.response.text}") from e - def deleteRequest(self, url): + def deleteRequest(self, url: str): try: - response = requests.delete( - url, auth=(self._username, self._password), timeout=self.timeout - ) + with httpx.Client( + timeout=self.timeout, auth=(self._username, self._password) + ) as client: + response = client.delete(url, headers=self.headers) response.raise_for_status() return response.json() - except ConnectionError as e: - message = f"Connection Error: {e}" - raise KomgaError(message) from e - except requests.exceptions.Timeout as e: + except httpx.ConnectError as e: + raise KomgaError(f"Connection Error: {e}") from e + except httpx.TimeoutException as e: raise KomgaError(f"Timeout Error: {e}") from e @classmethod def from_env(cls): - """Create a KOMGA API object from environment variables. - - Returns: - KOMGAPI_REST: The KOMGA API object. - """ import os return cls( diff --git a/src/komgapi/endpoints/book_controller.py b/src/komgapi/endpoints/book_controller.py index c0f69a8..c03589e 100644 --- a/src/komgapi/endpoints/book_controller.py +++ b/src/komgapi/endpoints/book_controller.py @@ -11,6 +11,7 @@ class BookController(BaseAPI): def __init__(self, username, password, url, timeout=20) -> None: super().__init__(username, password, url, timeout) + @typing_extensions.deprecated("This function is deprecated.") def getBooks( self, search_string: str = None, @@ -53,6 +54,17 @@ class BookController(BaseAPI): ret.append(Book(**book)) return ret + def listBooks( + self, query: dict[str, Any] = None, unpaged: bool = True + ) -> List[Book]: + url = self.url + "books/list" + if query is None: + query = {} + params = locals() + params = self.setParams(params) + data = self.postRequest(url, params, query) + return [Book(**book) for book in data["content"]] + def getBook(self, book_id: str) -> Book: """Get a specific book. diff --git a/src/komgapi/endpoints/series_controller.py b/src/komgapi/endpoints/series_controller.py index 351ac14..e3f6fd4 100644 --- a/src/komgapi/endpoints/series_controller.py +++ b/src/komgapi/endpoints/series_controller.py @@ -4,8 +4,8 @@ import subprocess import requests import typing_extensions from typing import List, Optional, Dict, Any, Union - -from komgapi.schemas import * # Progress, Series +import json +from komgapi.schemas import Series, Book, Collection, Thumbnail import loguru import sys @@ -86,6 +86,10 @@ class SeriesController(BaseAPI): data = self.postRequest(url) return data + # mark as pending deprecation + @typing_extensions.deprecated( + "This function will be deprecated soon. Switch to BookController.listBooks()" + ) def getSeriesBooks( self, series_id: str, @@ -240,8 +244,11 @@ class SeriesController(BaseAPI): """ url = self.url + f"series/{series_id}/metadata" + # change metadata to a json string + # changed_metadata = json.dumps(changed_metadata) + log.info("Changed metadata: {}", changed_metadata) + data = self.patchRequest(url, changed_metadata) - log.debug("Changed metadata: {}", data) return data diff --git a/src/komgapi/komgapi.py b/src/komgapi/komgapi.py index cf6fd39..ecfae8b 100644 --- a/src/komgapi/komgapi.py +++ b/src/komgapi/komgapi.py @@ -87,3 +87,40 @@ class KOMGAPI_REST: return False # Book controller + def seriesList(self) -> list[str]: + """Get the list of books from the server. + + Returns: + list: The list of books. + """ + data = [] + series = self.series_controller.getAllSeries() + for serie in series: + data.append(serie.name) + return data + + def getSeries(self, seriesName: str) -> bool: + """Get the series from the server. + + Args: + seriesName (str): The name of the series. + + Returns: + bool: True if the series is found, False otherwise. + """ + series = self.series_controller.getAllSeries( + body={"condition": {"title": {"operator": "contains", "value": seriesName}}} + ) + if len(series) == 1: + return True + else: + for serie in series: + if serie.name.strip().lower() == seriesName.strip().lower(): + return True + + return False + # for serie in series: + # print(f"series: {serie}") + # if serie.name.strip().lower() == seriesName.strip().lower(): + # return True + # return False diff --git a/src/komgapi/schemas/Library.py b/src/komgapi/schemas/Library.py index bed95c8..3ca3ddf 100644 --- a/src/komgapi/schemas/Library.py +++ b/src/komgapi/schemas/Library.py @@ -7,6 +7,8 @@ from dataclasses import dataclass @dataclass class Library: + """Library class to represent a library in the API.""" + id: str name: str root: str diff --git a/src/komgapi/schemas/Series.py b/src/komgapi/schemas/Series.py index 18eb9ab..14ef4f8 100644 --- a/src/komgapi/schemas/Series.py +++ b/src/komgapi/schemas/Series.py @@ -7,21 +7,25 @@ from .Metadata import Metadata @dataclass class Series: - id: str = None - libraryId: str = None - name: str = None - url: str = None - created: str = None - lastModified: str = None - fileLastModified: str = None - booksCount: int = 0 - booksReadCount: int = None - booksUnreadCount: int = None - booksInProgressCount: int = None - metadata: Metadata = None - booksMetadata: BooksMetadata = None - deleted: bool = None - oneshot: bool = None + """ + A class representing a series in the KOMGA API. + """ + + id: Optional[str] = None + libraryId: Optional[str] = None + name: Optional[str] = None + url: Optional[str] = None + created: Optional[str] = None + lastModified: Optional[str] = None + fileLastModified: Optional[str] = None + booksCount: Optional[int] = 0 + booksReadCount: Optional[int] = None + booksUnreadCount: Optional[int] = None + booksInProgressCount: Optional[int] = None + metadata: Optional[Metadata] = None + booksMetadata: Optional[BooksMetadata] = None + deleted: Optional[bool] = None + oneshot: Optional[bool] = None def __post_init__(self): if self.metadata is None: