diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 865820f20..c334720bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.LATEST_PYTHON_VER }} + allow-prereleases: true cache: pip check-latest: true cache-dependency-path: ${{ github.workspace }}/pyproject.toml @@ -130,7 +131,7 @@ jobs: needs: [ verify, package ] strategy: matrix: - PYTHON_VER: [ 3.13-dev, 3.12, 3.11, "3.10", pypy3.10, 3.9, 3.8 ] + PYTHON_VER: [ "3.14", "3.13", "3.12", "3.11", "3.10", "pypy3.10", "3.9" ] uses: ./.github/workflows/test.yml secrets: inherit with: @@ -207,6 +208,7 @@ jobs: uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.LATEST_PYTHON_VER }} + allow-prereleases: true cache: pip cache-dependency-path: ${{ github.workspace }}/setup.cfg diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ccffd40b9..d889d14fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,6 +56,7 @@ jobs: uses: actions/setup-python@v5.2.0 with: python-version: ${{ inputs.python-version }} + allow-prereleases: true cache: pip check-latest: true cache-dependency-path: ${{ github.workspace }}/setup.cfg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1944a73b4..70e717143 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,16 +10,17 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/PyCQA/docformatter - rev: v1.7.5 - hooks: - - id: docformatter - exclude: _attrdict.py - args: - - --in-place - - --pre-summary-newline - - --black - - --non-cap=qBittorrent + # need a post v1.7.5 release for latest pre-commit + #- repo: https://github.com/PyCQA/docformatter + # rev: v1.7.5 + # hooks: + # - id: docformatter + # exclude: _attrdict.py + # args: + # - --in-place + # - --pre-summary-newline + # - --black + # - --non-cap=qBittorrent - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.6.8 @@ -27,6 +28,7 @@ repos: - id: ruff args: - --fix + - --unsafe-fixes - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy diff --git a/pyproject.toml b/pyproject.toml index 158042795..7113b4f16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qbittorrent-api" -requires-python = ">=3.8" +requires-python = ">=3.9" description = "Python client for qBittorrent v4.1+ Web API." authors = [{name = "Russell Martin"}] maintainers = [{name = "Russell Martin"}] @@ -16,12 +16,12 @@ classifiers = [ "Operating System :: OS Independent", "Environment :: Console", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Utilities", @@ -36,25 +36,24 @@ dependencies = [ [project.optional-dependencies] dev = [ - "build ==1.2.2", - "coverage[toml] ==7.6.1", + "build ==1.2.2.post1", + "coverage[toml] ==7.6.4", "furo ==2024.8.6", - "mypy ==1.11.2", - "pre-commit <3.6.0 ; python_version < '3.9'", - "pre-commit ==3.8.0 ; python_version >= '3.9'", + "mypy ==1.13.0", + "pre-commit ==4.0.1", "pytest ==8.3.3", - "tox ==4.21.0", + "tox ==4.23.2", "twine ==5.1.1", - "types-requests ==2.32.0.20240914", + "types-requests ==2.32.0.20241016", ] docs = [ # building docs requires Python >3.10 - "sphinx ==8.0.2", - "sphinx-autobuild ==2024.9.19", + "sphinx ==8.1.3", + "sphinx-autobuild ==2024.10.3", "sphinx-copybutton ==0.5.2", "sphinxcontrib-spelling ==8.0.0", - "sphinx-autodoc-typehints ==2.4.4", + "sphinx-autodoc-typehints ==2.5.0", ] [project.urls] @@ -70,7 +69,7 @@ readme = {file = ["README.md", "CHANGELOG.md", "LICENSE"], content-type = "text/ # section must be present to trigger its use [tool.ruff] -target-version = "py38" +target-version = "py39" [tool.ruff.lint] select = ["C40", "C9", "E", "F", "PLE", "S", "W", "YTT", "I", "UP", "SIM"] diff --git a/src/qbittorrentapi/_attrdict.py b/src/qbittorrentapi/_attrdict.py index 44305fd57..3f2c56f25 100644 --- a/src/qbittorrentapi/_attrdict.py +++ b/src/qbittorrentapi/_attrdict.py @@ -31,8 +31,9 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Mapping, MutableMapping, Sequence from re import compile as re_compile -from typing import Any, Dict, Mapping, MutableMapping, Sequence, TypeVar +from typing import Any, TypeVar K = TypeVar("K") V = TypeVar("V") @@ -254,7 +255,7 @@ def __delattr__(self, key: str, force: bool = False) -> None: ) -class AttrDict(Dict[str, V], MutableAttr[V]): +class AttrDict(dict[str, V], MutableAttr[V]): """A dict that implements MutableAttr.""" _sequence_type: type diff --git a/src/qbittorrentapi/_version_support.py b/src/qbittorrentapi/_version_support.py index 318b412a5..f03df0a32 100644 --- a/src/qbittorrentapi/_version_support.py +++ b/src/qbittorrentapi/_version_support.py @@ -1,6 +1,6 @@ from __future__ import annotations -from functools import lru_cache +from functools import cache from typing import Final, Literal import packaging.version @@ -71,7 +71,7 @@ MOST_RECENT_SUPPORTED_API_VERSION: Final[Literal["2.11.2"]] = "2.11.2" -@lru_cache(maxsize=None) +@cache def v(version: str) -> packaging.version.Version: """Caching version parser.""" return packaging.version.Version(version) diff --git a/src/qbittorrentapi/app.py b/src/qbittorrentapi/app.py index cff56a0d0..7d95e6908 100644 --- a/src/qbittorrentapi/app.py +++ b/src/qbittorrentapi/app.py @@ -1,9 +1,10 @@ from __future__ import annotations import os +from collections.abc import Iterable, Mapping, Sequence from json import dumps from logging import Logger, getLogger -from typing import Any, AnyStr, Iterable, Mapping, Sequence, Union +from typing import Any, AnyStr, Union from qbittorrentapi.auth import AuthAPIMixIn from qbittorrentapi.definitions import ( diff --git a/src/qbittorrentapi/client.py b/src/qbittorrentapi/client.py index 8178105a6..775e395e1 100644 --- a/src/qbittorrentapi/client.py +++ b/src/qbittorrentapi/client.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from qbittorrentapi.log import LogAPIMixIn from qbittorrentapi.rss import RSSAPIMixIn diff --git a/src/qbittorrentapi/definitions.py b/src/qbittorrentapi/definitions.py index d6c5ef6ad..4ce5ac6c8 100644 --- a/src/qbittorrentapi/definitions.py +++ b/src/qbittorrentapi/definitions.py @@ -1,16 +1,12 @@ from __future__ import annotations -import sys from collections import UserList +from collections.abc import Iterable, Mapping, Sequence from enum import Enum from typing import ( TYPE_CHECKING, Any, Generic, - Iterable, - Mapping, - Sequence, - Tuple, TypeVar, Union, ) @@ -42,7 +38,7 @@ #: Type for List input to API method. ListInputT = Iterable[Mapping[str, JsonValueT]] #: Type for Files input to API method. -FilesToSendT = Mapping[str, Union[bytes, Tuple[str, bytes]]] +FilesToSendT = Mapping[str, Union[bytes, tuple[str, bytes]]] class APINames(str, Enum): @@ -250,50 +246,25 @@ def _normalize(cls, data: Mapping[str, V] | T) -> AttrDict[V] | T: return data -# Python 3.8 does not support UserList as a proper Generic -if sys.version_info < (3, 9): - - class List(UserList, Generic[ListEntryT]): - """Base definition for list-like objects returned from qBittorrent.""" - - def __init__( - self, - list_entries: ListInputT | None = None, - entry_class: type[ListEntryT] | None = None, - **kwargs: Any, - ): - super().__init__( - [ - ( - entry_class(data=entry, **kwargs) - if entry_class is not None and isinstance(entry, Mapping) - else entry - ) - for entry in list_entries or [] - ] - ) - -else: - - class List(UserList[ListEntryT]): - """Base definition for list-like objects returned from qBittorrent.""" - - def __init__( - self, - list_entries: ListInputT | None = None, - entry_class: type[ListEntryT] | None = None, - **kwargs: Any, - ): - super().__init__( - [ - ( - entry_class(data=entry, **kwargs) # type: ignore[misc] - if entry_class is not None and isinstance(entry, Mapping) - else entry - ) - for entry in list_entries or [] - ] - ) +class List(UserList[ListEntryT]): + """Base definition for list-like objects returned from qBittorrent.""" + + def __init__( + self, + list_entries: ListInputT | None = None, + entry_class: type[ListEntryT] | None = None, + **kwargs: Any, + ): + super().__init__( + [ + ( + entry_class(data=entry, **kwargs) # type: ignore[misc] + if entry_class is not None and isinstance(entry, Mapping) + else entry + ) + for entry in list_entries or [] + ] + ) class ListEntry(Dictionary[JsonValueT]): diff --git a/src/qbittorrentapi/request.py b/src/qbittorrentapi/request.py index a19dedab0..9aa790223 100644 --- a/src/qbittorrentapi/request.py +++ b/src/qbittorrentapi/request.py @@ -1,11 +1,11 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from json import loads from logging import Logger, NullHandler, getLogger from os import environ from time import sleep -from typing import TYPE_CHECKING, Any, Literal, Mapping, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast from urllib.parse import ParseResult, urljoin, urlparse from requests import Response, Session diff --git a/src/qbittorrentapi/rss.py b/src/qbittorrentapi/rss.py index 884831df7..fa6f408b1 100644 --- a/src/qbittorrentapi/rss.py +++ b/src/qbittorrentapi/rss.py @@ -1,7 +1,7 @@ from __future__ import annotations +from collections.abc import Mapping from json import dumps -from typing import Mapping from qbittorrentapi.app import AppAPIMixIn from qbittorrentapi.definitions import ( diff --git a/src/qbittorrentapi/search.py b/src/qbittorrentapi/search.py index ee1a5b7d4..6984a8c68 100644 --- a/src/qbittorrentapi/search.py +++ b/src/qbittorrentapi/search.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Iterable, Mapping, cast +from collections.abc import Iterable, Mapping +from typing import cast from qbittorrentapi.app import AppAPIMixIn from qbittorrentapi.definitions import ( diff --git a/src/qbittorrentapi/torrents.py b/src/qbittorrentapi/torrents.py index aa0bdd7f7..4040d8405 100644 --- a/src/qbittorrentapi/torrents.py +++ b/src/qbittorrentapi/torrents.py @@ -1,6 +1,7 @@ from __future__ import annotations import errno +from collections.abc import Iterable, Mapping, MutableMapping from logging import Logger, getLogger from os import path from os import strerror as os_strerror @@ -8,10 +9,7 @@ IO, Any, Callable, - Iterable, Literal, - Mapping, - MutableMapping, TypeVar, Union, cast, diff --git a/src/qbittorrentapi/transfer.py b/src/qbittorrentapi/transfer.py index 8b5da48be..1dc6a8416 100644 --- a/src/qbittorrentapi/transfer.py +++ b/src/qbittorrentapi/transfer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable +from collections.abc import Iterable from qbittorrentapi._version_support import v from qbittorrentapi.app import AppAPIMixIn diff --git a/tests/test_request.py b/tests/test_request.py index 04841d3ea..2cfba4594 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -637,8 +637,9 @@ def request500(*args, **kwargs): client.auth_log_in() with monkeypatch.context() as m: m.setattr(client, "_request", request500) - with caplog.at_level(logging.DEBUG, logger="qbittorrentapi"), pytest.raises( - exceptions.HTTP500Error + with ( + caplog.at_level(logging.DEBUG, logger="qbittorrentapi"), + pytest.raises(exceptions.HTTP500Error), ): client.app_version() assert "Retry attempt" in caplog.text @@ -647,8 +648,9 @@ def request500(*args, **kwargs): def test_request_retry_skip(caplog): client = Client(VERIFY_WEBUI_CERTIFICATE=False) client.auth_log_in() - with caplog.at_level(logging.DEBUG, logger="qbittorrentapi"), pytest.raises( - exceptions.MissingRequiredParameters400Error + with ( + caplog.at_level(logging.DEBUG, logger="qbittorrentapi"), + pytest.raises(exceptions.MissingRequiredParameters400Error), ): client.torrents_rename() assert "Retry attempt" not in caplog.text @@ -656,8 +658,9 @@ def test_request_retry_skip(caplog): def test_verbose_logging(caplog): client = Client(VERBOSE_RESPONSE_LOGGING=True, VERIFY_WEBUI_CERTIFICATE=False) - with caplog.at_level(logging.DEBUG, logger="qbittorrentapi"), pytest.raises( - exceptions.NotFound404Error + with ( + caplog.at_level(logging.DEBUG, logger="qbittorrentapi"), + pytest.raises(exceptions.NotFound404Error), ): client.torrents_rename(torrent_hash="asdf", new_torrent_name="erty") assert "Response status" in caplog.text