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:
@@ -24,3 +24,6 @@ post_commit_hooks = []
|
||||
filename = "src/komgapi/__init__.py"
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "pyproject.toml"
|
||||
[[tool.bumpversion.files]]
|
||||
filename = ".version"
|
||||
|
||||
|
||||
34
.gitea/ISSUE_TEMPLATE/bug.yml
Normal file
34
.gitea/ISSUE_TEMPLATE/bug.yml
Normal 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.
|
||||
|
||||
|
||||
23
.gitea/ISSUE_TEMPLATE/feature.yml
Normal file
23
.gitea/ISSUE_TEMPLATE/feature.yml
Normal 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.
|
||||
88
.gitea/ISSUE_TEMPLATE/workflows/release.yml
Normal file
88
.gitea/ISSUE_TEMPLATE/workflows/release.yml
Normal 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 }}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user