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.
This commit is contained in:
2025-05-23 16:42:33 +02:00
parent 759c01380f
commit 5a0502b748
12 changed files with 335 additions and 113 deletions

View File

@@ -24,3 +24,6 @@ post_commit_hooks = []
filename = "src/komgapi/__init__.py"
[[tool.bumpversion.files]]
filename = "pyproject.toml"
[[tool.bumpversion.files]]
filename = ".version"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<<EOF" >> $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<<EOF" >> $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 }}

0
.version Normal file
View File

View File

@@ -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",
]

View File

@@ -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,
json=body if body is not None else {},
headers=self.headers,
)
else:
response = requests.post(
log.debug(
"POST request to {} with data: {}, json: {}",
url,
auth=(self._username, self._password),
json=data,
timeout=self.timeout,
params=data,
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(

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: