From 576dc8b1a8962609035a699582b43a94cfe7b969 Mon Sep 17 00:00:00 2001 From: Russell Martin Date: Mon, 9 Oct 2023 14:25:54 -0400 Subject: [PATCH] Integrate typing into source --- .pre-commit-config.yaml | 34 +- docs/source/conf.py | 46 +- docs/source/spelling_wordlist | 6 + pyproject.toml | 1 + setup.cfg | 1 + src/qbittorrentapi/_attrdict.py | 318 +- src/qbittorrentapi/_attrdict.pyi | 37 - src/qbittorrentapi/_types.pyi | 29 - src/qbittorrentapi/_version_support.py | 38 +- src/qbittorrentapi/_version_support.pyi | 28 - src/qbittorrentapi/app.py | 346 +- src/qbittorrentapi/app.pyi | 96 - src/qbittorrentapi/auth.py | 134 +- src/qbittorrentapi/auth.pyi | 47 - src/qbittorrentapi/client.py | 35 +- src/qbittorrentapi/client.pyi | 46 - src/qbittorrentapi/decorators.py | 184 - src/qbittorrentapi/decorators.pyi | 37 - src/qbittorrentapi/definitions.py | 127 +- src/qbittorrentapi/definitions.pyi | 106 - src/qbittorrentapi/exceptions.py | 20 +- src/qbittorrentapi/exceptions.pyi | 36 - src/qbittorrentapi/log.py | 244 +- src/qbittorrentapi/log.pyi | 94 - src/qbittorrentapi/request.py | 811 ++-- src/qbittorrentapi/request.pyi | 238 - src/qbittorrentapi/rss.py | 489 +- src/qbittorrentapi/rss.pyi | 179 - src/qbittorrentapi/search.py | 525 +- src/qbittorrentapi/search.pyi | 175 - src/qbittorrentapi/sync.py | 192 +- src/qbittorrentapi/sync.pyi | 63 - src/qbittorrentapi/torrents.py | 4582 ++++++++++-------- src/qbittorrentapi/torrents.pyi | 949 ---- src/qbittorrentapi/transfer.py | 358 +- src/qbittorrentapi/transfer.pyi | 149 - tests/_resources/mypy_stubtest_allowlist.txt | 16 - tests/conftest.py | 4 + tests/test_decorators.py | 115 - tests/test_definitions.py | 30 +- tests/test_log.py | 10 +- tests/test_request.py | 99 +- tests/test_torrent.py | 7 +- tests/utils.py | 3 +- tox.ini | 1 + 45 files changed, 4686 insertions(+), 6399 deletions(-) delete mode 100644 src/qbittorrentapi/_attrdict.pyi delete mode 100644 src/qbittorrentapi/_types.pyi delete mode 100644 src/qbittorrentapi/_version_support.pyi delete mode 100644 src/qbittorrentapi/app.pyi delete mode 100644 src/qbittorrentapi/auth.pyi delete mode 100644 src/qbittorrentapi/client.pyi delete mode 100644 src/qbittorrentapi/decorators.py delete mode 100644 src/qbittorrentapi/decorators.pyi delete mode 100644 src/qbittorrentapi/definitions.pyi delete mode 100644 src/qbittorrentapi/exceptions.pyi delete mode 100644 src/qbittorrentapi/log.pyi delete mode 100644 src/qbittorrentapi/request.pyi delete mode 100644 src/qbittorrentapi/rss.pyi delete mode 100644 src/qbittorrentapi/search.pyi delete mode 100644 src/qbittorrentapi/sync.pyi delete mode 100644 src/qbittorrentapi/torrents.pyi delete mode 100644 src/qbittorrentapi/transfer.pyi delete mode 100644 tests/_resources/mypy_stubtest_allowlist.txt delete mode 100644 tests/test_decorators.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c5c36ea3a..da38b58d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-toml - id: check-yaml @@ -18,7 +18,7 @@ repos: - toml - repo: https://github.com/asottile/pyupgrade - rev: v3.13.0 + rev: v3.15.0 hooks: - id: pyupgrade args: @@ -36,7 +36,7 @@ repos: - --non-cap=qBittorrent - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.291 + rev: v0.0.292 hooks: - id: ruff args: @@ -49,35 +49,19 @@ repos: language_version: python3 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.5.1 + rev: v1.6.0 hooks: - id: mypy - files: '.*\.pyi' + files: ^src/ additional_dependencies: - types-requests - types-six + - packaging args: - --strict - --disallow-any-unimported - - --disallow-any-expr - - --disallow-any-decorated - - --warn-unreachable - - --warn-unused-ignores - - --warn-redundant-casts +# - --disallow-any-expr +# - --disallow-any-decorated - --strict-optional - --show-traceback - - - repo: local - hooks: - - id: stubtest - name: mypy.stubtest - language: system - entry: stubtest - args: - - qbittorrentapi - - --allowlist=tests/_resources/mypy_stubtest_allowlist.txt - pass_filenames: false - types_or: - - python - - text - files: '.*\.pyi?' + - --implicit-reexport diff --git a/docs/source/conf.py b/docs/source/conf.py index 80a42eade..9e9c74538 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -32,22 +32,45 @@ # -- General configuration --------------------------------------------------- -pygments_style = "sphinx" - -# warn about everything -nitpicky = True - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. +# Add any Sphinx extension module names here, as strings. They can be extensions coming +# with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ "sphinx.ext.todo", "sphinx.ext.githubpages", "sphinx.ext.autodoc", + "sphinx_autodoc_typehints", "sphinx.ext.intersphinx", "sphinx_copybutton", ] +pygments_style = "sphinx" + +# warn about everything +nitpicky = True +nitpick_ignore = [ + ("py:class", "JsonValueT"), + ("py:class", "ListInputT"), + ("py:class", "Response"), + ("py:class", "Request"), + ("py:class", "qbittorrentapi.request.ResponseT"), + ("py:class", "qbittorrentapi.request.T"), + ("py:class", "qbittorrentapi.torrents.TorrentFilesT"), + ("py:obj", "qbittorrentapi._attrdict.K"), + ("py:obj", "qbittorrentapi._attrdict.V"), + ("py:obj", "qbittorrentapi.definitions.K"), + ("py:obj", "qbittorrentapi.definitions.V"), +] + +autodoc_type_aliases = {"JsonValueT": "qbittorrentapi.definitions.JsonValueT"} +add_module_names = False +autodoc_typehints_format = "short" +python_use_unqualified_type_names = True +python_use_unqualified_names = True +typehints_fully_qualified = False +typehints_use_signature = False +typehints_use_signature_return = True +typehints_document_rtype = True + source_suffix = ".rst" # The master toctree document. @@ -80,13 +103,6 @@ html_theme = "furo" -# sphinx-autoapi -# extensions.append('autoapi.extension') -# autoapi_type = 'python' -# autoapi_dirs = ['../../qbittorrentapi'] -# autoapi_options = ['show-inheritance-diagram'] -# autoapi_ignore = ['*decorators*', '*exceptions*'] - # Add mappings intersphinx_mapping = { "python": ("https://docs.python.org/3", None), diff --git a/docs/source/spelling_wordlist b/docs/source/spelling_wordlist index 3aa40bf7a..36c2b1879 100644 --- a/docs/source/spelling_wordlist +++ b/docs/source/spelling_wordlist @@ -1,5 +1,7 @@ Attrs +Auth bc +boolean bt casted Curran @@ -12,10 +14,14 @@ hostname instantiation iterable kwargs +MitM namespace namespaces qBittorrent +pem Reannounce +ResponseT +str untagged Untrusted untrusted diff --git a/pyproject.toml b/pyproject.toml index 745f67cf8..01bff1d31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ exclude_lines = [ "raise NotImplementedError", "if 0:", "if __name__ == .__main__.:", + "if TYPE_CHECKING:", ] [tool.coverage.html] diff --git a/setup.cfg b/setup.cfg index d57b16258..3f0162853 100644 --- a/setup.cfg +++ b/setup.cfg @@ -67,6 +67,7 @@ dev = sphinx == 7.2.6; python_version >= "3.9" sphinx-copybutton == 0.5.2 sphinxcontrib-spelling == 8.0.0 + sphinx-autodoc-typehints == 1.24.0 tox == 4.11.3 twine == 4.0.2 types-requests == 2.31.0.5 diff --git a/src/qbittorrentapi/_attrdict.py b/src/qbittorrentapi/_attrdict.py index ebded7548..4b25f3c14 100644 --- a/src/qbittorrentapi/_attrdict.py +++ b/src/qbittorrentapi/_attrdict.py @@ -25,18 +25,28 @@ AttrDict finally broke with Python 3.10 since abstract base classes can no longer be imported from collections but should use collections.abc instead. Since AttrDict is abandoned, I've consolidated the code here for future use. -AttrMap and AttrDefault are left for posterity but commented out. +AttrMap and AttrDefault were removed altogether. """ +from __future__ import annotations -from abc import ABCMeta +from abc import ABC from abc import abstractmethod -from collections.abc import Mapping -from collections.abc import MutableMapping -from collections.abc import Sequence -from re import match as re_match +from re import compile as re_compile +from typing import Any +from typing import Dict +from typing import Mapping +from typing import MutableMapping +from typing import Sequence +from typing import TypeVar +K = TypeVar("K") +V = TypeVar("V") +T = TypeVar("T") -def merge(left, right): +VALID_KEY_RE = re_compile(r"^[A-Za-z][A-Za-z0-9_]*$") + + +def merge(left: Mapping[K, V], right: Mapping[K, V]) -> dict[K, V]: """ Merge two mappings objects together, combining overlapping Mappings, and favoring right-values. @@ -64,17 +74,17 @@ def merge(left, right): left_value = left[key] right_value = right[key] - if isinstance(left_value, Mapping) and isinstance( - right_value, Mapping - ): # recursive merge - merged[key] = merge(left_value, right_value) - else: # overwrite with right value + # recursive merge + if isinstance(left_value, Mapping) and isinstance(right_value, Mapping): + merged[key] = merge(left_value, right_value) # type: ignore + # overwrite with right value + else: merged[key] = right_value return merged -class Attr(Mapping, metaclass=ABCMeta): +class Attr(Mapping[K, V], ABC): """ A ``mixin`` class for a mapping that allows for attribute-style access of values. @@ -98,12 +108,13 @@ class Attr(Mapping, metaclass=ABCMeta): """ @abstractmethod - def _configuration(self): + def _configuration(self) -> Any: """All required state for building a new instance with the same settings as the current object.""" @classmethod - def _constructor(cls, mapping, configuration): + @abstractmethod + def _constructor(cls, mapping: Mapping[K, V], configuration: Any) -> Attr[K, V]: """ A standardized constructor used internally by Attr. @@ -112,9 +123,8 @@ def _constructor(cls, mapping, configuration): that will allow nested assignment (e.g., attr.foo.bar = baz) configuration: The return value of Attr._configuration """ - raise NotImplementedError("You need to implement this") - def __call__(self, key): + def __call__(self, key: K) -> Attr[K, V]: """ Dynamically access a key-value pair. @@ -125,25 +135,21 @@ def __call__(self, key): """ if key not in self: raise AttributeError( - "'{cls} instance has no attribute '{name}'".format( - cls=self.__class__.__name__, name=key - ) + f"'{self.__class__.__name__} instance has no attribute '{key}'" ) return self._build(self[key]) - def __getattr__(self, key): + def __getattr__(self, key: Any) -> Any: """Access an item as an attribute.""" if key not in self or not self._valid_name(key): raise AttributeError( - "'{cls}' instance has no attribute '{name}'".format( - cls=self.__class__.__name__, name=key - ) + f"'{self.__class__.__name__}' instance has no attribute '{key}'" ) return self._build(self[key]) - def __add__(self, other): + def __add__(self, other: Mapping[K, V]) -> Attr[K, V]: """ Add a mapping to this Attr, creating a new, merged Attr. @@ -156,7 +162,7 @@ def __add__(self, other): return self._constructor(merge(self, other), self._configuration()) - def __radd__(self, other): + def __radd__(self, other: Mapping[K, V]) -> Attr[K, V]: """ Add this Attr to a mapping, creating a new, merged Attr. @@ -169,7 +175,7 @@ def __radd__(self, other): return self._constructor(merge(other, self), self._configuration()) - def _build(self, obj): + def _build(self, obj: Any) -> Attr[K, V]: """ Conditionally convert an object to allow for recursive mapping access. @@ -184,14 +190,13 @@ def _build(self, obj): obj = self._constructor(obj, self._configuration()) elif isinstance(obj, Sequence) and not isinstance(obj, (str, bytes)): sequence_type = getattr(self, "_sequence_type", None) - if sequence_type: obj = sequence_type(self._build(element) for element in obj) - return obj + return obj # type: ignore @classmethod - def _valid_name(cls, key): + def _valid_name(cls, key: Any) -> bool: """ Check whether a key is a valid attribute name. @@ -204,21 +209,20 @@ def _valid_name(cls, key): """ return ( isinstance(key, str) - and re_match("^[A-Za-z][A-Za-z0-9_]*$", key) and not hasattr(cls, key) + and VALID_KEY_RE.match(key) is not None ) -class MutableAttr(Attr, MutableMapping, metaclass=ABCMeta): - """A ``mixin`` class for a mapping that allows for attribute-style access of - values.""" +class MutableAttr(Attr[str, V], MutableMapping[str, V], ABC): + """A ``mixin`` mapping class that allows for attribute-style access of values.""" - def _setattr(self, key, value): + def _setattr(self, key: str, value: Any) -> None: """Add an attribute to the object, without attempting to add it as a key to the mapping.""" super().__setattr__(key, value) - def __setattr__(self, key, value): + def __setattr__(self, key: str, value: V) -> None: """ Add an attribute. @@ -231,17 +235,15 @@ def __setattr__(self, key, value): super().__setattr__(key, value) else: raise TypeError( - "'{cls}' does not allow attribute creation.".format( - cls=self.__class__.__name__ - ) + f"'{self.__class__.__name__}' does not allow attribute creation." ) - def _delattr(self, key): + def _delattr(self, key: str) -> None: """Delete an attribute from the object, without attempting to remove it from the mapping.""" super().__delattr__(key) - def __delattr__(self, key, force=False): + def __delattr__(self, key: str, force: bool = False) -> None: """ Delete an attribute. @@ -253,248 +255,46 @@ def __delattr__(self, key, force=False): super().__delattr__(key) else: raise TypeError( - "'{cls}' does not allow attribute deletion.".format( - cls=self.__class__.__name__ - ) + f"'{self.__class__.__name__}' does not allow attribute deletion." ) -class AttrDict(dict, MutableAttr): +class AttrDict(Dict[str, V], MutableAttr[V]): """A dict that implements MutableAttr.""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + _sequence_type: type + _allow_invalid_attributes: bool + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) self._setattr("_sequence_type", tuple) self._setattr("_allow_invalid_attributes", False) - def _configuration(self): + def _configuration(self) -> type: """The configuration for an attrmap instance.""" return self._sequence_type - def __getstate__(self): + def __getstate__(self) -> tuple[Mapping[str, V], type, bool]: """Serialize the object.""" return self.copy(), self._sequence_type, self._allow_invalid_attributes - def __setstate__(self, state): + def __setstate__(self, state: tuple[Mapping[str, V], type, bool]) -> None: """Deserialize the object.""" mapping, sequence_type, allow_invalid_attributes = state self.update(mapping) self._setattr("_sequence_type", sequence_type) self._setattr("_allow_invalid_attributes", allow_invalid_attributes) - def __repr__(self): - return f"AttrDict({super().__repr__()})" + def __repr__(self) -> str: + return f"{self.__class__.__name__}({super().__repr__()})" @classmethod - def _constructor(cls, mapping, configuration): + def _constructor( + cls, + mapping: Mapping[str, V], + configuration: type, + ) -> AttrDict[V]: """A standardized constructor.""" attr = cls(mapping) attr._setattr("_sequence_type", configuration) - return attr - - -# class AttrMap(MutableAttr): -# """ -# An implementation of MutableAttr. -# """ -# def __init__(self, items=None, sequence_type=tuple): -# if items is None: -# items = {} -# elif not isinstance(items, Mapping): -# items = dict(items) -# -# self._setattr('_sequence_type', sequence_type) -# self._setattr('_mapping', items) -# self._setattr('_allow_invalid_attributes', False) -# -# def _configuration(self): -# """ -# The configuration for an attrmap instance. -# """ -# return self._sequence_type -# -# def __getitem__(self, key): -# """ -# Access a value associated with a key. -# """ -# return self._mapping[key] -# -# def __setitem__(self, key, value): -# """ -# Add a key-value pair to the instance. -# """ -# self._mapping[key] = value -# -# def __delitem__(self, key): -# """ -# Delete a key-value pair -# """ -# del self._mapping[key] -# -# def __len__(self): -# """ -# Check the length of the mapping. -# """ -# return len(self._mapping) -# -# def __iter__(self): -# """ -# Iterated through the keys. -# """ -# return iter(self._mapping) -# -# def __repr__(self): -# """ -# Return a string representation of the object. -# """ -# # sequence type seems like more trouble than it is worth. -# # If people want full serialization, they can pickle, and in -# # 99% of cases, sequence_type won't change anyway -# return six.u("AttrMap({mapping})").format(mapping=repr(self._mapping)) -# -# def __getstate__(self): -# """ -# Serialize the object. -# """ -# return ( -# self._mapping, -# self._sequence_type, -# self._allow_invalid_attributes -# ) -# -# def __setstate__(self, state): -# """ -# Deserialize the object. -# """ -# mapping, sequence_type, allow_invalid_attributes = state -# self._setattr('_mapping', mapping) -# self._setattr('_sequence_type', sequence_type) -# self._setattr('_allow_invalid_attributes', allow_invalid_attributes) -# -# @classmethod -# def _constructor(cls, mapping, configuration): -# """ -# A standardized constructor. -# """ -# return cls(mapping, sequence_type=configuration) - - -# class AttrDefault(MutableAttr): -# """ -# An implementation of MutableAttr with defaultdict support -# """ -# def __init__(self, default_factory=None, items=None, sequence_type=tuple, -# pass_key=False): -# if items is None: -# items = {} -# elif not isinstance(items, Mapping): -# items = dict(items) -# -# self._setattr('_default_factory', default_factory) -# self._setattr('_mapping', items) -# self._setattr('_sequence_type', sequence_type) -# self._setattr('_pass_key', pass_key) -# self._setattr('_allow_invalid_attributes', False) -# -# def _configuration(self): -# """ -# The configuration for a AttrDefault instance -# """ -# return self._sequence_type, self._default_factory, self._pass_key -# -# def __getitem__(self, key): -# """ -# Access a value associated with a key. -# -# Note: values returned will not be wrapped, even if recursive -# is True. -# """ -# if key in self._mapping: -# return self._mapping[key] -# elif self._default_factory is not None: -# return self.__missing__(key) -# -# raise KeyError(key) -# -# def __setitem__(self, key, value): -# """ -# Add a key-value pair to the instance. -# """ -# self._mapping[key] = value -# -# def __delitem__(self, key): -# """ -# Delete a key-value pair -# """ -# del self._mapping[key] -# -# def __len__(self): -# """ -# Check the length of the mapping. -# """ -# return len(self._mapping) -# -# def __iter__(self): -# """ -# Iterated through the keys. -# """ -# return iter(self._mapping) -# -# def __missing__(self, key): -# """ -# Add a missing element. -# """ -# if self._pass_key: -# self[key] = value = self._default_factory(key) -# else: -# self[key] = value = self._default_factory() -# -# return value -# -# def __repr__(self): -# """ -# Return a string representation of the object. -# """ -# return six.u( -# "AttrDefault({default_factory}, {pass_key}, {mapping})" -# ).format( -# default_factory=repr(self._default_factory), -# pass_key=repr(self._pass_key), -# mapping=repr(self._mapping), -# ) -# -# def __getstate__(self): -# """ -# Serialize the object. -# """ -# return ( -# self._default_factory, -# self._mapping, -# self._sequence_type, -# self._pass_key, -# self._allow_invalid_attributes, -# ) -# -# def __setstate__(self, state): -# """ -# Deserialize the object. -# """ -# (default_factory, mapping, sequence_type, pass_key, -# allow_invalid_attributes) = state -# -# self._setattr('_default_factory', default_factory) -# self._setattr('_mapping', mapping) -# self._setattr('_sequence_type', sequence_type) -# self._setattr('_pass_key', pass_key) -# self._setattr('_allow_invalid_attributes', allow_invalid_attributes) -# -# @classmethod -# def _constructor(cls, mapping, configuration): -# """ -# A standardized constructor. -# """ -# sequence_type, default_factory, pass_key = configuration -# return cls(default_factory, mapping, sequence_type=sequence_type, -# pass_key=pass_key) diff --git a/src/qbittorrentapi/_attrdict.pyi b/src/qbittorrentapi/_attrdict.pyi deleted file mode 100644 index e6e7dd1d5..000000000 --- a/src/qbittorrentapi/_attrdict.pyi +++ /dev/null @@ -1,37 +0,0 @@ -from abc import ABCMeta -from typing import Any -from typing import Dict -from typing import Mapping -from typing import MutableMapping -from typing import Text -from typing import TypeVar - -K = TypeVar("K") -KOther = TypeVar("KOther") -V = TypeVar("V") -VOther = TypeVar("VOther") -KwargsT = Any - -def merge( - left: Mapping[K, V], - right: Mapping[KOther, VOther], -) -> Dict[K | KOther, V | VOther]: ... - -class Attr(Mapping[K, V], metaclass=ABCMeta): - def __call__(self, key: K) -> V: ... - def __getattr__(self, key: Text) -> V: ... - def __add__( - self, - other: Mapping[KOther, VOther], - ) -> Attr[K | KOther, V | VOther]: ... - def __radd__( - self, - other: Mapping[KOther, VOther], - ) -> Attr[K | KOther, V | VOther]: ... - -class MutableAttr(Attr[K, V], MutableMapping[K, V], metaclass=ABCMeta): - def __setattr__(self, key: Text, value: V) -> None: ... - def __delattr__(self, key: Text, force: bool = ...) -> None: ... - -class AttrDict(Dict[K, V], MutableAttr[K, V]): - def __init__(self, *args: Any, **kwargs: KwargsT) -> None: ... diff --git a/src/qbittorrentapi/_types.pyi b/src/qbittorrentapi/_types.pyi deleted file mode 100644 index 56fbc62db..000000000 --- a/src/qbittorrentapi/_types.pyi +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Any -from typing import Iterable -from typing import Mapping -from typing import MutableMapping -from typing import Sequence -from typing import Text -from typing import Tuple -from typing import Union - -from qbittorrentapi.definitions import Dictionary - -KwargsT = Any -# Type to define JSON -JsonValueT = Union[ - None, - int, - Text, - bool, - Sequence[JsonValueT], - Mapping[Text, JsonValueT], -] -JsonDictionaryT = Dictionary[Text, JsonValueT] -# Type for inputs to build a Dictionary -DictInputT = Mapping[Text, JsonValueT] -DictMutableInputT = MutableMapping[Text, JsonValueT] -# Type for inputs to build a List -ListInputT = Iterable[Mapping[Text, JsonValueT]] -# Type for `files` in requests.get()/post() -FilesToSendT = Mapping[Text, bytes | Tuple[Text, bytes]] diff --git a/src/qbittorrentapi/_version_support.py b/src/qbittorrentapi/_version_support.py index 8e88af899..52491252c 100644 --- a/src/qbittorrentapi/_version_support.py +++ b/src/qbittorrentapi/_version_support.py @@ -1,8 +1,12 @@ +from __future__ import annotations + from functools import lru_cache +from typing import Final +from typing import Literal -from packaging.version import Version as _Version +import packaging.version -APP_VERSION_2_API_VERSION_MAP = { +APP_VERSION_2_API_VERSION_MAP: dict[str, str] = { "v4.1.0": "2.0", "v4.1.1": "2.0.1", "v4.1.2": "2.0.2", @@ -51,14 +55,14 @@ "v4.5.5": "2.8.19", } -MOST_RECENT_SUPPORTED_APP_VERSION = "v4.5.5" -MOST_RECENT_SUPPORTED_API_VERSION = "2.8.19" +MOST_RECENT_SUPPORTED_APP_VERSION: Final[Literal["v4.5.5"]] = "v4.5.5" +MOST_RECENT_SUPPORTED_API_VERSION: Final[Literal["2.8.19"]] = "2.8.19" @lru_cache(maxsize=None) -def v(version): +def v(version: str) -> packaging.version.Version: """Caching version parser.""" - return _Version(version) + return packaging.version.Version(version) class Version: @@ -72,25 +76,25 @@ class Version: notable exceptions. """ - _supported_app_versions = None - _supported_api_versions = None + _supported_app_versions: set[str] | None = None + _supported_api_versions: set[str] | None = None @classmethod - def supported_app_versions(cls): + def supported_app_versions(cls) -> set[str]: """Set of all supported qBittorrent application versions.""" if cls._supported_app_versions is None: cls._supported_app_versions = set(APP_VERSION_2_API_VERSION_MAP.keys()) return cls._supported_app_versions @classmethod - def supported_api_versions(cls): + def supported_api_versions(cls) -> set[str]: """Set of all supported qBittorrent Web API versions.""" if cls._supported_api_versions is None: cls._supported_api_versions = set(APP_VERSION_2_API_VERSION_MAP.values()) return cls._supported_api_versions @classmethod - def is_app_version_supported(cls, app_version): + def is_app_version_supported(cls, app_version: str) -> bool: """ Returns whether a version of the qBittorrent application is fully supported by this API client. @@ -104,7 +108,7 @@ def is_app_version_supported(cls, app_version): return app_version in cls.supported_app_versions() @classmethod - def is_api_version_supported(cls, api_version): + def is_api_version_supported(cls, api_version: str) -> bool: """ Returns whether a version of the qBittorrent Web API is fully supported by this API client. @@ -118,13 +122,11 @@ def is_api_version_supported(cls, api_version): return api_version in Version.supported_api_versions() @classmethod - def latest_supported_app_version(cls): - """Returns the most recent version of qBittorrent application that is fully - supported.""" + def latest_supported_app_version(cls) -> str: + """Returns the most recent version of qBittorrent that is supported.""" return MOST_RECENT_SUPPORTED_APP_VERSION @classmethod - def latest_supported_api_version(cls): - """Returns the most recent version of qBittorrent Web API that is fully - supported.""" + def latest_supported_api_version(cls) -> str: + """Returns the most recent version of qBittorrent Web API that is supported.""" return MOST_RECENT_SUPPORTED_API_VERSION diff --git a/src/qbittorrentapi/_version_support.pyi b/src/qbittorrentapi/_version_support.pyi deleted file mode 100644 index 1ad4202e5..000000000 --- a/src/qbittorrentapi/_version_support.pyi +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Dict -from typing import Optional -from typing import Set -from typing import Text - -from packaging.version import Version as _Version # type: ignore - -MOST_RECENT_SUPPORTED_APP_VERSION: Text -MOST_RECENT_SUPPORTED_API_VERSION: Text -APP_VERSION_2_API_VERSION_MAP: Dict[Text, Text] - -def v(version: Text) -> _Version: ... # type: ignore - -class Version: - _supported_app_versions: Optional[Set[str]] = None - _supported_api_versions: Optional[Set[str]] = None - @classmethod - def supported_app_versions(cls) -> Set[str]: ... - @classmethod - def supported_api_versions(cls) -> Set[str]: ... - @classmethod - def is_app_version_supported(cls, app_version: Text) -> bool: ... - @classmethod - def is_api_version_supported(cls, api_version: Text) -> bool: ... - @classmethod - def latest_supported_app_version(cls) -> str: ... - @classmethod - def latest_supported_api_version(cls) -> str: ... diff --git a/src/qbittorrentapi/app.py b/src/qbittorrentapi/app.py index f0c8ef63c..759a56a0a 100644 --- a/src/qbittorrentapi/app.py +++ b/src/qbittorrentapi/app.py @@ -1,133 +1,62 @@ +from __future__ import annotations + +from functools import wraps from json import dumps +from logging import Logger from logging import getLogger +from typing import Any +from typing import Iterable +from typing import Mapping +from typing import Union from qbittorrentapi.auth import AuthAPIMixIn -from qbittorrentapi.decorators import alias -from qbittorrentapi.decorators import aliased -from qbittorrentapi.decorators import endpoint_introduced -from qbittorrentapi.decorators import login_required +from qbittorrentapi.definitions import APIKwargsT from qbittorrentapi.definitions import APINames from qbittorrentapi.definitions import ClientCache from qbittorrentapi.definitions import Dictionary +from qbittorrentapi.definitions import JsonValueT from qbittorrentapi.definitions import List from qbittorrentapi.definitions import ListEntry +from qbittorrentapi.definitions import ListInputT -logger = getLogger(__name__) - +logger: Logger = getLogger(__name__) -class ApplicationPreferencesDictionary(Dictionary): - """Response for :meth:`~AppAPIMixIn.app_preferences`""" +class ApplicationPreferencesDictionary(Dictionary[JsonValueT]): + """ + Response for :meth:`~AppAPIMixIn.app_preferences` -class BuildInfoDictionary(Dictionary): - """Response for :meth:`~AppAPIMixIn.app_build_info`""" + Definition: ``_ + """ # noqa: E501 -class NetworkInterfaceList(List): - """Response for :meth:`~AppAPIMixIn.app_network_interface_list`""" +class BuildInfoDictionary(Dictionary[Union[str, int]]): + """ + Response for :meth:`~AppAPIMixIn.app_build_info` - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=NetworkInterface, client=client) + Definition: ``_ + """ # noqa: E501 class NetworkInterface(ListEntry): """Item in :class:`NetworkInterfaceList`""" -class NetworkInterfaceAddressList(List): - """Response for :meth:`~AppAPIMixIn.app_network_interface_address_list`""" - - def __init__(self, list_entries, client=None): - super().__init__(list_entries) - - -@aliased -class Application(ClientCache): - """ - Allows interaction with ``Application`` API endpoints. - - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> # these are all the same attributes that are available as named in the - >>> # endpoints or the more pythonic names in Client (with or without 'app_' prepended) - >>> webapiVersion = client.application.webapiVersion - >>> web_api_version = client.application.web_api_version - >>> app_web_api_version = client.application.web_api_version - >>> # access and set preferences as attributes - >>> is_dht_enabled = client.application.preferences.dht - >>> # supports sending a just subset of preferences to update - >>> client.application.preferences = dict(dht=(not is_dht_enabled)) - >>> prefs = client.application.preferences - >>> prefs['web_ui_clickjacking_protection_enabled'] = True - >>> client.app.preferences = prefs - >>> - >>> client.application.shutdown() - """ - - @property - def version(self): - """Implements :meth:`~AppAPIMixIn.app_version`""" - return self._client.app_version() - - @property - def web_api_version(self): - """Implements :meth:`~AppAPIMixIn.app_web_api_version`""" - return self._client.app_web_api_version() - - webapiVersion = web_api_version - - @property - def build_info(self): - """Implements :meth:`~AppAPIMixIn.app_build_info`""" - return self._client.app_build_info() - - buildInfo = build_info - - def shutdown(self): - """Implements :meth:`~AppAPIMixIn.app_shutdown`""" - return self._client.app_shutdown() - - @property - def preferences(self): - """Implements :meth:`~AppAPIMixIn.app_preferences` and - :meth:`~AppAPIMixIn.app_set_preferences`""" - return self._client.app_preferences() - - @preferences.setter - def preferences(self, value): - """Implements :meth:`~AppAPIMixIn.app_set_preferences`""" - self.set_preferences(prefs=value) - - @alias("setPreferences") - def set_preferences(self, prefs=None, **kwargs): - """Implements :meth:`~AppAPIMixIn.app_set_preferences`""" - return self._client.app_set_preferences(prefs=prefs, **kwargs) - - @property - def default_save_path(self): - """Implements :meth:`~AppAPIMixIn.app_default_save_path`""" - return self._client.app_default_save_path() +class NetworkInterfaceList(List[NetworkInterface]): + """Response for :meth:`~AppAPIMixIn.app_network_interface_list`""" - defaultSavePath = default_save_path + def __init__(self, list_entries: ListInputT, client: AppAPIMixIn | None = None): + super().__init__(list_entries, entry_class=NetworkInterface) - @property - def network_interface_list(self): - """Implements :meth:`~AppAPIMixIn.app_network_interface_list`""" - return self._client.app_network_interface_list() - networkInterfaceList = network_interface_list +# only API response that's a list of strings...so just ignore the typing for now +class NetworkInterfaceAddressList(List[str]): # type: ignore + """Response for :meth:`~AppAPIMixIn.app_network_interface_address_list`""" - @alias("networkInterfaceAddressList") - def network_interface_address_list(self, interface_name="", **kwargs): - """Implements :meth:`~AppAPIMixIn.app_network_interface_list`""" - return self._client.app_network_interface_address_list( - interface_name=interface_name, - **kwargs, - ) + def __init__(self, list_entries: Iterable[str], client: AppAPIMixIn | None = None): + super().__init__(list_entries) # type: ignore -@aliased class AppAPIMixIn(AuthAPIMixIn): """ Implementation of all ``Application`` API methods. @@ -140,12 +69,11 @@ class AppAPIMixIn(AuthAPIMixIn): """ @property - def app(self): + def app(self) -> Application: """ Allows for transparent interaction with Application endpoints. See Application class for usage. - :return: Application object """ if self._application is None: self._application = Application(client=self) @@ -153,75 +81,64 @@ def app(self): application = app - @login_required - def app_version(self, **kwargs): - """ - Retrieve application version. - - :return: string - """ - return self._get( - _name=APINames.Application, _method="version", response_class=str, **kwargs + def app_version(self, **kwargs: APIKwargsT) -> str: + """qBittorrent application version.""" + return self._get_cast( + _name=APINames.Application, + _method="version", + response_class=str, + **kwargs, ) - @alias("app_webapiVersion") - @login_required - def app_web_api_version(self, **kwargs): - """ - Retrieve web API version. - - :return: string - """ - return self._MOCK_WEB_API_VERSION or self._get( + def app_web_api_version(self, **kwargs: APIKwargsT) -> str: + """qBittorrent Web API version.""" + return self._get_cast( _name=APINames.Application, _method="webapiVersion", response_class=str, **kwargs, ) - @alias("app_buildInfo") - @endpoint_introduced("2.3", "app/buildInfo") - @login_required - def app_build_info(self, **kwargs): + app_webapiVersion = app_web_api_version + + def app_build_info(self, **kwargs: APIKwargsT) -> BuildInfoDictionary: """ - Retrieve build info. + qBittorrent build info. - :return: :class:`BuildInfoDictionary` - ``_ - """ # noqa: E501 - return self._get( + This method was introduced with qBittorrent v4.2.0 (Web API v2.3). + """ + return self._get_cast( _name=APINames.Application, _method="buildInfo", response_class=BuildInfoDictionary, + version_introduced="2.3", **kwargs, ) - @login_required - def app_shutdown(self, **kwargs): + app_buildInfo = app_build_info + + def app_shutdown(self, **kwargs: APIKwargsT) -> None: """Shutdown qBittorrent.""" self._post(_name=APINames.Application, _method="shutdown", **kwargs) - @login_required - def app_preferences(self, **kwargs): - """ - Retrieve qBittorrent application preferences. - - :return: :class:`ApplicationPreferencesDictionary` - ``_ - """ # noqa: E501 - return self._get( + def app_preferences(self, **kwargs: APIKwargsT) -> ApplicationPreferencesDictionary: + """Retrieve qBittorrent application preferences.""" + return self._get_cast( _name=APINames.Application, _method="preferences", response_class=ApplicationPreferencesDictionary, **kwargs, ) - @alias("app_setPreferences") - @login_required - def app_set_preferences(self, prefs=None, **kwargs): + def app_set_preferences( + self, + prefs: Mapping[str, Any] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Set one or more preferences in qBittorrent application. :param prefs: dictionary of preferences to set - :return: None """ data = {"json": dumps(prefs, separators=(",", ":"))} self._post( @@ -231,52 +148,149 @@ def app_set_preferences(self, prefs=None, **kwargs): **kwargs, ) - @alias("app_defaultSavePath") - @login_required - def app_default_save_path(self, **kwargs): - """ - Retrieves the default path for where torrents are saved. + app_setPreferences = app_set_preferences - :return: string - """ - return self._get( + def app_default_save_path(self, **kwargs: APIKwargsT) -> str: + """The default path where torrents are saved.""" + return self._get_cast( _name=APINames.Application, _method="defaultSavePath", response_class=str, **kwargs, ) - @alias("app_networkInterfaceList") - @endpoint_introduced("2.3", "app/networkInterfaceList") - @login_required - def app_network_interface_list(self, **kwargs): + app_defaultSavePath = app_default_save_path + + def app_network_interface_list(self, **kwargs: APIKwargsT) -> NetworkInterfaceList: """ - Retrieves the list of network interfaces. + The list of network interfaces on the host. - :return: :class:`NetworkInterfaceList` + This method was introduced with qBittorrent v4.2.0 (Web API v2.3). """ - return self._get( + return self._get_cast( _name=APINames.Application, _method="networkInterfaceList", response_class=NetworkInterfaceList, + version_introduced="2.3", **kwargs, ) - @alias("app_networkInterfaceAddressList") - @endpoint_introduced("2.3", "app/networkInterfaceAddressList") - @login_required - def app_network_interface_address_list(self, interface_name="", **kwargs): + app_networkInterfaceList = app_network_interface_list + + def app_network_interface_address_list( + self, + interface_name: str = "", + **kwargs: APIKwargsT, + ) -> NetworkInterfaceAddressList: """ - Retrieves the addresses for a network interface; omit name for all addresses. + The addresses for a network interface; omit name for all addresses. + + This method was introduced with qBittorrent v4.2.0 (Web API v2.3). :param interface_name: Name of interface to retrieve addresses for - :return: :class:`NetworkInterfaceAddressList` """ data = {"iface": interface_name} - return self._post( + return self._post_cast( _name=APINames.Application, _method="networkInterfaceAddressList", data=data, response_class=NetworkInterfaceAddressList, + version_introduced="2.3", + **kwargs, + ) + + app_networkInterfaceAddressList = app_network_interface_address_list + + +class Application(ClientCache[AppAPIMixIn]): + """ + Allows interaction with ``Application`` API endpoints. + + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> # these are all the same attributes that are available as named in the + >>> # endpoints or the more pythonic names in Client (with or without 'app_' prepended) + >>> webapiVersion = client.application.webapiVersion + >>> web_api_version = client.application.web_api_version + >>> app_web_api_version = client.application.web_api_version + >>> # access and set preferences as attributes + >>> is_dht_enabled = client.application.preferences.dht + >>> # supports sending a just subset of preferences to update + >>> client.application.preferences = dict(dht=(not is_dht_enabled)) + >>> prefs = client.application.preferences + >>> prefs['web_ui_clickjacking_protection_enabled'] = True + >>> client.app.preferences = prefs + >>> + >>> client.application.shutdown() + """ + + @property + @wraps(AppAPIMixIn.app_version) + def version(self) -> str: + return self._client.app_version() + + @property + @wraps(AppAPIMixIn.app_web_api_version) + def web_api_version(self) -> str: + return self._client.app_web_api_version() + + webapiVersion = web_api_version + + @property + @wraps(AppAPIMixIn.app_build_info) + def build_info(self) -> BuildInfoDictionary: + return self._client.app_build_info() + + buildInfo = build_info + + @wraps(AppAPIMixIn.app_shutdown) + def shutdown(self, **kwargs: APIKwargsT) -> None: + self._client.app_shutdown(**kwargs) + + @property + @wraps(AppAPIMixIn.app_preferences) + def preferences(self) -> ApplicationPreferencesDictionary: + return self._client.app_preferences() + + @preferences.setter + @wraps(AppAPIMixIn.app_set_preferences) + def preferences(self, value: Mapping[str, Any]) -> None: + self.set_preferences(prefs=value) + + @wraps(AppAPIMixIn.app_set_preferences) + def set_preferences( + self, + prefs: Mapping[str, Any] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.app_set_preferences(prefs=prefs, **kwargs) + + setPreferences = set_preferences + + @property + @wraps(AppAPIMixIn.app_default_save_path) + def default_save_path(self) -> str: + return self._client.app_default_save_path() + + defaultSavePath = default_save_path + + @property + @wraps(AppAPIMixIn.app_network_interface_list) + def network_interface_list(self) -> NetworkInterfaceList: + return self._client.app_network_interface_list() + + networkInterfaceList = network_interface_list + + @wraps(AppAPIMixIn.app_network_interface_list) + def network_interface_address_list( + self, + interface_name: str = "", + **kwargs: APIKwargsT, + ) -> NetworkInterfaceAddressList: + return self._client.app_network_interface_address_list( + interface_name=interface_name, **kwargs, ) + + networkInterfaceAddressList = network_interface_address_list diff --git a/src/qbittorrentapi/app.pyi b/src/qbittorrentapi/app.pyi deleted file mode 100644 index 47467682b..000000000 --- a/src/qbittorrentapi/app.pyi +++ /dev/null @@ -1,96 +0,0 @@ -from logging import Logger -from typing import Mapping -from typing import Optional -from typing import Text - -import six - -from qbittorrentapi._types import JsonDictionaryT -from qbittorrentapi._types import JsonValueT -from qbittorrentapi._types import KwargsT -from qbittorrentapi._types import ListInputT -from qbittorrentapi.definitions import ClientCache -from qbittorrentapi.definitions import List -from qbittorrentapi.definitions import ListEntry -from qbittorrentapi.request import Request - -logger: Logger - -class ApplicationPreferencesDictionary(JsonDictionaryT): ... -class BuildInfoDictionary(JsonDictionaryT): ... -class NetworkInterface(ListEntry): ... - -class NetworkInterfaceList(List[NetworkInterface]): - def __init__( - self, list_entries: ListInputT, client: Optional[AppAPIMixIn] = None - ) -> None: ... - -class NetworkInterfaceAddressList(List[six.text_type]): - def __init__( - self, list_entries: ListInputT, client: Optional[AppAPIMixIn] = None - ) -> None: ... - -class Application(ClientCache): - @property - def version(self) -> Text: ... - @property - def web_api_version(self) -> Text: ... - @property - def webapiVersion(self) -> Text: ... - @property - def build_info(self) -> BuildInfoDictionary: ... - @property - def buildInfo(self) -> BuildInfoDictionary: ... - def shutdown(self) -> None: ... - @property - def preferences(self) -> ApplicationPreferencesDictionary: ... - @preferences.setter - def preferences(self, value: Mapping[Text, JsonValueT]) -> None: ... - def set_preferences( - self, - prefs: Optional[Mapping[Text, JsonValueT]] = None, - **kwargs: KwargsT, - ) -> None: ... - setPreferences = set_preferences - @property - def default_save_path(self) -> Text: ... - @property - def defaultSavePath(self) -> Text: ... - @property - def network_interface_list(self, **kwargs: KwargsT) -> NetworkInterfaceList: ... - @property - def networkInterfaceList(self, **kwargs: KwargsT) -> NetworkInterfaceList: ... - def network_interface_address_list( - self, interface_name: Optional[Text] = "", **kwargs: KwargsT - ) -> NetworkInterfaceAddressList: ... - networkInterfaceAddressList = network_interface_address_list - -class AppAPIMixIn(Request): - @property - def app(self) -> Application: ... - @property - def application(self) -> Application: ... - def app_version(self, **kwargs: KwargsT) -> str: ... - def app_web_api_version(self, **kwargs: KwargsT) -> str: ... - app_webapiVersion = app_web_api_version - def app_build_info(self, **kwargs: KwargsT) -> BuildInfoDictionary: ... - app_buildInfo = app_build_info - def app_shutdown(self, **kwargs: KwargsT) -> None: ... - def app_preferences( - self, - **kwargs: KwargsT, - ) -> ApplicationPreferencesDictionary: ... - def app_set_preferences( - self, - prefs: Optional[Mapping[Text, JsonValueT]] = None, - **kwargs: KwargsT, - ) -> None: ... - app_setPreferences = app_set_preferences - def app_default_save_path(self, **kwargs: KwargsT) -> str: ... - app_defaultSavePath = app_default_save_path - def app_network_interface_list(self, **kwargs: KwargsT) -> NetworkInterfaceList: ... - app_networkInterfaceList = app_network_interface_list - def app_network_interface_address_list( - self, interface_name: Optional[Text] = "", **kwargs: KwargsT - ) -> NetworkInterfaceAddressList: ... - app_networkInterfaceAddressList = app_network_interface_address_list diff --git a/src/qbittorrentapi/auth.py b/src/qbittorrentapi/auth.py index 2a7828b1a..fdc333f1e 100644 --- a/src/qbittorrentapi/auth.py +++ b/src/qbittorrentapi/auth.py @@ -1,40 +1,25 @@ +from __future__ import annotations + +from functools import wraps +from logging import Logger from logging import getLogger +from types import TracebackType +from typing import TYPE_CHECKING + +from requests import Response from qbittorrentapi import Version -from qbittorrentapi.decorators import login_required +from qbittorrentapi.definitions import APIKwargsT from qbittorrentapi.definitions import APINames from qbittorrentapi.definitions import ClientCache from qbittorrentapi.exceptions import LoginFailed from qbittorrentapi.exceptions import UnsupportedQbittorrentVersion from qbittorrentapi.request import Request -logger = getLogger(__name__) - +if TYPE_CHECKING: + from qbittorrentapi.client import Client -class Authorization(ClientCache): - """ - Allows interaction with the ``Authorization`` API endpoints. - - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> is_logged_in = client.auth.is_logged_in - >>> client.auth.log_in(username='admin', password='adminadmin') - >>> client.auth.log_out() - """ - - @property - def is_logged_in(self): - """Implements :meth:`~AuthAPIMixIn.is_logged_in`""" - return self._client.is_logged_in - - def log_in(self, username=None, password=None, **kwargs): - """Implements :meth:`~AuthAPIMixIn.auth_log_in`""" - return self._client.auth_log_in(username=username, password=password, **kwargs) - - def log_out(self, **kwargs): - """Implements :meth:`~AuthAPIMixIn.auth_log_out`""" - return self._client.auth_log_out(**kwargs) +logger: Logger = getLogger(__name__) class AuthAPIMixIn(Request): @@ -50,12 +35,8 @@ class AuthAPIMixIn(Request): """ @property - def auth(self): - """ - Allows for transparent interaction with Authorization endpoints. - - :return: Auth object - """ + def auth(self) -> Authorization: + """Allows for transparent interaction with Authorization endpoints.""" if self._authorization is None: self._authorization = Authorization(client=self) return self._authorization @@ -63,23 +44,33 @@ def auth(self): authorization = auth @property - def is_logged_in(self): + def is_logged_in(self) -> bool: """ Returns True if low-overhead API call succeeds; False otherwise. There isn't a reliable way to know if an existing session is still valid without attempting to use it. qBittorrent invalidates cookies when they expire. - :returns: True/False if current authorization cookie is accepted by qBittorrent + :returns: ``True``/``False`` if current authorization cookie is accepted by qBittorrent """ try: - self._post(_name=APINames.Application, _method="version") + # use _request_manager() directly so log in is not attempted + self._request_manager( + http_method="post", + api_namespace=APINames.Application, + api_method="version", + ) except Exception: return False else: return True - def auth_log_in(self, username=None, password=None, **kwargs): + def auth_log_in( + self, + username: str | None = None, + password: str | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Log in to qBittorrent host. @@ -88,18 +79,20 @@ def auth_log_in(self, username=None, password=None, **kwargs): :param username: username for qBittorrent client :param password: password for qBittorrent client - :return: None """ if username: self.username = username self._password = password or "" + # trigger a (re-)initialization in case this is a new instance of qBittorrent self._initialize_context() + creds = {"username": self.username, "password": self._password} - auth_response = self._post( + auth_response = self._post_cast( _name=APINames.Authorization, _method="login", data=creds, + response_class=Response, **kwargs, ) @@ -110,8 +103,8 @@ def auth_log_in(self, username=None, password=None, **kwargs): # check if the connected qBittorrent is fully supported by this Client yet if self._RAISE_UNSUPPORTEDVERSIONERROR: - app_version = self.app_version() - api_version = self.app_web_api_version() + app_version = self.app_version() # type: ignore + api_version = self.app_web_api_version() # type: ignore if not ( Version.is_api_version_supported(api_version) and Version.is_app_version_supported(app_version) @@ -122,36 +115,67 @@ def auth_log_in(self, username=None, password=None, **kwargs): ) @property - def _SID(self): + def _SID(self) -> str | None: """ Authorization session cookie from qBittorrent using default cookie name `SID`. - Backwards compatible for :meth:`~AuthAPIMixIn._session_cookie`. - :return: Auth cookie value from qBittorrent or None if one isn't already - acquired + Backwards compatible for :meth:`~AuthAPIMixIn._session_cookie`. """ return self._session_cookie() - def _session_cookie(self, cookie_name="SID"): + def _session_cookie(self, cookie_name: str = "SID") -> str | None: """ Authorization session cookie from qBittorrent. :param cookie_name: Name of the authorization cookie; configurable after v4.5.0. - :return: Auth cookie value from qBittorrent or None if one isn't already - acquired """ if self._http_session: - return self._http_session.cookies.get(cookie_name, None) + return self._http_session.cookies.get(cookie_name, None) # type: ignore return None - @login_required - def auth_log_out(self, **kwargs): + def auth_log_out(self, **kwargs: APIKwargsT) -> None: """End session with qBittorrent.""" self._post(_name=APINames.Authorization, _method="logout", **kwargs) - def __enter__(self): + def __enter__(self) -> Client: self.auth_log_in() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): + return self # type: ignore[return-value] + + def __exit__( + self, + exctype: type[BaseException] | None, + excinst: BaseException | None, + exctb: TracebackType | None, + ) -> None: self.auth_log_out() + + +class Authorization(ClientCache[AuthAPIMixIn]): + """ + Allows interaction with the ``Authorization`` API endpoints. + + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> is_logged_in = client.auth.is_logged_in + >>> client.auth.log_in(username='admin', password='adminadmin') + >>> client.auth.log_out() + """ + + @property + @wraps(AuthAPIMixIn.is_logged_in) + def is_logged_in(self) -> bool: + return self._client.is_logged_in + + @wraps(AuthAPIMixIn.auth_log_in) + def log_in( + self, + username: str | None = None, + password: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.auth_log_in(username=username, password=password, **kwargs) + + @wraps(AuthAPIMixIn.auth_log_out) + def log_out(self, **kwargs: APIKwargsT) -> None: + return self._client.auth_log_out(**kwargs) diff --git a/src/qbittorrentapi/auth.pyi b/src/qbittorrentapi/auth.pyi deleted file mode 100644 index c91ca1579..000000000 --- a/src/qbittorrentapi/auth.pyi +++ /dev/null @@ -1,47 +0,0 @@ -from logging import Logger -from types import TracebackType -from typing import Optional -from typing import Text -from typing import Type - -from qbittorrentapi._types import KwargsT -from qbittorrentapi.definitions import ClientCache -from qbittorrentapi.request import Request - -logger: Logger - -class Authorization(ClientCache): - @property - def is_logged_in(self) -> bool: ... - def log_in( - self, - username: Optional[Text] = None, - password: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - def log_out(self, **kwargs: KwargsT) -> None: ... - -class AuthAPIMixIn(Request): - @property - def auth(self) -> Authorization: ... - @property - def authorization(self) -> Authorization: ... - @property - def is_logged_in(self) -> bool: ... - def auth_log_in( - self, - username: Optional[Text] = None, - password: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - @property - def _SID(self) -> Optional[Text]: ... - def _session_cookie(self, cookie_name: Text = "SID") -> Optional[Text]: ... - def auth_log_out(self, **kwargs: KwargsT) -> None: ... - def __enter__(self) -> "AuthAPIMixIn": ... - def __exit__( - self, - exctype: Optional[Type[BaseException]], - excinst: Optional[BaseException], - exctb: Optional[TracebackType], - ) -> bool: ... diff --git a/src/qbittorrentapi/client.py b/src/qbittorrentapi/client.py index 656d85f4a..1eeb3c1e7 100644 --- a/src/qbittorrentapi/client.py +++ b/src/qbittorrentapi/client.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from typing import Any +from typing import Mapping + from qbittorrentapi.log import LogAPIMixIn from qbittorrentapi.rss import RSSAPIMixIn from qbittorrentapi.search import SearchAPIMixIn @@ -95,24 +100,23 @@ class Client( qBittorrent is not fully supported by this client. Defaults ``False``. :param DISABLE_LOGGING_DEBUG_OUTPUT: Turn off debug output from logging for this package as well as Requests & urllib3. - """ # noqa: E501 + """ def __init__( self, - host="", - port=None, - username=None, - password=None, - EXTRA_HEADERS=None, - REQUESTS_ARGS=None, - VERIFY_WEBUI_CERTIFICATE=True, - FORCE_SCHEME_FROM_HOST=False, - RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=False, - RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS=False, - VERBOSE_RESPONSE_LOGGING=False, - SIMPLE_RESPONSES=False, - DISABLE_LOGGING_DEBUG_OUTPUT=False, - **kwargs, + host: str = "", + port: str | int | None = None, + username: str | None = None, + password: str | None = None, + EXTRA_HEADERS: Mapping[str, str] | None = None, + REQUESTS_ARGS: Mapping[str, Any] | None = None, + VERIFY_WEBUI_CERTIFICATE: bool = True, + FORCE_SCHEME_FROM_HOST: bool = False, + RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: bool = False, + RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS: bool = False, + VERBOSE_RESPONSE_LOGGING: bool = False, + SIMPLE_RESPONSES: bool = False, + DISABLE_LOGGING_DEBUG_OUTPUT: bool = False, ): super().__init__( host=host, @@ -128,5 +132,4 @@ def __init__( VERBOSE_RESPONSE_LOGGING=VERBOSE_RESPONSE_LOGGING, SIMPLE_RESPONSES=SIMPLE_RESPONSES, DISABLE_LOGGING_DEBUG_OUTPUT=DISABLE_LOGGING_DEBUG_OUTPUT, - **kwargs, ) diff --git a/src/qbittorrentapi/client.pyi b/src/qbittorrentapi/client.pyi deleted file mode 100644 index 523c3db76..000000000 --- a/src/qbittorrentapi/client.pyi +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any -from typing import Mapping -from typing import Optional -from typing import Text - -from qbittorrentapi._types import KwargsT -from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.auth import AuthAPIMixIn -from qbittorrentapi.log import LogAPIMixIn -from qbittorrentapi.request import Request -from qbittorrentapi.rss import RSSAPIMixIn -from qbittorrentapi.search import SearchAPIMixIn -from qbittorrentapi.sync import SyncAPIMixIn -from qbittorrentapi.torrents import TorrentsAPIMixIn -from qbittorrentapi.transfer import TransferAPIMixIn - -class Client( - LogAPIMixIn, - SyncAPIMixIn, - TransferAPIMixIn, - TorrentsAPIMixIn, - RSSAPIMixIn, - SearchAPIMixIn, - AuthAPIMixIn, - AppAPIMixIn, - Request, -): - def __init__( - self, - host: Text = "", - port: Optional[Text | int] = None, - username: Optional[Text] = None, - password: Optional[Text] = None, - EXTRA_HEADERS: Optional[Mapping[Text, Text]] = None, - REQUESTS_ARGS: Optional[Mapping[Text, Any]] = None, - VERIFY_WEBUI_CERTIFICATE: Optional[bool] = True, - FORCE_SCHEME_FROM_HOST: Optional[bool] = False, - RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: Optional[ - bool - ] = False, - RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS: Optional[bool] = False, - VERBOSE_RESPONSE_LOGGING: Optional[bool] = False, - SIMPLE_RESPONSES: Optional[bool] = False, - DISABLE_LOGGING_DEBUG_OUTPUT: Optional[bool] = False, - **kwargs: KwargsT, - ) -> None: ... diff --git a/src/qbittorrentapi/decorators.py b/src/qbittorrentapi/decorators.py deleted file mode 100644 index bf469d080..000000000 --- a/src/qbittorrentapi/decorators.py +++ /dev/null @@ -1,184 +0,0 @@ -from functools import wraps -from logging import getLogger - -from qbittorrentapi._version_support import v -from qbittorrentapi.exceptions import HTTP403Error - -logger = getLogger(__name__) - - -class alias: - """ - Alias class that can be used as a decorator for making methods callable - through other names (or "aliases"). - Note: This decorator must be used inside an @aliased -decorated class. - For example, if you want to make the method shout() be also callable as - yell() and scream(), you can use alias like this: - - @alias('yell', 'scream') - def shout(message): - # .... - """ - - def __init__(self, *aliases): - self.aliases = set(aliases) - - def __call__(self, func): - """ - Method call wrapper. - - As this decorator has arguments, this method will only be called - once as a part of the decoration process, receiving only one - argument: the decorated function ('f'). As a result of this kind - of decorator, this method must return the callable that will - wrap the decorated function. - """ - func._aliases = self.aliases - return func - - -def aliased(aliased_class): - """ - Decorator function that *must* be used in combination with @alias decorator. This - class will make the magic happen! - - @aliased classes will have their aliased method (via @alias) actually aliased. - This method simply iterates over the member attributes of 'aliased_class' - seeking for those which have an '_aliases' attribute and then defines new - members in the class using those aliases as mere pointer functions to the - original ones. - - Usage: - @aliased - class MyClass(object): - @alias('coolMethod', 'myKinkyMethod') - def boring_method(): - # ... - - i = MyClass() - i.coolMethod() # equivalent to i.myKinkyMethod() and i.boring_method() - """ - original_methods = aliased_class.__dict__.copy() - for method in original_methods.values(): - if hasattr(method, "_aliases"): - # Add the aliases for 'method', but don't override any - # previously-defined attribute of 'aliased_class' - # noinspection PyProtectedMember - for method_alias in method._aliases - set(original_methods): - setattr(aliased_class, method_alias, method) - return aliased_class - - -def login_required(func): - """Ensure client is logged in when calling API methods.""" - - def get_requests_kwargs(**kwargs): - """Extract kwargs for performing transparent qBittorrent login.""" - return { - "requests_args": kwargs.get("requests_args"), - "requests_params": kwargs.get("requests_params"), - "headers": kwargs.get("headers"), - } - - @wraps(func) - def wrapper(client, *args, **kwargs): - """ - Attempt API call; 403 is returned if the login is expired or the user is banned. - - Attempt a login and if successful try the API call a final time. - """ - try: - return func(client, *args, **kwargs) - except HTTP403Error: - logger.debug("Login may have expired...attempting new login") - client.auth_log_in(**get_requests_kwargs(**kwargs)) - return func(client, *args, **kwargs) - - return wrapper - - -def handle_hashes(func): - """ - Normalize torrent hash arguments. - - Initial implementations of this client used 'hash' and 'hashes' as function - arguments for torrent hashes. Since 'hash' collides with an internal python name, - all arguments were updated to 'torrent_hash' or 'torrent_hashes'. Since both - versions of argument names remain respected, this decorator normalizes torrent hash - arguments into either 'torrent_hash' or 'torrent_hashes'. - """ - - @wraps(func) - def wrapper(client, *args, **kwargs): - if "torrent_hash" not in kwargs and "hash" in kwargs: - kwargs["torrent_hash"] = kwargs.pop("hash") - elif "torrent_hashes" not in kwargs and "hashes" in kwargs: - kwargs["torrent_hashes"] = kwargs.pop("hashes") - return func(client, *args, **kwargs) - - return wrapper - - -def check_for_raise(client, error_message): - """For any nonexistent endpoint, log the error and conditionally raise an - exception.""" - logger.debug(error_message) - if client._RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: - raise NotImplementedError(error_message) - - -def endpoint_introduced(version_introduced, endpoint): - """ - Prevent hitting an endpoint if the connected qBittorrent version doesn't support it. - - :param version_introduced: version endpoint was made available - :param endpoint: API endpoint (e.g. /torrents/categories) - """ - - def _inner(func): - @wraps(func) - def wrapper(client, *args, **kwargs): - # if the endpoint doesn't exist, return None or log an error / raise an Exception - if v(client.app_web_api_version()) < v(version_introduced): - error_message = ( - f"ERROR: Endpoint '{endpoint}' is Not Implemented in this version of qBittorrent. " - f"This endpoint is available starting in Web API v{version_introduced}." - ) - check_for_raise(client=client, error_message=error_message) - return None - - # send request to endpoint - return func(client, *args, **kwargs) - - return wrapper - - return _inner - - -def version_removed(version_obsoleted, endpoint): - """ - Prevent hitting an endpoint that was removed in a version older than the connected - qBittorrent. - - :param version_obsoleted: the Web API version the endpoint was removed - :param endpoint: name of the removed endpoint - """ - - def _inner(func): - @wraps(func) - def wrapper(client, *args, **kwargs): - # if the endpoint doesn't exist, return None or log an error / raise an Exception - if v(client.app_web_api_version()) >= v(version_obsoleted): - error_message = ( - f"ERROR: Endpoint '{endpoint}' is Not Implemented. " - f"This endpoint was removed in Web API v{version_obsoleted}." - ) - check_for_raise(client=client, error_message=error_message) - return None - - # send request to endpoint - return func(client, *args, **kwargs) - - return wrapper - - return _inner diff --git a/src/qbittorrentapi/decorators.pyi b/src/qbittorrentapi/decorators.pyi deleted file mode 100644 index 05029f7c9..000000000 --- a/src/qbittorrentapi/decorators.pyi +++ /dev/null @@ -1,37 +0,0 @@ -from logging import Logger -from typing import Callable -from typing import Set -from typing import Text -from typing import Type -from typing import TypeVar - -from qbittorrentapi.request import Request - -logger: Logger - -APIClassT = TypeVar("APIClassT", bound=Request) -APIReturnValueT = TypeVar("APIReturnValueT") - -class alias: - aliases: Set[Text] - def __init__(self, *aliases: Text) -> None: ... - def __call__( - self, func: Callable[..., APIReturnValueT] - ) -> Callable[..., APIReturnValueT]: ... - -def aliased(aliased_class: Type[APIClassT]) -> Type[APIClassT]: ... -def login_required( - func: Callable[..., APIReturnValueT] -) -> Callable[..., APIReturnValueT]: ... -def handle_hashes( - func: Callable[..., APIReturnValueT] -) -> Callable[..., APIReturnValueT]: ... -def endpoint_introduced( - version_introduced: Text, - endpoint: Text, -) -> Callable[[Callable[..., APIReturnValueT]], Callable[..., APIReturnValueT]]: ... -def version_removed( - version_obsoleted: Text, - endpoint: Text, -) -> Callable[[Callable[..., APIReturnValueT]], Callable[..., APIReturnValueT]]: ... -def check_for_raise(client: Request, error_message: Text) -> None: ... diff --git a/src/qbittorrentapi/definitions.py b/src/qbittorrentapi/definitions.py index e6a175a82..3a8214148 100644 --- a/src/qbittorrentapi/definitions.py +++ b/src/qbittorrentapi/definitions.py @@ -1,9 +1,47 @@ +from __future__ import annotations + +import sys from collections import UserList -from collections.abc import Mapping from enum import Enum +from typing import TYPE_CHECKING +from typing import Any +from typing import Generic +from typing import Iterable +from typing import Mapping +from typing import Sequence +from typing import Tuple +from typing import TypeVar +from typing import Union from qbittorrentapi._attrdict import AttrDict +if TYPE_CHECKING: + from qbittorrentapi import Request + +K = TypeVar("K") +V = TypeVar("V") +T = TypeVar("T") + +#: Type to define JSON. +JsonValueT = Union[ + None, + int, + str, + bool, + Sequence["JsonValueT"], + Mapping[str, "JsonValueT"], +] +#: Type ``Any`` for ``kwargs`` parameters for API methods. +APIKwargsT = Any +#: Type for this API Client. +ClientT = TypeVar("ClientT", bound="Request") +#: Type for entry in List from API. +ListEntryT = TypeVar("ListEntryT", bound="ListEntry") +#: 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]]] + class APINames(str, Enum): """ @@ -66,7 +104,7 @@ class TorrentState(str, Enum): UNKNOWN = "unknown" @property - def is_downloading(self): + def is_downloading(self) -> bool: """Returns ``True`` if the State is categorized as Downloading.""" return self in { TorrentState.DOWNLOADING, @@ -80,7 +118,7 @@ def is_downloading(self): } @property - def is_uploading(self): + def is_uploading(self) -> bool: """Returns ``True`` if the State is categorized as Uploading.""" return self in { TorrentState.UPLOADING, @@ -91,7 +129,7 @@ def is_uploading(self): } @property - def is_complete(self): + def is_complete(self) -> bool: """Returns ``True`` if the State is categorized as Complete.""" return self in { TorrentState.UPLOADING, @@ -103,7 +141,7 @@ def is_complete(self): } @property - def is_checking(self): + def is_checking(self) -> bool: """Returns ``True`` if the State is categorized as Checking.""" return self in { TorrentState.CHECKING_UPLOAD, @@ -112,12 +150,12 @@ def is_checking(self): } @property - def is_errored(self): + def is_errored(self) -> bool: """Returns ``True`` if the State is categorized as Errored.""" return self in {TorrentState.MISSING_FILES, TorrentState.ERROR} @property - def is_paused(self): + def is_paused(self) -> bool: """Returns ``True`` if the State is categorized as Paused.""" return self in {TorrentState.PAUSED_UPLOAD, TorrentState.PAUSED_DOWNLOAD} @@ -150,7 +188,7 @@ class TrackerStatus(int, Enum): NOT_WORKING = 4 @property - def display(self): + def display(self) -> str: """Returns a descriptive display value for status.""" return { TrackerStatus.DISABLED: "Disabled", @@ -161,48 +199,75 @@ def display(self): }[self] -class ClientCache: +class ClientCache(Generic[ClientT]): """ Caches the client. Subclass this for any object that needs access to the Client. """ - def __init__(self, *args, **kwargs): - self._client = kwargs.pop("client") + def __init__(self, *args: Any, client: ClientT, **kwargs: Any): + self._client = client super().__init__(*args, **kwargs) -class Dictionary(ClientCache, AttrDict): +class Dictionary(AttrDict[V]): """Base definition of dictionary-like objects returned from qBittorrent.""" - def __init__(self, data=None, client=None): - super().__init__(self._normalize(data or {}), client=client) + def __init__(self, data: Mapping[str, JsonValueT] | None = None, **kwargs: Any): + super().__init__(self._normalize(data or {})) # allows updating properties that aren't necessarily a part of the AttrDict self._setattr("_allow_invalid_attributes", True) @classmethod - def _normalize(cls, data): + def _normalize(cls, data: Mapping[str, V] | T) -> AttrDict[V] | T: """Iterate through a dict converting any nested dicts to AttrDicts.""" if isinstance(data, Mapping): return AttrDict({key: cls._normalize(value) for key, value in data.items()}) return data -class List(UserList): - """Base definition for list-like objects returned from qBittorrent.""" - - def __init__(self, list_entries=None, entry_class=None, client=None): - is_safe_cast = None not in {client, entry_class} - super().__init__( - [ - entry_class(data=entry, client=client) - if is_safe_cast and isinstance(entry, dict) - else entry - for entry in list_entries or () - ] - ) - - -class ListEntry(Dictionary): +# 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 ListEntry(Dictionary[JsonValueT]): """Base definition for objects within a list returned from qBittorrent.""" diff --git a/src/qbittorrentapi/definitions.pyi b/src/qbittorrentapi/definitions.pyi deleted file mode 100644 index 05f8da2a0..000000000 --- a/src/qbittorrentapi/definitions.pyi +++ /dev/null @@ -1,106 +0,0 @@ -from enum import Enum -from typing import Any -from typing import Literal -from typing import Optional -from typing import Text -from typing import Type -from typing import TypeVar -from typing import Union - -import six - -try: - from collections import UserList -except ImportError: - from UserList import UserList # type: ignore - -from qbittorrentapi._attrdict import AttrDict -from qbittorrentapi._types import DictInputT -from qbittorrentapi._types import JsonDictionaryT -from qbittorrentapi._types import KwargsT -from qbittorrentapi._types import ListInputT -from qbittorrentapi.client import Client -from qbittorrentapi.request import Request - -K = TypeVar("K") -V = TypeVar("V") -ListEntryT = TypeVar("ListEntryT", bound=Union[JsonDictionaryT, six.text_type]) - -class APINames(Enum): - Authorization: Literal["auth"] - Application: Literal["app"] - Log: Literal["log"] - Sync: Literal["sync"] - Transfer: Literal["transfer"] - Torrents: Literal["torrents"] - RSS: Literal["rss"] - Search: Literal["search"] - EMPTY: Literal[""] - -class TorrentState(Enum): - ERROR: Literal["error"] - MISSING_FILES: Literal["missingFiles"] - UPLOADING: Literal["uploading"] - PAUSED_UPLOAD: Literal["pausedUP"] - QUEUED_UPLOAD: Literal["queuedUP"] - STALLED_UPLOAD: Literal["stalledUP"] - CHECKING_UPLOAD: Literal["checkingUP"] - FORCED_UPLOAD: Literal["forcedUP"] - ALLOCATING: Literal["allocating"] - DOWNLOADING: Literal["downloading"] - METADATA_DOWNLOAD: Literal["metaDL"] - FORCED_METADATA_DOWNLOAD: Literal["forcedMetaDL"] - PAUSED_DOWNLOAD: Literal["pausedDL"] - QUEUED_DOWNLOAD: Literal["queuedDL"] - FORCED_DOWNLOAD: Literal["forcedDL"] - STALLED_DOWNLOAD: Literal["stalledDL"] - CHECKING_DOWNLOAD: Literal["checkingDL"] - CHECKING_RESUME_DATA: Literal["checkingResumeData"] - MOVING: Literal["moving"] - UNKNOWN: Literal["unknown"] - @property - def is_downloading(self) -> bool: ... - @property - def is_uploading(self) -> bool: ... - @property - def is_complete(self) -> bool: ... - @property - def is_checking(self) -> bool: ... - @property - def is_errored(self) -> bool: ... - @property - def is_paused(self) -> bool: ... - -TorrentStates = TorrentState - -class TrackerStatus(Enum): - DISABLED: Literal[0] - NOT_CONTACTED: Literal[1] - WORKING: Literal[2] - UPDATING: Literal[3] - NOT_WORKING: Literal[4] - @property - def display(self) -> Text: ... - -class ClientCache: - _client: Client - def __init__(self, *args: Any, client: Request, **kwargs: KwargsT) -> None: ... - -class Dictionary(ClientCache, AttrDict[K, V]): - def __init__( - self, - data: Optional[DictInputT] = None, - client: Optional[Request] = None, - ): ... - @classmethod - def _normalize(cls, data: DictInputT) -> AttrDict[K, V]: ... - -class List(ClientCache, UserList[ListEntryT]): - def __init__( - self, - list_entries: Optional[ListInputT] = None, - entry_class: Optional[Type[ListEntryT]] = None, - client: Optional[Request] = None, - ) -> None: ... - -class ListEntry(JsonDictionaryT): ... diff --git a/src/qbittorrentapi/exceptions.py b/src/qbittorrentapi/exceptions.py index 4faca175e..9e84babca 100644 --- a/src/qbittorrentapi/exceptions.py +++ b/src/qbittorrentapi/exceptions.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from requests.exceptions import HTTPError as RequestsHTTPError from requests.exceptions import RequestException @@ -43,7 +45,7 @@ class HTTPError(RequestsHTTPError, APIConnectionError): statuses. """ - http_status_code = None + http_status_code: int class HTTP4XXError(HTTPError): @@ -57,49 +59,49 @@ class HTTP5XXError(HTTPError): class HTTP400Error(HTTP4XXError): """HTTP 400 Status.""" - http_status_code = 400 + http_status_code: int = 400 class HTTP401Error(HTTP4XXError): """HTTP 401 Status.""" - http_status_code = 401 + http_status_code: int = 401 class HTTP403Error(HTTP4XXError): """HTTP 403 Status.""" - http_status_code = 403 + http_status_code: int = 403 class HTTP404Error(HTTP4XXError): """HTTP 404 Status.""" - http_status_code = 404 + http_status_code: int = 404 class HTTP405Error(HTTP4XXError): """HTTP 405 Status.""" - http_status_code = 405 + http_status_code: int = 405 class HTTP409Error(HTTP4XXError): """HTTP 409 Status.""" - http_status_code = 409 + http_status_code: int = 409 class HTTP415Error(HTTP4XXError): """HTTP 415 Status.""" - http_status_code = 415 + http_status_code: int = 415 class HTTP500Error(HTTP5XXError): """HTTP 500 Status.""" - http_status_code = 500 + http_status_code: int = 500 class MissingRequiredParameters400Error(HTTP400Error): diff --git a/src/qbittorrentapi/exceptions.pyi b/src/qbittorrentapi/exceptions.pyi deleted file mode 100644 index 5bd831f19..000000000 --- a/src/qbittorrentapi/exceptions.pyi +++ /dev/null @@ -1,36 +0,0 @@ -from typing import Optional - -from requests.exceptions import HTTPError as RequestsHTTPError -from requests.exceptions import RequestException - -class APIError(Exception): ... -class UnsupportedQbittorrentVersion(APIError): ... -class FileError(IOError, APIError): ... -class TorrentFileError(FileError): ... -class TorrentFileNotFoundError(TorrentFileError): ... -class TorrentFilePermissionError(TorrentFileError): ... -class APIConnectionError(RequestException, APIError): ... -class LoginFailed(APIConnectionError): ... - -class HTTPError(RequestsHTTPError, APIConnectionError): - http_status_code: Optional[int] = None - -class HTTP4XXError(HTTPError): ... -class HTTP5XXError(HTTPError): ... -class HTTP400Error(HTTP4XXError): ... -class HTTP401Error(HTTP4XXError): ... -class HTTP403Error(HTTP4XXError): ... -class HTTP404Error(HTTP4XXError): ... -class HTTP405Error(HTTP4XXError): ... -class HTTP409Error(HTTP4XXError): ... -class HTTP415Error(HTTP4XXError): ... -class HTTP500Error(HTTP5XXError): ... -class MissingRequiredParameters400Error(HTTP400Error): ... -class InvalidRequest400Error(HTTP400Error): ... -class Unauthorized401Error(HTTP401Error): ... -class Forbidden403Error(HTTP403Error): ... -class NotFound404Error(HTTP404Error): ... -class MethodNotAllowed405Error(HTTP404Error): ... -class Conflict409Error(HTTP409Error): ... -class UnsupportedMediaType415Error(HTTP415Error): ... -class InternalServerError500Error(HTTP500Error): ... diff --git a/src/qbittorrentapi/log.py b/src/qbittorrentapi/log.py index 3ae3dc6df..e86989311 100644 --- a/src/qbittorrentapi/log.py +++ b/src/qbittorrentapi/log.py @@ -1,113 +1,36 @@ +from __future__ import annotations + +from functools import wraps + from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.decorators import login_required +from qbittorrentapi.definitions import APIKwargsT from qbittorrentapi.definitions import APINames from qbittorrentapi.definitions import ClientCache from qbittorrentapi.definitions import List from qbittorrentapi.definitions import ListEntry - - -class LogPeersList(List): - """Response for :meth:`~LogAPIMixIn.log_peers`""" - - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=LogPeer, client=client) +from qbittorrentapi.definitions import ListInputT class LogPeer(ListEntry): """Item in :class:`LogPeersList`""" -class LogMainList(List): - """Response to :meth:`~LogAPIMixIn.log_main`""" +class LogPeersList(List[LogPeer]): + """Response for :meth:`~LogAPIMixIn.log_peers`""" - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=LogEntry, client=client) + def __init__(self, list_entries: ListInputT, client: LogAPIMixIn | None = None): + super().__init__(list_entries, entry_class=LogPeer) class LogEntry(ListEntry): """Item in :class:`LogMainList`""" -class Log(ClientCache): - """ - Allows interaction with ``Log`` API endpoints. - - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> # this is all the same attributes that are available as named in the - >>> # endpoints or the more pythonic names in Client (with or without 'log_' prepended) - >>> log_list = client.log.main() - >>> peers_list = client.log.peers(hash='...') - >>> # can also filter log down with additional attributes - >>> log_info = client.log.main.info(last_known_id='...') - >>> log_warning = client.log.main.warning(last_known_id='...') - """ - - def __init__(self, client): - super().__init__(client=client) - self.main = Log._Main(client=client) - - def peers(self, last_known_id=None, **kwargs): - """Implements :meth:`~LogAPIMixIn.log_peers`""" - return self._client.log_peers(last_known_id=last_known_id, **kwargs) - - class _Main(ClientCache): - def _api_call( - self, - normal=None, - info=None, - warning=None, - critical=None, - last_known_id=None, - **kwargs, - ): - return self._client.log_main( - normal=normal, - info=info, - warning=warning, - critical=critical, - last_known_id=last_known_id, - **kwargs, - ) - - def __call__( - self, - normal=True, - info=True, - warning=True, - critical=True, - last_known_id=None, - **kwargs, - ): - return self._api_call( - normal=normal, - info=info, - warning=warning, - critical=critical, - last_known_id=last_known_id, - **kwargs, - ) - - def info(self, last_known_id=None, **kwargs): - return self._api_call(last_known_id=last_known_id, **kwargs) - - def normal(self, last_known_id=None, **kwargs): - return self._api_call(info=False, last_known_id=last_known_id, **kwargs) - - def warning(self, last_known_id=None, **kwargs): - return self._api_call( - info=False, normal=False, last_known_id=last_known_id, **kwargs - ) +class LogMainList(List[LogEntry]): + """Response to :meth:`~LogAPIMixIn.log_main`""" - def critical(self, last_known_id=None, **kwargs): - return self._api_call( - info=False, - normal=False, - warning=False, - last_known_id=last_known_id, - **kwargs, - ) + def __init__(self, list_entries: ListInputT, client: LogAPIMixIn | None = None): + super().__init__(list_entries, entry_class=LogEntry) class LogAPIMixIn(AppAPIMixIn): @@ -122,27 +45,25 @@ class LogAPIMixIn(AppAPIMixIn): """ @property - def log(self): + def log(self) -> Log: """ Allows for transparent interaction with Log endpoints. See Log class for usage. - :return: Log object """ if self._log is None: self._log = Log(client=self) return self._log - @login_required def log_main( self, - normal=None, - info=None, - warning=None, - critical=None, - last_known_id=None, - **kwargs, - ): + normal: bool | None = None, + info: bool | None = None, + warning: bool | None = None, + critical: bool | None = None, + last_known_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> LogMainList: """ Retrieve the qBittorrent log entries. Iterate over returned object. @@ -151,7 +72,6 @@ def log_main( :param warning: False to exclude ``warning`` entries :param critical: False to exclude ``critical`` entries :param last_known_id: only entries with an ID greater than this value will be returned - :return: :class:`LogMainList` """ params = { "normal": None if normal is None else bool(normal), @@ -160,7 +80,7 @@ def log_main( "critical": None if critical is None else bool(critical), "last_known_id": last_known_id, } - return self._get( + return self._get_cast( _name=APINames.Log, _method="main", params=params, @@ -168,19 +88,127 @@ def log_main( **kwargs, ) - @login_required - def log_peers(self, last_known_id=None, **kwargs): + def log_peers( + self, + last_known_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> LogPeersList: """ Retrieve qBittorrent peer log. - :param last_known_id: only entries with an ID greater than this value will be returned - :return: :class:`LogPeersList` + :param last_known_id: only entries with an ID greater than this value will be + returned """ params = {"last_known_id": last_known_id} - return self._get( + return self._get_cast( _name=APINames.Log, _method="peers", params=params, response_class=LogPeersList, **kwargs, ) + + +class Log(ClientCache[LogAPIMixIn]): + """ + Allows interaction with ``Log`` API endpoints. + + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> # this is all the same attributes that are available as named in the + >>> # endpoints or the more pythonic names in Client (with or without 'log_' prepended) + >>> log_list = client.log.main() + >>> peers_list = client.log.peers(hash='...') + >>> # can also filter log down with additional attributes + >>> log_info = client.log.main.info(last_known_id=1) + >>> log_warning = client.log.main.warning(last_known_id=1) + """ + + def __init__(self, client: LogAPIMixIn): + super().__init__(client=client) + self.main = Log._Main(client=client) + + @wraps(LogAPIMixIn.log_peers) + def peers( + self, + last_known_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> LogPeersList: + return self._client.log_peers(last_known_id=last_known_id, **kwargs) + + class _Main(ClientCache["LogAPIMixIn"]): + def _api_call( + self, + normal: bool | None = None, + info: bool | None = None, + warning: bool | None = None, + critical: bool | None = None, + last_known_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> LogMainList: + return self._client.log_main( + normal=normal, + info=info, + warning=warning, + critical=critical, + last_known_id=last_known_id, + **kwargs, + ) + + def __call__( + self, + normal: bool | None = True, + info: bool | None = True, + warning: bool | None = True, + critical: bool | None = True, + last_known_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> LogMainList: + return self._api_call( + normal=normal, + info=info, + warning=warning, + critical=critical, + last_known_id=last_known_id, + **kwargs, + ) + + def info( + self, + last_known_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> LogMainList: + return self._api_call(last_known_id=last_known_id, **kwargs) + + def normal( + self, + last_known_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> LogMainList: + return self._api_call(info=False, last_known_id=last_known_id, **kwargs) + + def warning( + self, + last_known_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> LogMainList: + return self._api_call( + info=False, + normal=False, + last_known_id=last_known_id, + **kwargs, + ) + + def critical( + self, + last_known_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> LogMainList: + return self._api_call( + info=False, + normal=False, + warning=False, + last_known_id=last_known_id, + **kwargs, + ) diff --git a/src/qbittorrentapi/log.pyi b/src/qbittorrentapi/log.pyi deleted file mode 100644 index c865d2070..000000000 --- a/src/qbittorrentapi/log.pyi +++ /dev/null @@ -1,94 +0,0 @@ -from typing import Optional -from typing import Text - -from qbittorrentapi._types import KwargsT -from qbittorrentapi._types import ListInputT -from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.definitions import ClientCache -from qbittorrentapi.definitions import List -from qbittorrentapi.definitions import ListEntry - -class LogPeer(ListEntry): ... - -class LogPeersList(List[LogPeer]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[LogAPIMixIn] = None, - ) -> None: ... - -class LogEntry(ListEntry): ... - -class LogMainList(List[LogEntry]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[LogAPIMixIn] = None, - ) -> None: ... - -class Log(ClientCache): - main: _Main - def __init__(self, client: LogAPIMixIn) -> None: ... - def peers( - self, - last_known_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> LogPeersList: ... - - class _Main(ClientCache): - def _api_call( - self, - normal: Optional[bool] = None, - info: Optional[bool] = None, - warning: Optional[bool] = None, - critical: Optional[bool] = None, - last_known_id: Optional[bool] = None, - **kwargs: KwargsT, - ) -> LogMainList: ... - def __call__( - self, - normal: Optional[bool] = True, - info: Optional[bool] = True, - warning: Optional[bool] = True, - critical: Optional[bool] = True, - last_known_id: Optional[bool] = None, - **kwargs: KwargsT, - ) -> LogMainList: ... - def info( - self, - last_known_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> LogMainList: ... - def normal( - self, - last_known_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> LogMainList: ... - def warning( - self, - last_known_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> LogMainList: ... - def critical( - self, - last_known_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> LogMainList: ... - -class LogAPIMixIn(AppAPIMixIn): - @property - def log(self) -> Log: ... - def log_main( - self, - normal: Optional[bool] = None, - info: Optional[bool] = None, - warning: Optional[bool] = None, - critical: Optional[bool] = None, - last_known_id: Optional[bool] = None, - **kwargs: KwargsT, - ) -> LogMainList: ... - def log_peers( - self, - last_known_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> LogPeersList: ... diff --git a/src/qbittorrentapi/request.py b/src/qbittorrentapi/request.py index e2e156020..7693740f2 100644 --- a/src/qbittorrentapi/request.py +++ b/src/qbittorrentapi/request.py @@ -1,14 +1,24 @@ +from __future__ import annotations + from collections.abc import Iterable -from contextlib import suppress -from copy import deepcopy +from functools import wraps from json import loads +from logging import Logger from logging import NullHandler from logging import getLogger from os import environ from time import sleep +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import Mapping +from typing import TypeVar +from typing import cast +from urllib.parse import ParseResult from urllib.parse import urljoin from urllib.parse import urlparse +from requests import Response from requests import Session from requests import exceptions as requests_exceptions from requests.adapters import HTTPAdapter @@ -16,14 +26,18 @@ from urllib3.exceptions import InsecureRequestWarning from urllib3.util.retry import Retry +from qbittorrentapi._version_support import v +from qbittorrentapi.definitions import APIKwargsT from qbittorrentapi.definitions import APINames from qbittorrentapi.definitions import Dictionary +from qbittorrentapi.definitions import FilesToSendT from qbittorrentapi.definitions import List from qbittorrentapi.exceptions import APIConnectionError from qbittorrentapi.exceptions import APIError from qbittorrentapi.exceptions import Conflict409Error from qbittorrentapi.exceptions import Forbidden403Error from qbittorrentapi.exceptions import HTTP5XXError +from qbittorrentapi.exceptions import HTTP403Error from qbittorrentapi.exceptions import HTTPError from qbittorrentapi.exceptions import InternalServerError500Error from qbittorrentapi.exceptions import InvalidRequest400Error @@ -33,22 +47,47 @@ from qbittorrentapi.exceptions import Unauthorized401Error from qbittorrentapi.exceptions import UnsupportedMediaType415Error -logger = getLogger(__name__) +if TYPE_CHECKING: + from qbittorrentapi.app import Application + from qbittorrentapi.auth import Authorization + from qbittorrentapi.log import Log + from qbittorrentapi.rss import RSS + from qbittorrentapi.search import Search + from qbittorrentapi.sync import Sync + from qbittorrentapi.torrents import TorrentCategories + from qbittorrentapi.torrents import Torrents + from qbittorrentapi.torrents import TorrentTags + from qbittorrentapi.transfer import Transfer + +T = TypeVar("T") +ExceptionT = TypeVar("ExceptionT", bound=requests_exceptions.RequestException) +ResponseT = TypeVar("ResponseT") + +logger: Logger = getLogger(__name__) getLogger("qbittorrentapi").addHandler(NullHandler()) -class URL: +class QbittorrentURL: """Management for the qBittorrent Web API URL.""" - def __init__(self, client): + def __init__(self, client: Request): self.client = client + self._base_url: str | None = None - def build_url(self, api_namespace, api_method, headers, requests_kwargs): + def build( + self, + api_namespace: APINames | str, + api_method: str, + headers: Mapping[str, str] | None = None, + requests_kwargs: Mapping[str, Any] | None = None, + base_path: str = "api/v2", + ) -> str: """ Create a fully qualified URL for the API endpoint. :param api_namespace: the namespace for the API endpoint (e.g. ``torrents``) :param api_method: the specific method for the API endpoint (e.g. ``info``) + :param base_path: base path for URL (e.g. ``api/v2``) :param headers: HTTP headers for request :param requests_kwargs: kwargs for any calls to Requests :return: fully qualified URL string for endpoint @@ -56,11 +95,15 @@ def build_url(self, api_namespace, api_method, headers, requests_kwargs): # since base_url is guaranteed to end in a slash and api_path will never # start with a slash, this join only ever append to the path in base_url return urljoin( - self.build_base_url(headers, requests_kwargs), - self.build_url_path(api_namespace, api_method), + self.build_base_url(headers or {}, requests_kwargs or {}), + "/".join((base_path, api_namespace, api_method)).strip("/"), ) - def build_base_url(self, headers, requests_kwargs=None): + def build_base_url( + self, + headers: Mapping[str, str], + requests_kwargs: Mapping[str, Any], + ) -> str: """ Determine the Base URL for the Web API endpoints. @@ -81,8 +124,8 @@ def build_base_url(self, headers, requests_kwargs=None): :param requests_kwargs: arguments from user for HTTP ``HEAD`` request :return: base URL as a ``string`` for Web API endpoint """ - if self.client._API_BASE_URL is not None: - return self.client._API_BASE_URL + if self._base_url is not None: + return self._base_url # Parse user host - urlparse requires some sort of scheme for parsing to work at all host = self.client.host @@ -92,7 +135,7 @@ def build_base_url(self, headers, requests_kwargs=None): logger.debug("Parsed user URL: %r", base_url) # default to HTTP if user didn't specify - user_scheme = base_url.scheme + user_scheme = base_url.scheme.lower() default_scheme = user_scheme or "http" alt_scheme = "https" if default_scheme == "http" else "http" @@ -123,17 +166,17 @@ def build_base_url(self, headers, requests_kwargs=None): # force a new session to be created now that the URL is known self.client._trigger_session_initialization() - self.client._API_BASE_URL = base_url_str - return base_url_str + self._base_url = base_url_str + return self._base_url def detect_scheme( self, - base_url, - default_scheme, - alt_scheme, - headers, - requests_kwargs, - ): + base_url: ParseResult, + default_scheme: str, + alt_scheme: str, + headers: Mapping[str, str], + requests_kwargs: Mapping[str, Any], + ) -> str: """ Determine if the URL endpoint is using HTTP or HTTPS. @@ -152,7 +195,7 @@ def detect_scheme( r = self.client._session.request( "head", base_url.geturl(), headers=headers, **requests_kwargs ) - scheme_to_use = urlparse(r.url).scheme + scheme_to_use: str = urlparse(r.url).scheme break except requests_exceptions.SSLError: # an SSLError means that qBittorrent is likely listening on HTTPS @@ -168,39 +211,83 @@ def detect_scheme( logger.debug("Using %s scheme", scheme_to_use.upper()) return scheme_to_use - def build_url_path(self, api_namespace, api_method): - """ - Determine the full URL path for the API endpoint. - :param api_namespace: the namespace for the API endpoint (e.g. torrents) - :param api_method: the specific method for the API endpoint (e.g. info) - :return: entire URL string for API endpoint - (e.g. ``http://localhost:8080/api/v2/torrents/info`` - or ``http://example.com/qbt/api/v2/torrents/info``) - """ - with suppress(AttributeError): - api_namespace = api_namespace.value +class QbittorrentSession(Session): + """ + Wrapper to augment Requests Session. - return "/".join( - str(path_part or "").strip("/") - for path_part in [self.client._API_BASE_PATH, api_namespace, api_method] - ) + Requests doesn't allow Session to default certain configuration globally. This gets + around that by setting defaults for each request. + """ + + @wraps(Session.request) + def request(self, method: str, url: str, **kwargs: Any) -> Response: # type: ignore[override] + kwargs.setdefault("timeout", 15.1) + kwargs.setdefault("allow_redirects", True) + + # send Content-Length as 0 for empty POSTs...Requests will not send Content-Length + # if data is empty but qBittorrent will complain otherwise + data = kwargs.get("data") or {} + is_data = any(x is not None for x in data.values()) + if method.lower() == "post" and not is_data: + kwargs.setdefault("headers", {}).update({"Content-Length": "0"}) + + return super().request(method, url, **kwargs) class Request: """Facilitates HTTP requests to qBittorrent's Web API.""" - def __init__(self, host="", port=None, username=None, password=None, **kwargs): - self.host = host + def __init__( + self, + host: str | None = None, + port: str | int | None = None, + username: str | None = None, + password: str | None = None, + EXTRA_HEADERS: Mapping[str, str] | None = None, + REQUESTS_ARGS: Mapping[str, Any] | None = None, + VERIFY_WEBUI_CERTIFICATE: bool = True, + FORCE_SCHEME_FROM_HOST: bool = False, + RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: bool = False, + RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS: bool = False, + VERBOSE_RESPONSE_LOGGING: bool = False, + SIMPLE_RESPONSES: bool = False, + DISABLE_LOGGING_DEBUG_OUTPUT: bool = False, + ) -> None: + self.host = host or "" self.port = port self.username = username or "" self._password = password or "" - self._initialize_context() - self._initialize_lesser(**kwargs) + self._initialize_settings( + EXTRA_HEADERS=EXTRA_HEADERS, + REQUESTS_ARGS=REQUESTS_ARGS, + VERIFY_WEBUI_CERTIFICATE=VERIFY_WEBUI_CERTIFICATE, + FORCE_SCHEME_FROM_HOST=FORCE_SCHEME_FROM_HOST, + RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=( + RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS + ), + RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS=( + RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS + ), + VERBOSE_RESPONSE_LOGGING=VERBOSE_RESPONSE_LOGGING, + SIMPLE_RESPONSES=SIMPLE_RESPONSES, + DISABLE_LOGGING_DEBUG_OUTPUT=DISABLE_LOGGING_DEBUG_OUTPUT, + ) + + self._url = QbittorrentURL(client=self) + self._http_session: QbittorrentSession | None = None - # URL management - self._url = URL(client=self) + self._application: Application | None = None + self._authorization: Authorization | None = None + self._log: Log | None = None + self._rss: RSS | None = None + self._search: Search | None = None + self._sync: Sync | None = None + self._torrents: Torrents | None = None + self._torrent_categories: TorrentCategories | None = None + self._torrent_tags: TorrentTags | None = None + self._transfer: Transfer | None = None # turn off console-printed warnings about SSL certificate issues. # these errors are only shown once the user has explicitly allowed @@ -209,23 +296,21 @@ def __init__(self, host="", port=None, username=None, password=None, **kwargs): if not self._VERIFY_WEBUI_CERTIFICATE: disable_warnings(InsecureRequestWarning) - def _initialize_context(self): + def _initialize_context(self) -> None: """ - Initialize and/or reset communications context with qBittorrent. + Initialize and reset communications context with qBittorrent. This is necessary on startup or when the authorization cookie needs to be replaced...perhaps because it expired, qBittorrent was restarted, significant settings changes, etc. """ logger.debug("Re-initializing context...") - # base path for all API endpoints - self._API_BASE_PATH = "api/v2" # reset URL so the full URL is derived again # (primarily allows for switching scheme for WebUI: HTTP <-> HTTPS) - self._API_BASE_URL = None + self._url = QbittorrentURL(client=self) - # reset Requests session so it is rebuilt with new auth cookie and all + # reset comm session so it is rebuilt with new auth cookie and all self._trigger_session_initialization() # reinitialize interaction layers @@ -240,34 +325,29 @@ def _initialize_context(self): self._rss = None self._search = None - def _initialize_lesser( + def _initialize_settings( self, - EXTRA_HEADERS=None, - REQUESTS_ARGS=None, - VERIFY_WEBUI_CERTIFICATE=True, - FORCE_SCHEME_FROM_HOST=False, - RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=False, - RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=False, - RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS=False, - VERBOSE_RESPONSE_LOGGING=False, - PRINT_STACK_FOR_EACH_REQUEST=False, - SIMPLE_RESPONSES=False, - DISABLE_LOGGING_DEBUG_OUTPUT=False, - MOCK_WEB_API_VERSION=None, - ): + EXTRA_HEADERS: Mapping[str, str] | None = None, + REQUESTS_ARGS: Mapping[str, Any] | None = None, + VERIFY_WEBUI_CERTIFICATE: bool = True, + FORCE_SCHEME_FROM_HOST: bool = False, + RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: bool = False, + RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS: bool = False, + VERBOSE_RESPONSE_LOGGING: bool = False, + SIMPLE_RESPONSES: bool = False, + DISABLE_LOGGING_DEBUG_OUTPUT: bool = False, + ) -> None: """Initialize lesser used configuration.""" # Configuration parameters - self._EXTRA_HEADERS = EXTRA_HEADERS or {} - self._REQUESTS_ARGS = REQUESTS_ARGS or {} + self._EXTRA_HEADERS = dict(EXTRA_HEADERS) if EXTRA_HEADERS is not None else {} + self._REQUESTS_ARGS = dict(REQUESTS_ARGS) if REQUESTS_ARGS is not None else {} self._VERIFY_WEBUI_CERTIFICATE = bool(VERIFY_WEBUI_CERTIFICATE) self._VERBOSE_RESPONSE_LOGGING = bool(VERBOSE_RESPONSE_LOGGING) - self._PRINT_STACK_FOR_EACH_REQUEST = bool(PRINT_STACK_FOR_EACH_REQUEST) self._SIMPLE_RESPONSES = bool(SIMPLE_RESPONSES) self._FORCE_SCHEME_FROM_HOST = bool(FORCE_SCHEME_FROM_HOST) self._RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS = bool( - RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS - or RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS + RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS ) self._RAISE_UNSUPPORTEDVERSIONERROR = bool( RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS @@ -312,17 +392,15 @@ def _initialize_lesser( if env_verify_cert is not None: self._VERIFY_WEBUI_CERTIFICATE = False - # Mocking variables until better unit testing exists - self._MOCK_WEB_API_VERSION = MOCK_WEB_API_VERSION + self._PRINT_STACK_FOR_EACH_REQUEST = False @classmethod - def _list2string(cls, input_list=None, delimiter="|"): + def _list2string(cls, input_list: T, delimiter: str = "|") -> str | T: """ Convert entries in a list to a concatenated string. :param input_list: list to convert :param delimiter: delimiter for concatenation - :return: if input is a list, concatenated string...else whatever the input was """ if isinstance(input_list, Iterable) and not isinstance(input_list, str): return delimiter.join(map(str, input_list)) @@ -330,17 +408,19 @@ def _list2string(cls, input_list=None, delimiter="|"): def _get( self, - _name=APINames.EMPTY, - _method="", - requests_args=None, - requests_params=None, - headers=None, - params=None, - data=None, - files=None, - response_class=None, - **kwargs, - ): + _name: APINames | str, + _method: str, + requests_args: Mapping[str, Any] | None = None, + requests_params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + files: FilesToSendT | None = None, + response_class: type = Response, + version_introduced: str = "", + version_removed: str = "", + **kwargs: APIKwargsT, + ) -> Any: """ Send ``GET`` request. @@ -350,7 +430,7 @@ def _get( :param kwargs: see :meth:`~Request._request` :return: Requests :class:`~requests.Response` """ - return self._request_manager( + return self._auth_request( http_method="get", api_namespace=_name, api_method=_method, @@ -361,22 +441,68 @@ def _get( data=data, files=files, response_class=response_class, + version_introduced=version_introduced, + version_removed=version_removed, **kwargs, ) + def _get_cast( + self, + _name: APINames | str, + _method: str, + response_class: type[ResponseT], + requests_args: Mapping[str, Any] | None = None, + requests_params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + files: FilesToSendT | None = None, + version_introduced: str = "", + version_removed: str = "", + **kwargs: APIKwargsT, + ) -> ResponseT: + """ + Send ``GET`` request with casted response. + + :param api_namespace: the namespace for the API endpoint + (e.g. :class:`~qbittorrentapi.definitions.APINames` or ``torrents``) + :param api_method: the name for the API endpoint (e.g. ``add``) + :param kwargs: see :meth:`~Request._request` + """ + return cast( + ResponseT, + self._auth_request( + http_method="get", + api_namespace=_name, + api_method=_method, + requests_args=requests_args, + requests_params=requests_params, + headers=headers, + params=params, + data=data, + files=files, + response_class=response_class, + version_introduced=version_introduced, + version_removed=version_removed, + **kwargs, + ), + ) + def _post( self, - _name=APINames.EMPTY, - _method="", - requests_args=None, - requests_params=None, - headers=None, - params=None, - data=None, - files=None, - response_class=None, - **kwargs, - ): + _name: APINames | str, + _method: str, + requests_args: Mapping[str, Any] | None = None, + requests_params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + files: FilesToSendT | None = None, + response_class: type = Response, + version_introduced: str = "", + version_removed: str = "", + **kwargs: APIKwargsT, + ) -> Any: """ Send ``POST`` request. @@ -384,9 +510,8 @@ def _post( (e.g. :class:`~qbittorrentapi.definitions.APINames` or ``torrents``) :param api_method: the name for the API endpoint (e.g. ``add``) :param kwargs: see :meth:`~Request._request` - :return: Requests :class:`~requests.Response` """ - return self._request_manager( + return self._auth_request( http_method="post", api_namespace=_name, api_method=_method, @@ -397,25 +522,128 @@ def _post( data=data, files=files, response_class=response_class, + version_introduced=version_introduced, + version_removed=version_removed, **kwargs, ) + def _post_cast( + self, + _name: APINames | str, + _method: str, + response_class: type[ResponseT], + requests_args: Mapping[str, Any] | None = None, + requests_params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + files: FilesToSendT | None = None, + version_introduced: str = "", + version_removed: str = "", + **kwargs: APIKwargsT, + ) -> ResponseT: + """ + Send ``POST`` request with casted response. + + :param api_namespace: the namespace for the API endpoint + (e.g. :class:`~qbittorrentapi.definitions.APINames` or ``torrents``) + :param api_method: the name for the API endpoint (e.g. ``add``) + :param kwargs: see :meth:`~Request._request` + """ + return cast( + ResponseT, + self._auth_request( + http_method="post", + api_namespace=_name, + api_method=_method, + requests_args=requests_args, + requests_params=requests_params, + headers=headers, + params=params, + data=data, + files=files, + response_class=response_class, + version_introduced=version_introduced, + version_removed=version_removed, + **kwargs, + ), + ) + + def _auth_request( + self, + http_method: Literal["get", "GET", "post", "POST"], + api_namespace: APINames | str, + api_method: str, + _retry_backoff_factor: float = 0.3, + requests_args: Mapping[str, Any] | None = None, + requests_params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + files: FilesToSendT | None = None, + response_class: type = Response, + version_introduced: str = "", + version_removed: str = "", + **kwargs: APIKwargsT, + ) -> Any: + """Wraps API call with re-authorization if first attempt is not authorized.""" + try: + return self._request_manager( + http_method=http_method, + api_namespace=api_namespace, + api_method=api_method, + requests_args=requests_args, + requests_params=requests_params, + headers=headers, + params=params, + data=data, + files=files, + response_class=response_class, + version_introduced=version_introduced, + version_removed=version_removed, + **kwargs, + ) + except HTTP403Error: + logger.debug("Login may have expired...attempting new login") + self.auth_log_in( # type: ignore[attr-defined] + requests_args=requests_args, + requests_params=requests_params, + headers=headers, + ) + return self._request_manager( + http_method=http_method, + api_namespace=api_namespace, + api_method=api_method, + requests_args=requests_args, + requests_params=requests_params, + headers=headers, + params=params, + data=data, + files=files, + response_class=response_class, + version_introduced=version_introduced, + version_removed=version_removed, + **kwargs, + ) + def _request_manager( self, - http_method, - api_namespace, - api_method, - _retries=1, - _retry_backoff_factor=0.3, - requests_args=None, - requests_params=None, - headers=None, - params=None, - data=None, - files=None, - response_class=None, - **kwargs, - ): + http_method: Literal["get", "GET", "post", "POST"], + api_namespace: APINames | str, + api_method: str, + _retries: int = 1, + _retry_backoff_factor: float = 0.3, + requests_args: Mapping[str, Any] | None = None, + requests_params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + files: FilesToSendT | None = None, + response_class: type = Response, + version_introduced: str = "", + version_removed: str = "", + **kwargs: APIKwargsT, + ) -> Any: """ Wrapper to manage request retries and severe exceptions. @@ -423,39 +651,12 @@ def _request_manager( to HTTPS. During the second attempt, the URL is rebuilt using HTTP or HTTPS as appropriate. """ - - def build_error_msg(exc): - """Create error message for exception to be raised to user.""" - error_prologue = "Failed to connect to qBittorrent. " - error_messages = { - requests_exceptions.SSLError: "This is likely due to using an untrusted certificate " - "(likely self-signed) for HTTPS qBittorrent WebUI. To suppress this error (and skip " - "certificate verification consequently exposing the HTTPS connection to man-in-the-middle " - "attacks), set VERIFY_WEBUI_CERTIFICATE=False when instantiating Client or set " - "environment variable PYTHON_QBITTORRENTAPI_DO_NOT_VERIFY_WEBUI_CERTIFICATE " - f"to a non-null value. SSL Error: {repr(exc)}", - requests_exceptions.HTTPError: f"Invalid HTTP Response: {repr(exc)}", - requests_exceptions.TooManyRedirects: f"Too many redirects: {repr(exc)}", - requests_exceptions.ConnectionError: f"Connection Error: {repr(exc)}", - requests_exceptions.Timeout: f"Timeout Error: {repr(exc)}", - requests_exceptions.RequestException: f"Requests Error: {repr(exc)}", - } - err_msg = error_messages.get(type(exc), f"Unknown Error: {repr(exc)}") - err_msg = error_prologue + err_msg - logger.debug(err_msg) - return err_msg - - def retry_backoff(retry_count): - """ - Back off on attempting each subsequent request retry. - - The first retry is always immediate. if the backoff factor is 0.3, then will - sleep for 0s then .3s, then .6s, etc. between retries. - """ - if retry_count > 0: - backoff_time = _retry_backoff_factor * (2 ** ((retry_count + 1) - 1)) - sleep(backoff_time if backoff_time <= 10 else 10) - logger.debug("Retry attempt %d", retry_count + 1) + if not self._is_endpoint_supported_for_version( + endpoint=f"{api_namespace}/{api_method}", + version_introduced=version_introduced, + version_removed=version_removed, + ): + return None max_retries = _retries if _retries >= 1 else 1 for retry in range(0, (max_retries + 1)): # pragma: no branch @@ -473,36 +674,86 @@ def retry_backoff(retry_count): response_class=response_class, **kwargs, ) - except HTTPError as exc: - # retry the request for HTTP 500 statuses; - # raise immediately for other HTTP errors (e.g. 4XX statuses) - if retry >= max_retries or not isinstance(exc, HTTP5XXError): + except HTTP5XXError: + if retry >= max_retries: raise except APIError: raise except Exception as exc: if retry >= max_retries: - error_message = build_error_msg(exc=exc) - response = getattr(exc, "response", None) - raise APIConnectionError(error_message, response=response) + err_msg = "Failed to connect to qBittorrent. " + { + requests_exceptions.SSLError: "This is likely due to using an untrusted certificate " + "(likely self-signed) for HTTPS qBittorrent WebUI. To suppress this error (and skip " + "certificate verification consequently exposing the HTTPS connection to man-in-the-middle " + "attacks), set VERIFY_WEBUI_CERTIFICATE=False when instantiating Client or set " + "environment variable QBITTORRENTAPI_DO_NOT_VERIFY_WEBUI_CERTIFICATE to a non-null value. " + f"SSL Error: {repr(exc)}", + requests_exceptions.HTTPError: f"Invalid HTTP Response: {repr(exc)}", + requests_exceptions.TooManyRedirects: f"Too many redirects: {repr(exc)}", + requests_exceptions.ConnectionError: f"Connection Error: {repr(exc)}", + requests_exceptions.Timeout: f"Timeout Error: {repr(exc)}", + requests_exceptions.RequestException: f"Requests Error: {repr(exc)}", + }.get( + type(exc), f"Unknown Error: {repr(exc)}" # type: ignore[arg-type] + ) + logger.debug(err_msg) + response: Response | None = getattr(exc, "response", None) + raise APIConnectionError(err_msg, response=response) + + if retry > 0: + sleep(min(_retry_backoff_factor * 2**retry, 10)) + logger.debug("Retry attempt %d", retry + 1) + + self._initialize_context() # reset connection for each retry + + def _is_endpoint_supported_for_version( + self, + endpoint: str, + version_introduced: str, + version_removed: str, + ) -> bool: + """ + Prevent using an API methods that doesn't exist in this version of qBittorrent. + + :param endpoint: name of the removed endpoint, e.g. torrents/ban_peers + :param version_introduced: the Web API version the endpoint was introduced + :param version_removed: the Web API version the endpoint was removed + """ + error_message = "" + + if version_introduced and v(self.app.web_api_version) < v(version_introduced): # type: ignore[attr-defined] + error_message = ( + f"ERROR: Endpoint '{endpoint}' is Not Implemented in this version of qBittorrent. " + f"This endpoint is available starting in Web API v{version_introduced}." + ) + + if version_removed and v(self.app.web_api_version) >= v(version_removed): # type: ignore[attr-defined] + error_message = ( + f"ERROR: Endpoint '{endpoint}' is Not Implemented in this version of qBittorrent. " + f"This endpoint was removed in Web API v{version_removed}." + ) - retry_backoff(retry_count=retry) - self._initialize_context() + if error_message: + logger.debug(error_message) + if self._RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: + raise NotImplementedError(error_message) + return False + return True def _request( self, - http_method, - api_namespace, - api_method, - requests_args=None, - requests_params=None, - headers=None, - params=None, - data=None, - files=None, - response_class=None, - **kwargs, - ): + http_method: str, + api_namespace: APINames | str, + api_method: str, + requests_args: Mapping[str, Any] | None = None, + requests_params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + files: FilesToSendT | None = None, + response_class: type = Response, + **kwargs: APIKwargsT, + ) -> Any: """ Meat and potatoes of sending requests to qBittorrent. @@ -518,75 +769,56 @@ def _request( :param files: files to be sent with the request :param response_class: class to use to cast the API response :param kwargs: arbitrary keyword arguments to send with the request - :return: Requests :class:`~requests.Response` """ - response_kwargs, kwargs = self._get_response_kwargs(kwargs) - requests_kwargs = self._get_requests_kwargs(requests_args, requests_params) - headers = self._get_headers(headers, requests_kwargs.pop("headers", {})) - url = self._url.build_url(api_namespace, api_method, headers, requests_kwargs) - params, data, files = self._get_data(http_method, params, data, files, **kwargs) + http_method = http_method.lower() - response = self._session.request( - http_method, - url, - params=params, - data=data, - files=files, - headers=headers, - **requests_kwargs, - ) - - self._verbose_logging(http_method, url, data, params, requests_kwargs, response) - self._handle_error_responses(data, params, response) - return self._cast(response, response_class, **response_kwargs) - - @staticmethod - def _get_response_kwargs(kwargs): - """ - Determine the kwargs for managing the response to return. - - :param kwargs: extra keywords arguments to be passed along in request - :return: sanitized arguments - """ + # keyword args that influence response handling response_kwargs = { "SIMPLE_RESPONSES": kwargs.pop( - "SIMPLE_RESPONSES", - kwargs.pop("SIMPLE_RESPONSE", None), + "SIMPLE_RESPONSES", kwargs.pop("SIMPLE_RESPONSE", False) ) } - return response_kwargs, kwargs - def _get_requests_kwargs(self, requests_args=None, requests_params=None): - """ - Determine the requests_kwargs for the call to Requests. The global configuration - in ``self._REQUESTS_ARGS`` is updated by any arguments provided for a specific - call. + # merge arguments for invoking Requests Session + requests_kwargs = { + **self._REQUESTS_ARGS, + **(requests_args or requests_params or {}), + } - :param requests_args: default location to expect Requests ``requests_kwargs`` - :param requests_params: alternative location to expect Requests ``requests_kwargs`` - :return: final dictionary of Requests ``requests_kwargs`` - """ - requests_kwargs = deepcopy(self._REQUESTS_ARGS) - requests_kwargs.update(requests_args or requests_params or {}) - return requests_kwargs + # merge HTTP headers to include with request + final_headers = { + **requests_kwargs.pop("headers", {}), + **(headers or {}), + } - @staticmethod - def _get_headers(headers=None, more_headers=None): - """ - Determine headers specific to this request. Request headers can be specified - explicitly or with the requests kwargs. Headers specified in - ``self._EXTRA_HEADERS`` are merged in Requests itself. + url = self._url.build(api_namespace, api_method, headers, requests_kwargs) - :param headers: headers specified for this specific request - :param more_headers: headers from requests_kwargs arguments - :return: final dictionary of headers for this specific request - """ - user_headers = more_headers or {} - user_headers.update(headers or {}) - return user_headers + final_params, final_data, final_files = self._format_payload( + http_method, params, data, files, **kwargs + ) + + response = self._session.request( + method=http_method, + url=url, + headers=final_headers, + params=final_params, + data=final_data, + files=final_files, + **requests_kwargs, + ) + + self._verbose_logging(url, final_data, final_params, requests_kwargs, response) + self._handle_error_responses(final_data, final_params, response) + return self._cast(response, response_class, **response_kwargs) @staticmethod - def _get_data(http_method, params=None, data=None, files=None, **kwargs): + def _format_payload( + http_method: str, + params: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + files: FilesToSendT | None = None, + **kwargs: APIKwargsT, + ) -> tuple[dict[str, Any], dict[str, Any], FilesToSendT]: """ Determine ``data``, ``params``, and ``files`` for the Requests call. @@ -594,11 +826,10 @@ def _get_data(http_method, params=None, data=None, files=None, **kwargs): :param params: key value pairs to send with GET calls :param data: key value pairs to send with POST calls :param files: dictionary of files to send with request - :return: final dictionaries of data to send to qBittorrent """ - params = params or {} - data = data or {} - files = files or {} + params = dict(params) if params is not None else {} + data = dict(data) if data is not None else {} + files = dict(files) if files is not None else {} # any other keyword arguments are sent to qBittorrent as part of the request. # These are user-defined since this Client will put everything in data/params/files @@ -611,32 +842,38 @@ def _get_data(http_method, params=None, data=None, files=None, **kwargs): return params, data, files - def _cast(self, response, response_class, **response_kwargs): + def _cast( + self, + response: Response, + response_class: type, + **response_kwargs: APIKwargsT, + ) -> Any: """ Returns the API response casted to the requested class. :param response: requests ``Response`` from API :param response_class: class to return response as; if none, response is returned :param response_kwargs: request-specific configuration for response - :return: API response as type of ``response_class`` """ try: - if response_class is None: + if response_class is Response: return response - if response_class in (str, int): - return response_class(response.text) + if response_class is str: + return response.text + if response_class is int: + return int(response.text) if response_class is bytes: return response.content if issubclass(response_class, (Dictionary, List)): try: - result = response.json() + json_response = response.json() except AttributeError: # just in case the requests package is old and doesn't contain json() - result = loads(response.text) + json_response = loads(response.text) if self._SIMPLE_RESPONSES or response_kwargs.get("SIMPLE_RESPONSES"): - return result + return json_response else: - return response_class(result, client=self) + return response_class(json_response, client=self) except Exception as exc: logger.debug("Exception during response parsing.", exc_info=True) raise APIError(f"Exception during response parsing. Error: {exc!r}") @@ -645,35 +882,10 @@ def _cast(self, response, response_class, **response_kwargs): raise APIError(f"No handler defined to cast response to {response_class}") @property - def _session(self): - """ - Create or return existing HTTP session. - - :return: Requests :class:`~requests.Session` object - """ - - class QbittorrentSession(Session): - """ - Wrapper to augment Requests Session. - - Requests doesn't allow Session to default certain configuration globally. - This gets around that by setting defaults for each request. - """ - - def request(self, method, url, **kwargs): - kwargs.setdefault("timeout", 15.1) - kwargs.setdefault("allow_redirects", True) - - # send Content-Length as 0 for empty POSTs...Requests will not send Content-Length - # if data is empty but qBittorrent will complain otherwise - data = kwargs.get("data") or {} - is_data = any(x is not None for x in data.values()) - if method.lower() == "post" and not is_data: - kwargs.setdefault("headers", {}).update({"Content-Length": "0"}) + def _session(self) -> QbittorrentSession: + """Create or return existing HTTP session.""" - return super().request(method, url, **kwargs) - - if self._http_session: + if self._http_session is not None: return self._http_session self._http_session = QbittorrentSession() @@ -710,7 +922,7 @@ def request(self, method, url, **kwargs): return self._http_session - def __del__(self): + def __del__(self) -> None: """ Close HTTP Session before destruction. @@ -720,40 +932,52 @@ def __del__(self): """ self._trigger_session_initialization() - def _trigger_session_initialization(self): + def _trigger_session_initialization(self) -> None: """ Effectively resets the HTTP session by removing the reference to it. During the next request, a new session will be created. """ - with suppress(Exception): + if self._http_session is not None: self._http_session.close() self._http_session = None @staticmethod - def _handle_error_responses(data, params, response): + def _handle_error_responses( + data: Mapping[str, Any], + params: Mapping[str, Any], + response: Response, + ) -> None: """Raise proper exception if qBittorrent returns Error HTTP Status.""" if response.status_code < 400: # short circuit for non-error statuses return + request = response.request + if response.status_code == 400: # Returned for malformed requests such as missing or invalid parameters. # If an error_message isn't returned, qBittorrent didn't receive all required parameters. # APIErrorType::BadParams # the name of the HTTP error (i.e. Bad Request) started being returned in v4.3.0 if response.text in ("", "Bad Request"): - raise MissingRequiredParameters400Error() - raise InvalidRequest400Error(response.text) + raise MissingRequiredParameters400Error( + request=request, response=response + ) + raise InvalidRequest400Error( + response.text, request=request, response=response + ) if response.status_code == 401: # Primarily reserved for XSS and host header issues. - raise Unauthorized401Error(response.text) + raise Unauthorized401Error( + response.text, request=request, response=response + ) if response.status_code == 403: # Not logged in or calling an API method that isn't public # APIErrorType::AccessDenied - raise Forbidden403Error(response.text) + raise Forbidden403Error(response.text, request=request, response=response) if response.status_code == 404: # API method doesn't exist or more likely, torrent not found @@ -764,39 +988,46 @@ def _handle_error_responses(data, params, response): error_hash = hash_source.get("hashes", hash_source.get("hash", "")) if error_hash: error_message = "Torrent hash(es): %s" % error_hash - raise NotFound404Error(error_message) + raise NotFound404Error(error_message, request=request, response=response) if response.status_code == 405: # HTTP method not allowed for the API endpoint. # This should only be raised if qBittorrent changes the requirement for an endpoint... - raise MethodNotAllowed405Error(response.text) + raise MethodNotAllowed405Error( + response.text, request=request, response=response + ) if response.status_code == 409: # APIErrorType::Conflict - raise Conflict409Error(response.text) + raise Conflict409Error(response.text, request=request, response=response) if response.status_code == 415: # APIErrorType::BadData - raise UnsupportedMediaType415Error(response.text) + raise UnsupportedMediaType415Error( + response.text, request=request, response=response + ) if response.status_code >= 500: - http_error = InternalServerError500Error(response.text) - http_error.http_status_code = response.status_code - raise http_error + http500_error = InternalServerError500Error( + response.text, request=request, response=response + ) + http500_error.http_status_code = response.status_code + raise http500_error # Unaccounted for API errors - http_error = HTTPError(response.text) + http_error = HTTPError(response.text, request=request, response=response) http_error.http_status_code = response.status_code raise http_error def _verbose_logging( - self, http_method, url, data, params, requests_kwargs, response - ): - """ - Log verbose information about request. - - Can be useful during development. - """ + self, + url: str, + data: Mapping[str, Any], + params: Mapping[str, Any], + requests_kwargs: Mapping[str, Any], + response: Response, + ) -> None: + """Log verbose information about request; can be useful during development.""" if self._VERBOSE_RESPONSE_LOGGING: resp_logger = logger.debug max_text_length_to_log = 254 @@ -804,14 +1035,12 @@ def _verbose_logging( # log as much as possible in an error condition max_text_length_to_log = 10000 - resp_logger("Request URL: (%s) %s", http_method.upper(), response.url) + resp_logger("Request URL: (%s) %s", response.request.method, response.url) resp_logger("Request Headers: %s", response.request.headers) - resp_logger("Request HTTP Data: %s", {"data": data, "params": params}) + if "auth/login" not in url: + resp_logger("Request HTTP Data: %s", {"data": data, "params": params}) resp_logger("Requests Config: %s", requests_kwargs) - if ( - str(response.request.body) not in ("None", "") - and "auth/login" not in url - ): + if isinstance(response.request.body, str) and "auth/login" not in url: body_len = ( max_text_length_to_log if len(response.request.body) > max_text_length_to_log diff --git a/src/qbittorrentapi/request.pyi b/src/qbittorrentapi/request.pyi deleted file mode 100644 index 52665cca6..000000000 --- a/src/qbittorrentapi/request.pyi +++ /dev/null @@ -1,238 +0,0 @@ -from logging import Logger -from typing import Any -from typing import Iterable -from typing import Mapping -from typing import MutableMapping -from typing import Optional -from typing import Text -from typing import Tuple -from typing import Type -from typing import TypeVar -from typing import Union -from urllib.parse import ParseResult - -import six -from requests import Response -from requests import Session - -from qbittorrentapi._types import FilesToSendT -from qbittorrentapi._types import JsonDictionaryT -from qbittorrentapi._types import KwargsT -from qbittorrentapi.app import Application -from qbittorrentapi.auth import Authorization -from qbittorrentapi.definitions import APINames -from qbittorrentapi.definitions import List -from qbittorrentapi.log import Log -from qbittorrentapi.rss import RSS -from qbittorrentapi.search import Search -from qbittorrentapi.sync import Sync -from qbittorrentapi.torrents import TorrentCategories -from qbittorrentapi.torrents import Torrents -from qbittorrentapi.torrents import TorrentTags -from qbittorrentapi.transfer import Transfer - -logger: Logger - -FinalResponseT = TypeVar( - "FinalResponseT", - bound=Union[ - int, - bytes, - six.text_type, - JsonDictionaryT, - List[JsonDictionaryT], - ], -) - -class URL(object): - client: Request - def __init__(self, client: Request) -> None: ... - def build_url( - self, - api_namespace: APINames | Text, - api_method: Text, - headers: Mapping[Text, Text], - requests_kwargs: Mapping[Text, Any], - ) -> str: ... - def build_base_url( - self, - headers: Mapping[Text, Text], - requests_kwargs: Optional[Mapping[Text, Any]] = None, - ) -> str: ... - def detect_scheme( - self, - base_url: ParseResult, - default_scheme: Text, - alt_scheme: Text, - headers: Mapping[Text, Text], - requests_kwargs: Mapping[Text, Any], - ) -> str: ... - def build_url_path( - self, api_namespace: APINames | Text, api_method: Text - ) -> str: ... - -class Request(object): - host: Text - port: Text | int - username: Text - _password: Text - _url: URL - _http_session: Session | None - _application: Application | None - _authorization: Authorization | None - _transfer: Transfer | None - _torrents: Torrents | None - _torrent_categories: TorrentCategories | None - _torrent_tags: TorrentTags | None - _log: Log | None - _sync: Sync | None - _rss: RSS | None - _search: Search | None - - _API_BASE_URL: Text | None - _API_BASE_PATH: Text | None - - _EXTRA_HEADERS: Mapping[Text, Text] | None - _REQUESTS_ARGS: MutableMapping[Text, Any] | None - _VERIFY_WEBUI_CERTIFICATE: bool | None - _FORCE_SCHEME_FROM_HOST: bool | None - _RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: bool | None - _RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: bool | None - _RAISE_UNSUPPORTEDVERSIONERROR: bool | None - _VERBOSE_RESPONSE_LOGGING: bool | None - _PRINT_STACK_FOR_EACH_REQUEST: bool | None - _SIMPLE_RESPONSES: bool | None - _DISABLE_LOGGING_DEBUG_OUTPUT: bool | None - _MOCK_WEB_API_VERSION: Text | None - def __init__( - self, - host: Optional[Text] = "", - port: Optional[Text | int] = None, - username: Optional[Text] = None, - password: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - def _initialize_context(self) -> None: ... - def _initialize_lesser( - self, - EXTRA_HEADERS: Optional[Mapping[Text, Text]] = None, - REQUESTS_ARGS: Optional[Mapping[Text, Any]] = None, - VERIFY_WEBUI_CERTIFICATE: bool = True, - FORCE_SCHEME_FROM_HOST: bool = False, - RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: bool = False, - RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: bool = False, - RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS: bool = False, - VERBOSE_RESPONSE_LOGGING: bool = False, - PRINT_STACK_FOR_EACH_REQUEST: bool = False, - SIMPLE_RESPONSES: bool = False, - DISABLE_LOGGING_DEBUG_OUTPUT: bool = False, - MOCK_WEB_API_VERSION: Optional[Text] = None, - ) -> None: ... - @classmethod - def _list2string( - cls, - input_list: Optional[Iterable[Any]] = None, - delimiter: Text = "|", - ) -> Text: ... - def _trigger_session_initialization(self) -> None: ... - def _get( - self, - _name: APINames | Text = APINames.EMPTY, - _method: Text = "", - requests_args: Optional[Mapping[Text, Any]] = None, - requests_params: Optional[Mapping[Text, Any]] = None, - headers: Optional[Mapping[Text, Text]] = None, - params: Optional[Mapping[Text, Any]] = None, - data: Optional[Mapping[Text, Any]] = None, - files: Optional[FilesToSendT] = None, - response_class: Optional[Type[FinalResponseT]] = None, - **kwargs: KwargsT, - ) -> FinalResponseT: ... - def _post( - self, - _name: APINames | Text = APINames.EMPTY, - _method: Text = "", - requests_args: Optional[Mapping[Text, Any]] = None, - requests_params: Optional[Mapping[Text, Any]] = None, - headers: Optional[Mapping[Text, Text]] = None, - params: Optional[Mapping[Text, Any]] = None, - data: Optional[Mapping[Text, Any]] = None, - files: Optional[FilesToSendT] = None, - response_class: Optional[Type[FinalResponseT]] = None, - **kwargs: KwargsT, - ) -> FinalResponseT: ... - def _request_manager( - self, - http_method: Text, - api_namespace: APINames | Text, - api_method: Text, - _retries: int = 1, - _retry_backoff_factor: float = 0.3, - requests_args: Optional[Mapping[Text, Any]] = None, - requests_params: Optional[Mapping[Text, Any]] = None, - headers: Optional[Mapping[Text, Text]] = None, - params: Optional[Mapping[Text, Any]] = None, - data: Optional[Mapping[Text, Any]] = None, - files: Optional[FilesToSendT] = None, - response_class: Optional[Type[FinalResponseT]] = None, - **kwargs: KwargsT, - ) -> FinalResponseT: ... - def _request( - self, - http_method: Text, - api_namespace: APINames | Text, - api_method: Text, - requests_args: Optional[Mapping[Text, Any]] = None, - requests_params: Optional[Mapping[Text, Any]] = None, - headers: Optional[Mapping[Text, Text]] = None, - params: Optional[Mapping[Text, Any]] = None, - data: Optional[Mapping[Text, Any]] = None, - files: Optional[FilesToSendT] = None, - response_class: Optional[Type[FinalResponseT]] = None, - **kwargs: KwargsT, - ) -> FinalResponseT: ... - @staticmethod - def _get_response_kwargs( - kwargs: MutableMapping[Text, Any] - ) -> Tuple[dict[str, Any], dict[str, Any]]: ... - def _get_requests_kwargs( - self, - requests_args: Optional[Mapping[Text, Any]] = None, - requests_params: Optional[Mapping[Text, Any]] = None, - ) -> dict[Text, Any]: ... - @staticmethod - def _get_headers( - headers: Optional[Mapping[Text, Text]] = None, - more_headers: Optional[Mapping[Text, Text]] = None, - ) -> dict[Text, Text]: ... - @staticmethod - def _get_data( - http_method: Text, - params: Optional[Mapping[Text, Any]] = None, - data: Optional[Mapping[Text, Any]] = None, - files: Optional[FilesToSendT] = None, - **kwargs: KwargsT, - ) -> Tuple[dict[Text, Any], dict[Text, Any], FilesToSendT]: ... - def _cast( - self, - response: Response, - response_class: Type[FinalResponseT], - **response_kwargs: KwargsT, - ) -> FinalResponseT: ... - @property - def _session(self) -> Session: ... - @staticmethod - def _handle_error_responses( - data: Mapping[Text, Any], - params: Mapping[Text, Any], - response: Response, - ) -> None: ... - def _verbose_logging( - self, - http_method: Text, - url: Text, - data: Mapping[Text, Any], - params: Mapping[Text, Any], - requests_kwargs: Mapping[Text, Any], - response: Response, - ) -> None: ... diff --git a/src/qbittorrentapi/rss.py b/src/qbittorrentapi/rss.py index 85b18aa99..d007771f6 100644 --- a/src/qbittorrentapi/rss.py +++ b/src/qbittorrentapi/rss.py @@ -1,136 +1,25 @@ +from __future__ import annotations + +from functools import wraps from json import dumps +from typing import Mapping from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.decorators import alias -from qbittorrentapi.decorators import aliased -from qbittorrentapi.decorators import endpoint_introduced -from qbittorrentapi.decorators import login_required +from qbittorrentapi.definitions import APIKwargsT from qbittorrentapi.definitions import APINames from qbittorrentapi.definitions import ClientCache from qbittorrentapi.definitions import Dictionary +from qbittorrentapi.definitions import JsonValueT -class RSSitemsDictionary(Dictionary): +class RSSitemsDictionary(Dictionary[JsonValueT]): """Response for :meth:`~RSSAPIMixIn.rss_items`""" -class RSSRulesDictionary(Dictionary): +class RSSRulesDictionary(Dictionary[JsonValueT]): """Response for :meth:`~RSSAPIMixIn.rss_rules`""" -@aliased -class RSS(ClientCache): - """ - Allows interaction with ``RSS`` API endpoints. - - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> # this is all the same attributes that are available as named in the - >>> # endpoints or the more pythonic names in Client (with or without 'log_' prepended) - >>> rss_rules = client.rss.rules - >>> client.rss.addFolder(folder_path="TPB") - >>> client.rss.addFeed(url='...', item_path="TPB\\Top100") - >>> client.rss.remove_item(item_path="TPB") # deletes TPB and Top100 - >>> client.rss.set_rule(rule_name="...", rule_def={...}) - >>> items = client.rss.items.with_data - >>> items_no_data = client.rss.items.without_data - """ - - def __init__(self, client): - super().__init__(client=client) - self.items = RSS._Items(client=client) - - @alias("addFolder") - def add_folder(self, folder_path=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_add_folder`""" - return self._client.rss_add_folder(folder_path=folder_path, **kwargs) - - @alias("addFeed") - def add_feed(self, url=None, item_path=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_add_feed`""" - return self._client.rss_add_feed(url=url, item_path=item_path, **kwargs) - - @alias("setFeedURL") - def set_feed_url(self, url=None, item_path=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_set_feed_url`""" - return self._client.rss_set_feed_url(url=url, item_path=item_path, **kwargs) - - @alias("removeItem") - def remove_item(self, item_path=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_remove_item`""" - return self._client.rss_remove_item(item_path=item_path, **kwargs) - - @alias("moveItem") - def move_item(self, orig_item_path=None, new_item_path=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_move_item`""" - return self._client.rss_move_item( - orig_item_path=orig_item_path, - new_item_path=new_item_path, - **kwargs, - ) - - @alias("refreshItem") - def refresh_item(self, item_path=None): - """Implements :meth:`~RSSAPIMixIn.rss_refresh_item`""" - return self._client.rss_refresh_item(item_path=item_path) - - @alias("markAsRead") - def mark_as_read(self, item_path=None, article_id=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_mark_as_read`""" - return self._client.rss_mark_as_read( - item_path=item_path, - article_id=article_id, - **kwargs, - ) - - @alias("setRule") - def set_rule(self, rule_name=None, rule_def=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_set_rule`""" - return self._client.rss_set_rule( - rule_name=rule_name, - rule_def=rule_def, - **kwargs, - ) - - @alias("renameRule") - def rename_rule(self, orig_rule_name=None, new_rule_name=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_rename_rule`""" - return self._client.rss_rename_rule( - orig_rule_name=orig_rule_name, - new_rule_name=new_rule_name, - **kwargs, - ) - - @alias("removeRule") - def remove_rule(self, rule_name=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_remove_rule`""" - return self._client.rss_remove_rule(rule_name=rule_name, **kwargs) - - @property - def rules(self): - """Implements :meth:`~RSSAPIMixIn.rss_rules`""" - return self._client.rss_rules() - - @alias("matchingArticles") - def matching_articles(self, rule_name=None, **kwargs): - """Implements :meth:`~RSSAPIMixIn.rss_matching_articles`""" - return self._client.rss_matching_articles(rule_name=rule_name, **kwargs) - - class _Items(ClientCache): - def __call__(self, include_feed_data=None, **kwargs): - return self._client.rss_items(include_feed_data=include_feed_data, **kwargs) - - @property - def without_data(self): - return self._client.rss_items(include_feed_data=False) - - @property - def with_data(self): - return self._client.rss_items(include_feed_data=True) - - -@aliased class RSSAPIMixIn(AppAPIMixIn): """ Implementation of all ``RSS`` API methods. @@ -143,34 +32,39 @@ class RSSAPIMixIn(AppAPIMixIn): """ @property - def rss(self): + def rss(self) -> RSS: """ Allows for transparent interaction with RSS endpoints. See RSS class for usage. - :return: RSS object """ if self._rss is None: self._rss = RSS(client=self) return self._rss - @alias("rss_addFolder") - @login_required - def rss_add_folder(self, folder_path=None, **kwargs): + def rss_add_folder( + self, + folder_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Add an RSS folder. Any intermediate folders in path must already exist. :raises Conflict409Error: :param folder_path: path to new folder (e.g. ``Linux\\ISOs``) - :return: None """ data = {"path": folder_path} self._post(_name=APINames.RSS, _method="addFolder", data=data, **kwargs) - @alias("rss_addFeed") - @login_required - def rss_add_feed(self, url=None, item_path=None, **kwargs): + rss_addFolder = rss_add_folder + + def rss_add_feed( + self, + url: str | None = None, + item_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Add new RSS feed. Folders in path must already exist. @@ -178,30 +72,44 @@ def rss_add_feed(self, url=None, item_path=None, **kwargs): :param url: URL of RSS feed (e.g. https://distrowatch.com/news/torrents.xml) :param item_path: Name and/or path for new feed (e.g. ``Folder\\Subfolder\\FeedName``) - :return: None """ data = {"path": item_path, "url": url} self._post(_name=APINames.RSS, _method="addFeed", data=data, **kwargs) - @endpoint_introduced("2.9.1", "rss/setFeedURL") - @alias("rss_setFeedURL") - @login_required - def rss_set_feed_url(self, url=None, item_path=None, **kwargs): + rss_addFeed = rss_add_feed + + def rss_set_feed_url( + self, + url: str | None = None, + item_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Update the URL for an existing RSS feed. + This method was introduced with qBittorrent v4.6.0 (Web API v2.9.1). + :raises Conflict409Error: :param url: URL of RSS feed (e.g. https://distrowatch.com/news/torrents.xml) :param item_path: Name and/or path for feed (e.g. ``Folder\\Subfolder\\FeedName``) - :return: None """ data = {"path": item_path, "url": url} - self._post(_name=APINames.RSS, _method="setFeedURL", data=data, **kwargs) + self._post( + _name=APINames.RSS, + _method="setFeedURL", + data=data, + version_introduced="2.9.1", + **kwargs, + ) - @alias("rss_removeItem") - @login_required - def rss_remove_item(self, item_path=None, **kwargs): + rss_setFeedURL = rss_set_feed_url + + def rss_remove_item( + self, + item_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Remove an RSS item (folder, feed, etc). @@ -210,14 +118,18 @@ def rss_remove_item(self, item_path=None, **kwargs): :raises Conflict409Error: :param item_path: path to item to be removed (e.g. ``Folder\\Subfolder\\ItemName``) - :return: None """ data = {"path": item_path} self._post(_name=APINames.RSS, _method="removeItem", data=data, **kwargs) - @alias("rss_moveItem") - @login_required - def rss_move_item(self, orig_item_path=None, new_item_path=None, **kwargs): + rss_removeItem = rss_remove_item + + def rss_move_item( + self, + orig_item_path: str | None = None, + new_item_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Move/rename an RSS item (folder, feed, etc.). @@ -225,23 +137,26 @@ def rss_move_item(self, orig_item_path=None, new_item_path=None, **kwargs): :param orig_item_path: path to item to be removed (e.g. ``Folder\\Subfolder\\ItemName``) :param new_item_path: path to item to be removed (e.g. ``Folder\\Subfolder\\ItemName``) - :return: None """ data = {"itemPath": orig_item_path, "destPath": new_item_path} self._post(_name=APINames.RSS, _method="moveItem", data=data, **kwargs) - @login_required - def rss_items(self, include_feed_data=None, **kwargs): + rss_moveItem = rss_move_item + + def rss_items( + self, + include_feed_data: bool | None = None, + **kwargs: APIKwargsT, + ) -> RSSitemsDictionary: """ Retrieve RSS items and optionally feed data. :param include_feed_data: True or false to include feed data - :return: :class:`RSSitemsDictionary` """ params = { "withData": None if include_feed_data is None else bool(include_feed_data) } - return self._get( + return self._get_cast( _name=APINames.RSS, _method="items", params=params, @@ -249,108 +164,306 @@ def rss_items(self, include_feed_data=None, **kwargs): **kwargs, ) - @endpoint_introduced("2.2", "rss/refreshItem") - @alias("rss_refreshItem") - @login_required - def rss_refresh_item(self, item_path=None, **kwargs): + def rss_refresh_item( + self, + item_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Trigger a refresh for an RSS item. - Note: qBittorrent v4.1.5 thru v4.1.8 all use Web API 2.2. However, this endpoint was - introduced with v4.1.8; so, behavior may be undefined for these versions. + Note: qBittorrent v4.1.5 through v4.1.8 all use Web API 2.2 but this endpoint + was introduced with v4.1.8; so, behavior may be undefined for these versions. :param item_path: path to item to be refreshed (e.g. ``Folder\\Subfolder\\ItemName``) - :return: None """ data = {"itemPath": item_path} - self._post(_name=APINames.RSS, _method="refreshItem", data=data, **kwargs) + self._post( + _name=APINames.RSS, + _method="refreshItem", + data=data, + version_introduced="2.2", + **kwargs, + ) + + rss_refreshItem = rss_refresh_item - @endpoint_introduced("2.5.1", "rss/markAsRead") - @alias("rss_markAsRead") - @login_required - def rss_mark_as_read(self, item_path=None, article_id=None, **kwargs): + def rss_mark_as_read( + self, + item_path: str | None = None, + article_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Mark RSS article as read. If article ID is not provider, the entire feed is marked as read. + This method was introduced with qBittorrent v4.2.5 (Web API v2.5.1). + :raises NotFound404Error: :param item_path: path to item to be refreshed (e.g. ``Folder\\Subfolder\\ItemName``) :param article_id: article ID from :meth:`~RSSAPIMixIn.rss_items` - :return: None """ data = {"itemPath": item_path, "articleId": article_id} - self._post(_name=APINames.RSS, _method="markAsRead", data=data, **kwargs) + self._post( + _name=APINames.RSS, + _method="markAsRead", + data=data, + version_introduced="2.5.1", + **kwargs, + ) - @alias("rss_setRule") - @login_required - def rss_set_rule(self, rule_name=None, rule_def=None, **kwargs): + rss_markAsRead = rss_mark_as_read + + def rss_set_rule( + self, + rule_name: str | None = None, + rule_def: Mapping[str, JsonValueT] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Create a new RSS auto-downloading rule. :param rule_name: name for new rule :param rule_def: dictionary with rule fields - ``_ - :return: None """ # noqa: E501 data = {"ruleName": rule_name, "ruleDef": dumps(rule_def)} self._post(_name=APINames.RSS, _method="setRule", data=data, **kwargs) - @alias("rss_renameRule") - @login_required - def rss_rename_rule(self, orig_rule_name=None, new_rule_name=None, **kwargs): + rss_setRule = rss_set_rule + + def rss_rename_rule( + self, + orig_rule_name: str | None = None, + new_rule_name: str | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Rename an RSS auto-download rule. - Note: this endpoint did not work properly until qBittorrent v4.3.0 + This method did not work properly until qBittorrent v4.3.0 (Web API v2.6). :param orig_rule_name: current name of rule :param new_rule_name: new name for rule - :return: None """ data = {"ruleName": orig_rule_name, "newRuleName": new_rule_name} self._post(_name=APINames.RSS, _method="renameRule", data=data, **kwargs) - @alias("rss_removeRule") - @login_required - def rss_remove_rule(self, rule_name=None, **kwargs): + rss_renameRule = rss_rename_rule + + def rss_remove_rule( + self, + rule_name: str | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Delete a RSS auto-downloading rule. :param rule_name: Name of rule to delete - :return: None """ data = {"ruleName": rule_name} self._post(_name=APINames.RSS, _method="removeRule", data=data, **kwargs) - @login_required - def rss_rules(self, **kwargs): - """ - Retrieve RSS auto-download rule definitions. - - :return: :class:`RSSRulesDictionary` - """ - return self._get( + def rss_rules(self, **kwargs: APIKwargsT) -> RSSRulesDictionary: + """Retrieve RSS auto-download rule definitions.""" + return self._get_cast( _name=APINames.RSS, _method="rules", response_class=RSSRulesDictionary, **kwargs, ) - @endpoint_introduced("2.5.1", "rss/matchingArticles") - @alias("rss_matchingArticles") - @login_required - def rss_matching_articles(self, rule_name=None, **kwargs): + rss_removeRule = rss_remove_rule + + def rss_matching_articles( + self, + rule_name: str | None = None, + **kwargs: APIKwargsT, + ) -> RSSitemsDictionary: """ Fetch all articles matching a rule. + This method was introduced with qBittorrent v4.2.5 (Web API v2.5.1). + :param rule_name: Name of rule to return matching articles - :return: :class:`RSSitemsDictionary` """ data = {"ruleName": rule_name} - return self._post( + return self._post_cast( _name=APINames.RSS, _method="matchingArticles", data=data, response_class=RSSitemsDictionary, + version_introduced="2.5.1", + **kwargs, + ) + + rss_matchingArticles = rss_matching_articles + + +class RSS(ClientCache[RSSAPIMixIn]): + """ + Allows interaction with ``RSS`` API endpoints. + + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> # this is all the same attributes that are available as named in the + >>> # endpoints or the more pythonic names in Client (with or without 'log_' prepended) + >>> rss_rules = client.rss.rules + >>> client.rss.addFolder(folder_path="TPB") + >>> client.rss.addFeed(url='...', item_path="TPB\\Top100") + >>> client.rss.remove_item(item_path="TPB") # deletes TPB and Top100 + >>> client.rss.set_rule(rule_name="...", rule_def={...}) + >>> items = client.rss.items.with_data + >>> items_no_data = client.rss.items.without_data + """ + + def __init__(self, client: RSSAPIMixIn): + super().__init__(client=client) + self.items = RSS._Items(client=client) + + @wraps(RSSAPIMixIn.rss_add_folder) + def add_folder( + self, + folder_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.rss_add_folder(folder_path=folder_path, **kwargs) + + addFolder = add_folder + + @wraps(RSSAPIMixIn.rss_add_feed) + def add_feed( + self, + url: str | None = None, + item_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.rss_add_feed(url=url, item_path=item_path, **kwargs) + + addFeed = add_feed + + @wraps(RSSAPIMixIn.rss_set_feed_url) + def set_feed_url( + self, + url: str | None = None, + item_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.rss_set_feed_url(url=url, item_path=item_path, **kwargs) + + setFeedURL = set_feed_url + + @wraps(RSSAPIMixIn.rss_remove_item) + def remove_item(self, item_path: str | None = None, **kwargs: APIKwargsT) -> None: + return self._client.rss_remove_item(item_path=item_path, **kwargs) + + removeItem = remove_item + + @wraps(RSSAPIMixIn.rss_move_item) + def move_item( + self, + orig_item_path: str | None = None, + new_item_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.rss_move_item( + orig_item_path=orig_item_path, + new_item_path=new_item_path, **kwargs, ) + + moveItem = move_item + + @wraps(RSSAPIMixIn.rss_refresh_item) + def refresh_item(self, item_path: str | None = None) -> None: + return self._client.rss_refresh_item(item_path=item_path) + + refreshItem = refresh_item + + @wraps(RSSAPIMixIn.rss_mark_as_read) + def mark_as_read( + self, + item_path: str | None = None, + article_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.rss_mark_as_read( + item_path=item_path, + article_id=article_id, + **kwargs, + ) + + markAsRead = mark_as_read + + @wraps(RSSAPIMixIn.rss_set_rule) + def set_rule( + self, + rule_name: str | None = None, + rule_def: Mapping[str, JsonValueT] | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.rss_set_rule( + rule_name=rule_name, + rule_def=rule_def, + **kwargs, + ) + + setRule = set_rule + + @wraps(RSSAPIMixIn.rss_rename_rule) + def rename_rule( + self, + orig_rule_name: str | None = None, + new_rule_name: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.rss_rename_rule( + orig_rule_name=orig_rule_name, + new_rule_name=new_rule_name, + **kwargs, + ) + + renameRule = rename_rule + + @wraps(RSSAPIMixIn.rss_remove_rule) + def remove_rule( + self, + rule_name: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.rss_remove_rule(rule_name=rule_name, **kwargs) + + removeRule = remove_rule + + @property + @wraps(RSSAPIMixIn.rss_rules) + def rules(self) -> RSSRulesDictionary: + return self._client.rss_rules() + + @wraps(RSSAPIMixIn.rss_matching_articles) + def matching_articles( + self, + rule_name: str | None = None, + **kwargs: APIKwargsT, + ) -> RSSitemsDictionary: + return self._client.rss_matching_articles(rule_name=rule_name, **kwargs) + + matchingArticles = matching_articles + + class _Items(ClientCache[RSSAPIMixIn]): + def __call__( + self, + include_feed_data: bool | None = None, + **kwargs: APIKwargsT, + ) -> RSSitemsDictionary: + return self._client.rss_items(include_feed_data=include_feed_data, **kwargs) + + @property + def without_data(self) -> RSSitemsDictionary: + return self._client.rss_items(include_feed_data=False) + + @property + def with_data(self) -> RSSitemsDictionary: + return self._client.rss_items(include_feed_data=True) diff --git a/src/qbittorrentapi/rss.pyi b/src/qbittorrentapi/rss.pyi deleted file mode 100644 index b5ef2b2b6..000000000 --- a/src/qbittorrentapi/rss.pyi +++ /dev/null @@ -1,179 +0,0 @@ -from typing import Mapping -from typing import Optional -from typing import Text - -from qbittorrentapi._types import JsonDictionaryT -from qbittorrentapi._types import JsonValueT -from qbittorrentapi._types import KwargsT -from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.definitions import ClientCache - -class RSSitemsDictionary(JsonDictionaryT): ... -class RSSRulesDictionary(JsonDictionaryT): ... - -class RSS(ClientCache): - items: _Items - def __init__(self, client: RSSAPIMixIn) -> None: ... - def add_folder( - self, - folder_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - addFolder = add_folder - def add_feed( - self, - url: Optional[Text] = None, - item_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - addFeed = add_feed - def set_feed_url( - self, - url: Optional[Text] = None, - item_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - setFeedURL = set_feed_url - def remove_item( - self, - item_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - removeItem = remove_item - def move_item( - self, - orig_item_path: Optional[Text] = None, - new_item_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - moveItem = move_item - def refresh_item(self, item_path: Optional[Text] = None) -> None: ... - refreshItem = refresh_item - def mark_as_read( - self, - item_path: Optional[Text] = None, - article_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - markAsRead = mark_as_read - def set_rule( - self, - rule_name: Optional[Text] = None, - rule_def: Optional[Mapping[Text, JsonValueT]] = None, - **kwargs: KwargsT, - ) -> None: ... - setRule = set_rule - def rename_rule( - self, - orig_rule_name: Optional[Text] = None, - new_rule_name: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - renameRule = rename_rule - def remove_rule( - self, - rule_name: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - removeRule = remove_rule - @property - def rules(self) -> RSSRulesDictionary: ... - def matching_articles( - self, - rule_name: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - matchingArticles = matching_articles - - class _Items(ClientCache): - def __call__( - self, - include_feed_data: Optional[bool] = None, - **kwargs: KwargsT, - ) -> RSSitemsDictionary: ... - @property - def without_data(self) -> RSSitemsDictionary: ... - @property - def with_data(self) -> RSSitemsDictionary: ... - -class RSSAPIMixIn(AppAPIMixIn): - @property - def rss(self) -> RSS: ... - def rss_add_folder( - self, - folder_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_addFolder = rss_add_folder - def rss_add_feed( - self, - url: Optional[Text] = None, - item_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_addFeed = rss_add_feed - def rss_set_feed_url( - self, - url: Optional[Text] = None, - item_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_setFeedURL = rss_set_feed_url - def rss_remove_item( - self, - item_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_removeItem = rss_remove_item - def rss_move_item( - self, - orig_item_path: Optional[Text] = None, - new_item_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_moveItem = rss_move_item - def rss_items( - self, - include_feed_data: Optional[bool] = None, - **kwargs: KwargsT, - ) -> RSSitemsDictionary: ... - def rss_refresh_item( - self, - item_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_refreshItem = rss_refresh_item - def rss_mark_as_read( - self, - item_path: Optional[Text] = None, - article_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_markAsRead = rss_mark_as_read - def rss_set_rule( - self, - rule_name: Optional[Text] = None, - rule_def: Optional[Mapping[Text, JsonValueT]] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_setRule = rss_set_rule - def rss_rename_rule( - self, - orig_rule_name: Optional[Text] = None, - new_rule_name: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_renameRule = rss_rename_rule - def rss_remove_rule( - self, - rule_name: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - rss_removeRule = rss_remove_rule - def rss_rules(self, **kwargs: KwargsT) -> RSSRulesDictionary: ... - def rss_matching_articles( - self, - rule_name: Optional[Text] = None, - **kwargs: KwargsT, - ) -> RSSitemsDictionary: ... - rss_matchingArticles = rss_matching_articles diff --git a/src/qbittorrentapi/search.py b/src/qbittorrentapi/search.py index 02893b677..d9886ad16 100644 --- a/src/qbittorrentapi/search.py +++ b/src/qbittorrentapi/search.py @@ -1,166 +1,70 @@ +from __future__ import annotations + +from functools import wraps +from typing import Iterable +from typing import Mapping +from typing import cast + from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.decorators import alias -from qbittorrentapi.decorators import aliased -from qbittorrentapi.decorators import endpoint_introduced -from qbittorrentapi.decorators import login_required -from qbittorrentapi.decorators import version_removed +from qbittorrentapi.definitions import APIKwargsT from qbittorrentapi.definitions import APINames from qbittorrentapi.definitions import ClientCache from qbittorrentapi.definitions import Dictionary +from qbittorrentapi.definitions import JsonValueT from qbittorrentapi.definitions import List from qbittorrentapi.definitions import ListEntry +from qbittorrentapi.definitions import ListInputT -class SearchJobDictionary(Dictionary): - """Response for :meth:`~SearchAPIMixIn.search_start`""" - - def __init__(self, data, client): - self._search_job_id = data.get("id", None) - super().__init__(data=data, client=client) - - def stop(self, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_stop`""" - return self._client.search_stop(search_id=self._search_job_id, **kwargs) - - def status(self, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_status`""" - return self._client.search_status(search_id=self._search_job_id, **kwargs) - - def results(self, limit=None, offset=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_results`""" - return self._client.search_results( - limit=limit, offset=offset, search_id=self._search_job_id, **kwargs - ) - - def delete(self, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_delete`""" - return self._client.search_delete(search_id=self._search_job_id, **kwargs) - - -class SearchResultsDictionary(Dictionary): - """Response for :meth:`~SearchAPIMixIn.search_results`""" - - -class SearchStatusesList(List): - """Response for :meth:`~SearchAPIMixIn.search_status`""" +class SearchResultsDictionary(Dictionary[JsonValueT]): + """ + Response for :meth:`~SearchAPIMixIn.search_results` - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=SearchStatus, client=client) + Definition: ``_ + """ # noqa: E501 class SearchStatus(ListEntry): """Item in :class:`SearchStatusesList`""" -class SearchCategoriesList(List): - """Response for :meth:`~SearchAPIMixIn.search_categories`""" +class SearchStatusesList(List[SearchStatus]): + """ + Response for :meth:`~SearchAPIMixIn.search_status` - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=SearchCategory, client=client) + Definition: ``_ + """ # noqa: E501 + + def __init__(self, list_entries: ListInputT, client: SearchAPIMixIn | None = None): + super().__init__(list_entries, entry_class=SearchStatus, client=client) class SearchCategory(ListEntry): """Item in :class:`SearchCategoriesList`""" -class SearchPluginsList(List): - """Response for :meth:`~SearchAPIMixIn.search_plugins`""" +class SearchCategoriesList(List[SearchCategory]): + """Response for :meth:`~SearchAPIMixIn.search_categories`""" - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=SearchPlugin, client=client) + def __init__(self, list_entries: ListInputT, client: SearchAPIMixIn | None = None): + super().__init__(list_entries, entry_class=SearchCategory, client=client) class SearchPlugin(ListEntry): """Item in :class:`SearchPluginsList`""" -@aliased -class Search(ClientCache): +class SearchPluginsList(List[SearchPlugin]): """ - Allows interaction with ``Search`` API endpoints. + Response for :meth:`~SearchAPIMixIn.search_plugins`. - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> # this is all the same attributes that are available as named in the - >>> # endpoints or the more pythonic names in Client (with or without 'search_' prepended) - >>> # initiate searches and retrieve results - >>> search_job = client.search.start(pattern='Ubuntu', plugins='all', category='all') - >>> status = search_job.status() - >>> results = search_job.result() - >>> search_job.delete() - >>> # inspect and manage plugins - >>> plugins = client.search.plugins - >>> cats = client.search.categories(plugin_name='...') - >>> client.search.install_plugin(sources='...') - >>> client.search.update_plugins() + Definition: ``_ """ - def start(self, pattern=None, plugins=None, category=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_start`""" - return self._client.search_start( - pattern=pattern, - plugins=plugins, - category=category, - **kwargs, - ) - - def stop(self, search_id=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_stop`""" - return self._client.search_stop(search_id=search_id, **kwargs) - - def status(self, search_id=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_status`""" - return self._client.search_status(search_id=search_id, **kwargs) - - def results(self, search_id=None, limit=None, offset=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_results`""" - return self._client.search_results( - search_id=search_id, - limit=limit, - offset=offset, - **kwargs, - ) - - def delete(self, search_id=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_delete`""" - return self._client.search_delete(search_id=search_id, **kwargs) - - def categories(self, plugin_name=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_categories`""" - return self._client.search_categories(plugin_name=plugin_name, **kwargs) - - @property - def plugins(self): - """Implements :meth:`~SearchAPIMixIn.search_plugins`""" - return self._client.search_plugins() - - @alias("installPlugin") - def install_plugin(self, sources=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_install_plugin`""" - return self._client.search_install_plugin(sources=sources, **kwargs) - - @alias("uninstallPlugin") - def uninstall_plugin(self, sources=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_uninstall_plugin`""" - return self._client.search_uninstall_plugin(sources=sources, **kwargs) - - @alias("enablePlugin") - def enable_plugin(self, plugins=None, enable=None, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_enable_plugin`""" - return self._client.search_enable_plugin( - plugins=plugins, - enable=enable, - **kwargs, - ) - - @alias("updatePlugins") - def update_plugins(self, **kwargs): - """Implements :meth:`~SearchAPIMixIn.search_update_plugins`""" - return self._client.search_update_plugins(**kwargs) + def __init__(self, list_entries: ListInputT, client: SearchAPIMixIn | None = None): + super().__init__(list_entries, entry_class=SearchPlugin, client=client) -@aliased class SearchAPIMixIn(AppAPIMixIn): """ Implementation for all ``Search`` API methods. @@ -176,204 +80,437 @@ class SearchAPIMixIn(AppAPIMixIn): """ @property - def search(self): + def search(self) -> Search: """ Allows for transparent interaction with ``Search`` endpoints. See Search class for usage. - :return: Search object """ if self._search is None: self._search = Search(client=self) return self._search - @endpoint_introduced("2.1.1", "search/start") - @login_required - def search_start(self, pattern=None, plugins=None, category=None, **kwargs): + def search_start( + self, + pattern: str | None = None, + plugins: Iterable[str] | None = None, + category: str | None = None, + **kwargs: APIKwargsT, + ) -> SearchJobDictionary: """ Start a search. Python must be installed. Host may limit number of concurrent searches. + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). + :raises Conflict409Error: :param pattern: term to search for - :param plugins: list of plugins to use for searching (supports 'all' and 'enabled') - :param category: categories to limit search; dependent on plugins. (supports 'all') - :return: :class:`SearchJobDictionary` + :param plugins: list of plugins to use for searching (supports 'all' and + 'enabled') + :param category: categories to limit search; dependent on plugins. (supports + 'all') """ data = { "pattern": pattern, "plugins": self._list2string(plugins, "|"), "category": category, } - return self._post( + return self._post_cast( _name=APINames.Search, _method="start", data=data, response_class=SearchJobDictionary, + version_introduced="2.1.1", **kwargs, ) - @endpoint_introduced("2.1.1", "search/stop") - @login_required - def search_stop(self, search_id=None, **kwargs): + def search_stop( + self, + search_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Stop a running search. + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). + :raises NotFound404Error: :param search_id: ID of search job to stop - :return: None """ data = {"id": search_id} - self._post(_name=APINames.Search, _method="stop", data=data, **kwargs) + self._post( + _name=APINames.Search, + _method="stop", + data=data, + version_introduced="2.1.1", + **kwargs, + ) - @endpoint_introduced("2.1.1", "search/status") - @login_required - def search_status(self, search_id=None, **kwargs): + def search_status( + self, + search_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> SearchStatusesList: """ Retrieve status of one or all searches. + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). + :raises NotFound404Error: :param search_id: ID of search to get status; leave empty for status of all jobs - :return: :class:`SearchStatusesList` - ``_ - """ # noqa: E501 + """ params = {"id": search_id} - return self._get( + return self._get_cast( _name=APINames.Search, _method="status", params=params, response_class=SearchStatusesList, + version_introduced="2.1.1", **kwargs, ) - @endpoint_introduced("2.1.1", "search/results") - @login_required - def search_results(self, search_id=None, limit=None, offset=None, **kwargs): + def search_results( + self, + search_id: str | int | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + **kwargs: APIKwargsT, + ) -> SearchResultsDictionary: """ Retrieve the results for the search. + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). + :raises NotFound404Error: :raises Conflict409Error: :param search_id: ID of search job :param limit: number of results to return :param offset: where to start returning results - :return: :class:`SearchResultsDictionary` - ``_ - """ # noqa: E501 + """ data = {"id": search_id, "limit": limit, "offset": offset} - return self._post( + return self._post_cast( _name=APINames.Search, _method="results", data=data, response_class=SearchResultsDictionary, + version_introduced="2.1.1", **kwargs, ) - @endpoint_introduced("2.1.1", "search/delete") - @login_required - def search_delete(self, search_id=None, **kwargs): + def search_delete( + self, + search_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Delete a search job. + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). + :raises NotFound404Error: :param search_id: ID of search to delete - :return: None """ data = {"id": search_id} - self._post(_name=APINames.Search, _method="delete", data=data, **kwargs) + self._post( + _name=APINames.Search, + _method="delete", + data=data, + version_introduced="2.1.1", + **kwargs, + ) - @endpoint_introduced("2.1.1", "search/categories") - @version_removed("2.6", "search/categories") - @login_required - def search_categories(self, plugin_name=None, **kwargs): + def search_categories( + self, + plugin_name: str | None = None, + **kwargs: APIKwargsT, + ) -> SearchCategoriesList: """ Retrieve categories for search. + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1) and removed + with qBittorrent v4.3.0 (Web API v2.6). + Note: endpoint was removed in qBittorrent v4.3.0 :param plugin_name: Limit categories returned by plugin(s) (supports ``all`` and ``enabled``) - :return: :class:`SearchCategoriesList` """ data = {"pluginName": plugin_name} - return self._post( + return self._post_cast( _name=APINames.Search, _method="categories", data=data, response_class=SearchCategoriesList, + version_introduced="2.1.1", + version_removed="2.6", **kwargs, ) - @endpoint_introduced("2.1.1", "search/plugins") - @login_required - def search_plugins(self, **kwargs): + def search_plugins(self, **kwargs: APIKwargsT) -> SearchPluginsList: """ Retrieve details of search plugins. - :return: :class:`SearchPluginsList` - ``_ - """ # noqa: E501 - return self._get( + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). + """ + return self._get_cast( _name=APINames.Search, _method="plugins", response_class=SearchPluginsList, + version_introduced="2.1.1", **kwargs, ) - @endpoint_introduced("2.1.1", "search/installPlugin") - @alias("search_installPlugin") - @login_required - def search_install_plugin(self, sources=None, **kwargs): + def search_install_plugin( + self, + sources: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Install search plugins from either URL or file. + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). + :param sources: list of URLs or filepaths - :return: None """ data = {"sources": self._list2string(sources, "|")} - self._post(_name=APINames.Search, _method="installPlugin", data=data, **kwargs) + self._post( + _name=APINames.Search, + _method="installPlugin", + data=data, + version_introduced="2.1.1", + **kwargs, + ) + + search_installPlugin = search_install_plugin - @endpoint_introduced("2.1.1", "search/uninstallPlugin") - @alias("search_uninstallPlugin") - @login_required - def search_uninstall_plugin(self, names=None, **kwargs): + def search_uninstall_plugin( + self, + names: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Uninstall search plugins. + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). + :param names: names of plugins to uninstall - :return: None """ data = {"names": self._list2string(names, "|")} self._post( _name=APINames.Search, _method="uninstallPlugin", data=data, + version_introduced="2.1.1", **kwargs, ) - @endpoint_introduced("2.1.1", "search/enablePlugin") - @alias("search_enablePlugin") - @login_required - def search_enable_plugin(self, plugins=None, enable=None, **kwargs): + search_uninstallPlugin = search_uninstall_plugin + + def search_enable_plugin( + self, + plugins: Iterable[str] | None = None, + enable: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Enable or disable search plugin(s). + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). + :param plugins: list of plugin names :param enable: Defaults to ``True`` if ``None`` or unset; use ``False`` to disable - :return: None """ data = { "names": self._list2string(plugins, "|"), "enable": True if enable is None else bool(enable), } - self._post(_name=APINames.Search, _method="enablePlugin", data=data, **kwargs) + self._post( + _name=APINames.Search, + _method="enablePlugin", + data=data, + version_introduced="2.1.1", + **kwargs, + ) + + search_enablePlugin = search_enable_plugin - @endpoint_introduced("2.1.1", "search/updatePlugin") - @alias("search_updatePlugins") - @login_required - def search_update_plugins(self, **kwargs): + def search_update_plugins(self, **kwargs: APIKwargsT) -> None: """ Auto update search plugins. - :return: None + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). """ - self._post(_name=APINames.Search, _method="updatePlugins", **kwargs) + self._post( + _name=APINames.Search, + _method="updatePlugins", + version_introduced="2.1.1", + **kwargs, + ) + + search_updatePlugins = search_update_plugins + + +class SearchJobDictionary(ClientCache[SearchAPIMixIn], Dictionary[JsonValueT]): + """Response for :meth:`~SearchAPIMixIn.search_start`""" + + def __init__(self, data: Mapping[str, JsonValueT], client: SearchAPIMixIn): + self._search_job_id: int | None = cast(int, data.get("id", None)) + super().__init__(data=data, client=client) + + @wraps(SearchAPIMixIn.search_stop) + def stop(self, **kwargs: APIKwargsT) -> None: + self._client.search_stop(search_id=self._search_job_id, **kwargs) + + @wraps(SearchAPIMixIn.search_start) + def status(self, **kwargs: APIKwargsT) -> SearchStatusesList: + return self._client.search_status(search_id=self._search_job_id, **kwargs) + + @wraps(SearchAPIMixIn.search_results) + def results( + self, + limit: str | int | None = None, + offset: str | int | None = None, + **kwargs: APIKwargsT, + ) -> SearchResultsDictionary: + return self._client.search_results( + limit=limit, + offset=offset, + search_id=self._search_job_id, + **kwargs, + ) + + @wraps(SearchAPIMixIn.search_delete) + def delete(self, **kwargs: APIKwargsT) -> None: + return self._client.search_delete(search_id=self._search_job_id, **kwargs) + + +class Search(ClientCache[SearchAPIMixIn]): + """ + Allows interaction with ``Search`` API endpoints. + + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> # this is all the same attributes that are available as named in the + >>> # endpoints or the more pythonic names in Client (with or without 'search_' prepended) + >>> # initiate searches and retrieve results + >>> search_job = client.search.start(pattern='Ubuntu', plugins='all', category='all') + >>> status = search_job.status() + >>> results = search_job.result() + >>> search_job.delete() + >>> # inspect and manage plugins + >>> plugins = client.search.plugins + >>> cats = client.search.categories(plugin_name='...') + >>> client.search.install_plugin(sources='...') + >>> client.search.update_plugins() + """ + + @wraps(SearchAPIMixIn.search_start) + def start( + self, + pattern: str | None = None, + plugins: Iterable[str] | None = None, + category: str | None = None, + **kwargs: APIKwargsT, + ) -> SearchJobDictionary: + return self._client.search_start( + pattern=pattern, + plugins=plugins, + category=category, + **kwargs, + ) + + @wraps(SearchAPIMixIn.search_stop) + def stop( + self, + search_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.search_stop(search_id=search_id, **kwargs) + + @wraps(SearchAPIMixIn.search_status) + def status( + self, + search_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> SearchStatusesList: + return self._client.search_status(search_id=search_id, **kwargs) + + @wraps(SearchAPIMixIn.search_results) + def results( + self, + search_id: str | int | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + **kwargs: APIKwargsT, + ) -> SearchResultsDictionary: + return self._client.search_results( + search_id=search_id, + limit=limit, + offset=offset, + **kwargs, + ) + + @wraps(SearchAPIMixIn.search_delete) + def delete( + self, + search_id: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.search_delete(search_id=search_id, **kwargs) + + @wraps(SearchAPIMixIn.search_categories) + def categories( + self, + plugin_name: str | None = None, + **kwargs: APIKwargsT, + ) -> SearchCategoriesList: + return self._client.search_categories(plugin_name=plugin_name, **kwargs) + + @property + @wraps(SearchAPIMixIn.search_plugins) + def plugins(self) -> SearchPluginsList: + return self._client.search_plugins() + + @wraps(SearchAPIMixIn.search_install_plugin) + def install_plugin( + self, + sources: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.search_install_plugin(sources=sources, **kwargs) + + installPlugin = install_plugin + + @wraps(SearchAPIMixIn.search_uninstall_plugin) + def uninstall_plugin( + self, + sources: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.search_uninstall_plugin(sources=sources, **kwargs) + + uninstallPlugin = uninstall_plugin + + @wraps(SearchAPIMixIn.search_enable_plugin) + def enable_plugin( + self, + plugins: Iterable[str] | None = None, + enable: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.search_enable_plugin( + plugins=plugins, + enable=enable, + **kwargs, + ) + + enablePlugin = enable_plugin + + @wraps(SearchAPIMixIn.search_update_plugins) + def update_plugins(self, **kwargs: APIKwargsT) -> None: + return self._client.search_update_plugins(**kwargs) + + updatePlugins = update_plugins diff --git a/src/qbittorrentapi/search.pyi b/src/qbittorrentapi/search.pyi deleted file mode 100644 index bf6eb6146..000000000 --- a/src/qbittorrentapi/search.pyi +++ /dev/null @@ -1,175 +0,0 @@ -from typing import Iterable -from typing import Optional -from typing import Text - -from qbittorrentapi._types import DictInputT -from qbittorrentapi._types import JsonDictionaryT -from qbittorrentapi._types import KwargsT -from qbittorrentapi._types import ListInputT -from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.definitions import ClientCache -from qbittorrentapi.definitions import List -from qbittorrentapi.definitions import ListEntry - -class SearchJobDictionary(JsonDictionaryT): - def __init__( - self, - data: DictInputT, - client: SearchAPIMixIn, - ) -> None: ... - def stop(self, **kwargs: KwargsT) -> None: ... - def status(self, **kwargs: KwargsT) -> SearchStatusesList: ... - def results( - self, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> SearchResultsDictionary: ... - def delete(self, **kwargs: KwargsT) -> None: ... - -class SearchResultsDictionary(JsonDictionaryT): ... -class SearchStatus(ListEntry): ... - -class SearchStatusesList(List[SearchStatus]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[SearchAPIMixIn] = None, - ) -> None: ... - -class SearchCategory(ListEntry): ... - -class SearchCategoriesList(List[SearchCategory]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[SearchAPIMixIn] = None, - ) -> None: ... - -class SearchPlugin(ListEntry): ... - -class SearchPluginsList(List[SearchPlugin]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[SearchAPIMixIn] = None, - ) -> None: ... - -class Search(ClientCache): - def start( - self, - pattern: Optional[Text] = None, - plugins: Optional[Iterable[Text]] = None, - category: Optional[Text] = None, - **kwargs: KwargsT, - ) -> SearchJobDictionary: ... - def stop( - self, - search_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def status( - self, - search_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> SearchStatusesList: ... - def results( - self, - search_id: Optional[Text | int] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> SearchResultsDictionary: ... - def delete( - self, - search_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def categories( - self, - plugin_name: Optional[Text] = None, - **kwargs: KwargsT, - ) -> SearchCategoriesList: ... - @property - def plugins(self) -> SearchPluginsList: ... - def install_plugin( - self, - sources: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - installPlugin = install_plugin - def uninstall_plugin( - self, - sources: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - uninstallPlugin = uninstall_plugin - def enable_plugin( - self, - plugins: Optional[Iterable[Text]] = None, - enable: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - enablePlugin = enable_plugin - def update_plugins(self, **kwargs: KwargsT) -> None: ... - updatePlugins = update_plugins - -class SearchAPIMixIn(AppAPIMixIn): - @property - def search(self) -> Search: ... - def search_start( - self, - pattern: Optional[Text] = None, - plugins: Optional[Iterable[Text]] = None, - category: Optional[Text] = None, - **kwargs: KwargsT, - ) -> SearchJobDictionary: ... - def search_stop( - self, - search_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def search_status( - self, - search_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> SearchStatusesList: ... - def search_results( - self, - search_id: Optional[Text | int] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> SearchResultsDictionary: ... - def search_delete( - self, - search_id: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def search_categories( - self, - plugin_name: Optional[Text] = None, - **kwargs: KwargsT, - ) -> SearchCategoriesList: ... - def search_plugins(self, **kwargs: KwargsT) -> SearchPluginsList: ... - def search_install_plugin( - self, - sources: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - search_installPlugin = search_install_plugin - def search_uninstall_plugin( - self, - names: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - search_uninstallPlugin = search_uninstall_plugin - def search_enable_plugin( - self, - plugins: Optional[Iterable[Text]] = None, - enable: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - search_enablePlugin = search_enable_plugin - def search_update_plugins(self, **kwargs: KwargsT) -> None: ... - search_updatePlugins = search_update_plugins diff --git a/src/qbittorrentapi/sync.py b/src/qbittorrentapi/sync.py index c290af700..04feb68c8 100644 --- a/src/qbittorrentapi/sync.py +++ b/src/qbittorrentapi/sync.py @@ -1,83 +1,31 @@ +from __future__ import annotations + +from typing import cast + from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.decorators import alias -from qbittorrentapi.decorators import aliased -from qbittorrentapi.decorators import handle_hashes -from qbittorrentapi.decorators import login_required +from qbittorrentapi.definitions import APIKwargsT from qbittorrentapi.definitions import APINames from qbittorrentapi.definitions import ClientCache from qbittorrentapi.definitions import Dictionary +from qbittorrentapi.definitions import JsonValueT -class SyncMainDataDictionary(Dictionary): - """Response for :meth:`~SyncAPIMixIn.sync_maindata`""" - - -class SyncTorrentPeersDictionary(Dictionary): - """Response for :meth:`~SyncAPIMixIn.sync_torrent_peers`""" - - -class Sync(ClientCache): +class SyncMainDataDictionary(Dictionary[JsonValueT]): """ - Allows interaction with the ``Sync`` API endpoints. + Response for :meth:`~SyncAPIMixIn.sync_maindata` - Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> # these are all the same attributes that are available as named in the - >>> # endpoints or the more pythonic names in Client (with or without 'sync_' prepended) - >>> maindata = client.sync.maindata(rid="...") - >>> # for use when continuously calling maindata for changes in torrents - >>> # this will automatically request the changes since the last call - >>> md = client.sync.maindata.delta() - >>> # - >>> torrentPeers = client.sync.torrentPeers(hash="...'", rid='...') - >>> torrent_peers = client.sync.torrent_peers(hash="...'", rid='...') - """ + Definition: ``_ + """ # noqa: E501 - def __init__(self, client): - super().__init__(client=client) - self.maindata = self._MainData(client=client) - self.torrent_peers = self._TorrentPeers(client=client) - self.torrentPeers = self.torrent_peers - - class _MainData(ClientCache): - def __init__(self, client): - super().__init__(client=client) - self._rid = 0 - def __call__(self, rid=None, **kwargs): - return self._client.sync_maindata(rid=rid, **kwargs) - - def delta(self, **kwargs): - md = self._client.sync_maindata(rid=self._rid, **kwargs) - self._rid = md.get("rid", 0) - return md - - def reset_rid(self): - self._rid = 0 - - class _TorrentPeers(ClientCache): - def __init__(self, client): - super().__init__(client=client) - self._rid = None - - def __call__(self, torrent_hash=None, rid=None, **kwargs): - return self._client.sync_torrent_peers( - torrent_hash=torrent_hash, rid=rid, **kwargs - ) +class SyncTorrentPeersDictionary(Dictionary[JsonValueT]): + """ + Response for :meth:`~SyncAPIMixIn.sync_torrent_peers` - def delta(self, torrent_hash=None, **kwargs): - tp = self._client.sync_torrent_peers( - torrent_hash=torrent_hash, rid=self._rid, **kwargs - ) - self._rid = tp.get("rid", 0) - return tp + Definition: ``_ + """ # noqa: E501 - def reset_rid(self): - self._rid = 0 - -@aliased class SyncAPIMixIn(AppAPIMixIn): """ Implementation of all ``Sync`` API Methods. @@ -90,27 +38,28 @@ class SyncAPIMixIn(AppAPIMixIn): """ @property - def sync(self): + def sync(self) -> Sync: """ Allows for transparent interaction with ``Sync`` endpoints. See Sync class for usage. - :return: Transfer object """ if self._sync is None: self._sync = Sync(client=self) return self._sync - @login_required - def sync_maindata(self, rid=0, **kwargs): + def sync_maindata( + self, + rid: str | int = 0, + **kwargs: APIKwargsT, + ) -> SyncMainDataDictionary: """ Retrieves sync data. :param rid: response ID - :return: :class:`SyncMainDataDictionary` - ``_ - """ # noqa: E501 + """ data = {"rid": rid} - return self._post( + return self._post_cast( _name=APINames.Sync, _method="maindata", data=data, @@ -118,10 +67,12 @@ def sync_maindata(self, rid=0, **kwargs): **kwargs, ) - @alias("sync_torrentPeers") - @handle_hashes - @login_required - def sync_torrent_peers(self, torrent_hash=None, rid=0, **kwargs): + def sync_torrent_peers( + self, + torrent_hash: str | None = None, + rid: str | int = 0, + **kwargs: APIKwargsT, + ) -> SyncTorrentPeersDictionary: """ Retrieves torrent sync data. @@ -129,13 +80,92 @@ def sync_torrent_peers(self, torrent_hash=None, rid=0, **kwargs): :param torrent_hash: hash for torrent :param rid: response ID - :return: :class:`SyncTorrentPeersDictionary` - ``_ - """ # noqa: E501 + """ data = {"hash": torrent_hash, "rid": rid} - return self._post( + return self._post_cast( _name=APINames.Sync, _method="torrentPeers", data=data, response_class=SyncTorrentPeersDictionary, **kwargs, ) + + sync_torrentPeers = sync_torrent_peers + + +class Sync(ClientCache[SyncAPIMixIn]): + """ + Allows interaction with the ``Sync`` API endpoints. + + Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> # these are all the same attributes that are available as named in the + >>> # endpoints or the more pythonic names in Client (with or without 'sync_' prepended) + >>> maindata = client.sync.maindata(rid="...") + >>> # for use when continuously calling maindata for changes in torrents + >>> # this will automatically request the changes since the last call + >>> md = client.sync.maindata.delta() + >>> # + >>> torrentPeers = client.sync.torrentPeers(hash="...'", rid='...') + >>> torrent_peers = client.sync.torrent_peers(hash="...'", rid='...') + """ + + def __init__(self, client: SyncAPIMixIn) -> None: + super().__init__(client=client) + self.maindata = self._MainData(client=client) + self.torrent_peers = self._TorrentPeers(client=client) + self.torrentPeers = self.torrent_peers + + class _MainData(ClientCache[SyncAPIMixIn]): + def __init__(self, client: SyncAPIMixIn) -> None: + super().__init__(client=client) + self._rid: int = 0 + + def __call__( + self, + rid: str | int = 0, + **kwargs: APIKwargsT, + ) -> SyncMainDataDictionary: + return self._client.sync_maindata(rid=rid, **kwargs) + + def delta(self, **kwargs: APIKwargsT) -> SyncMainDataDictionary: + md = self._client.sync_maindata(rid=self._rid, **kwargs) + self._rid = cast(int, md.get("rid", 0)) + return md + + def reset_rid(self) -> None: + self._rid = 0 + + class _TorrentPeers(ClientCache["SyncAPIMixIn"]): + def __init__(self, client: SyncAPIMixIn) -> None: + super().__init__(client=client) + self._rid: int = 0 + + def __call__( + self, + torrent_hash: str | None = None, + rid: str | int = 0, + **kwargs: APIKwargsT, + ) -> SyncTorrentPeersDictionary: + return self._client.sync_torrent_peers( + torrent_hash=torrent_hash, + rid=rid, + **kwargs, + ) + + def delta( + self, + torrent_hash: str | None = None, + **kwargs: APIKwargsT, + ) -> SyncTorrentPeersDictionary: + torrent_peers = self._client.sync_torrent_peers( + torrent_hash=torrent_hash, + rid=self._rid, + **kwargs, + ) + self._rid = cast(int, torrent_peers.get("rid", 0)) + return torrent_peers + + def reset_rid(self) -> None: + self._rid = 0 diff --git a/src/qbittorrentapi/sync.pyi b/src/qbittorrentapi/sync.pyi deleted file mode 100644 index 388db966b..000000000 --- a/src/qbittorrentapi/sync.pyi +++ /dev/null @@ -1,63 +0,0 @@ -from typing import Optional -from typing import Text - -from qbittorrentapi._types import JsonValueT -from qbittorrentapi._types import KwargsT -from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.definitions import ClientCache -from qbittorrentapi.definitions import Dictionary - -# mypy crashes when this is imported from _types... -JsonDictionaryT = Dictionary[Text, JsonValueT] - -class SyncMainDataDictionary(JsonDictionaryT): ... -class SyncTorrentPeersDictionary(JsonDictionaryT): ... - -class Sync(ClientCache): - maindata: _MainData - torrent_peers: _TorrentPeers - torrentPeers: _TorrentPeers - def __init__(self, client: SyncAPIMixIn) -> None: ... - - class _MainData(ClientCache): - _rid: int | None - def __init__(self, client: SyncAPIMixIn) -> None: ... - def __call__( - self, - rid: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> SyncMainDataDictionary: ... - def delta(self, **kwargs: KwargsT) -> SyncMainDataDictionary: ... - def reset_rid(self) -> None: ... - - class _TorrentPeers(ClientCache): - _rid: int | None - def __init__(self, client: SyncAPIMixIn) -> None: ... - def __call__( - self, - torrent_hash: Optional[Text] = None, - rid: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> SyncTorrentPeersDictionary: ... - def delta( - self, - torrent_hash: Optional[Text] = None, - **kwargs: KwargsT, - ) -> SyncTorrentPeersDictionary: ... - def reset_rid(self) -> None: ... - -class SyncAPIMixIn(AppAPIMixIn): - @property - def sync(self) -> Sync: ... - def sync_maindata( - self, - rid: Text | int = 0, - **kwargs: KwargsT, - ) -> SyncMainDataDictionary: ... - def sync_torrent_peers( - self, - torrent_hash: Optional[Text] = None, - rid: Text | int = 0, - **kwargs: KwargsT, - ) -> SyncTorrentPeersDictionary: ... - sync_torrentPeers = sync_torrent_peers diff --git a/src/qbittorrentapi/torrents.py b/src/qbittorrentapi/torrents.py index 1e430e8c9..0900b7118 100644 --- a/src/qbittorrentapi/torrents.py +++ b/src/qbittorrentapi/torrents.py @@ -1,2470 +1,2854 @@ +from __future__ import annotations + import errno -from collections.abc import Iterable -from collections.abc import Mapping +from functools import wraps +from logging import Logger from logging import getLogger from os import path from os import strerror as os_strerror +from typing import IO +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Literal +from typing import Mapping +from typing import MutableMapping +from typing import TypeVar +from typing import Union +from typing import cast from qbittorrentapi._version_support import v from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.decorators import alias -from qbittorrentapi.decorators import aliased -from qbittorrentapi.decorators import check_for_raise -from qbittorrentapi.decorators import endpoint_introduced -from qbittorrentapi.decorators import handle_hashes -from qbittorrentapi.decorators import login_required +from qbittorrentapi.definitions import APIKwargsT from qbittorrentapi.definitions import APINames from qbittorrentapi.definitions import ClientCache from qbittorrentapi.definitions import Dictionary +from qbittorrentapi.definitions import FilesToSendT +from qbittorrentapi.definitions import JsonValueT from qbittorrentapi.definitions import List from qbittorrentapi.definitions import ListEntry +from qbittorrentapi.definitions import ListInputT from qbittorrentapi.definitions import TorrentState from qbittorrentapi.exceptions import TorrentFileError from qbittorrentapi.exceptions import TorrentFileNotFoundError from qbittorrentapi.exceptions import TorrentFilePermissionError -logger = getLogger(__name__) +#: Type for Torrent Status. +TorrentStatusesT = Literal[ + "all", + "downloading", + "seeding", + "completed", + "paused", + "active", + "inactive", + "resumed", + "stalled", + "stalled_uploading", + "stalled_downloading", + "checking", + "moving", + "errored", +] + +#: Type for input of files to API method. +TorrentFilesT = TypeVar( + "TorrentFilesT", + bytes, + str, + IO[bytes], + Mapping[str, Union[bytes, str, IO[bytes]]], + Iterable[Union[bytes, str, IO[bytes]]], +) + +logger: Logger = getLogger(__name__) + + +class TorrentPropertiesDictionary(Dictionary[JsonValueT]): + """ + Response to :meth:`~TorrentsAPIMixIn.torrents_properties` + Definition: ``_ + """ # noqa: E501 -@aliased -class TorrentDictionary(Dictionary): - """ - Item in :class:`TorrentInfoList`. Allows interaction with individual torrents via - the ``Torrents`` API endpoints. - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> # these are all the same attributes that are available as named in the - >>> # endpoints or the more pythonic names in Client (with or without 'transfer_' prepended) - >>> torrent = client.torrents.info()[0] - >>> torrent_hash = torrent.info.hash - >>> # Attributes without inputs and a return value are properties - >>> properties = torrent.properties - >>> trackers = torrent.trackers - >>> files = torrent.files - >>> # Action methods - >>> torrent.edit_tracker(original_url="...", new_url="...") - >>> torrent.remove_trackers(urls='http://127.0.0.2/') - >>> torrent.rename(new_torrent_name="...") - >>> torrent.resume() - >>> torrent.pause() - >>> torrent.recheck() - >>> torrent.torrents_top_priority() - >>> torrent.setLocation(location='/home/user/torrents/') - >>> torrent.setCategory(category='video') - """ +class TorrentLimitsDictionary(Dictionary[JsonValueT]): + """Response to :meth:`~TorrentsAPIMixIn.torrents_download_limit`""" - def __init__(self, data, client): - self._torrent_hash = data.get("hash", None) - # The countdown to the next announce was added in v5.0.0. - # To avoid clashing with `reannounce()`, rename to `reannounce_in`. - if "reannounce" in data: - data["reannounce_in"] = data.pop("reannounce") - super().__init__(client=client, data=data) - def sync_local(self): - """Update local cache of torrent info.""" - for name, value in self.info.items(): - setattr(self, name, value) +class TorrentCategoriesDictionary(Dictionary[JsonValueT]): + """Response to :meth:`~TorrentsAPIMixIn.torrents_categories`""" - @property - def state_enum(self): - """Returns the state of a :class:`~qbittorrentapi.definitions.TorrentState`.""" - try: - return TorrentState(self.state) - except ValueError: - return TorrentState.UNKNOWN - @property - def info(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_info`""" - info = self._client.torrents_info(torrent_hashes=self._torrent_hash) - if len(info) == 1 and info[0].hash == self._torrent_hash: - return info[0] +class TorrentsAddPeersDictionary(Dictionary[JsonValueT]): + """Response to :meth:`~TorrentsAPIMixIn.torrents_add_peers`""" - # qBittorrent v4.1.0 didn't support torrent hash parameter - info = [t for t in self._client.torrents_info() if t.hash == self._torrent_hash] - if len(info) == 1: - return info[0] - return TorrentDictionary(data={}, client=self._client) +class TorrentFile(ListEntry): + """Item in :class:`TorrentFilesList`""" - def resume(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_resume`""" - self._client.torrents_resume(torrent_hashes=self._torrent_hash, **kwargs) - def pause(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_pause`""" - self._client.torrents_pause(torrent_hashes=self._torrent_hash, **kwargs) +class TorrentFilesList(List[TorrentFile]): + """ + Response to :meth:`~TorrentsAPIMixIn.torrents_files` - def delete(self, delete_files=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_delete`""" - self._client.torrents_delete( - delete_files=delete_files, torrent_hashes=self._torrent_hash, **kwargs - ) + Definition: ``_ + """ # noqa: E501 - def recheck(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_recheck`""" - self._client.torrents_recheck(torrent_hashes=self._torrent_hash, **kwargs) + def __init__( + self, + list_entries: ListInputT, + client: TorrentsAPIMixIn | None = None, + ): + super().__init__(list_entries, entry_class=TorrentFile, client=client) + # until v4.3.5, the index key wasn't returned...default it to ID for older versions. + # when index is returned, maintain backwards compatibility and populate id with index value. + for i, entry in enumerate(self): + entry.update({"id": entry.setdefault("index", i)}) - def reannounce(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_reannounce`""" - self._client.torrents_reannounce(torrent_hashes=self._torrent_hash, **kwargs) - @alias("increasePrio") - def increase_priority(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_increase_priority`""" - self._client.torrents_increase_priority( - torrent_hashes=self._torrent_hash, - **kwargs, - ) +class WebSeed(ListEntry): + """Item in :class:`WebSeedsList`""" - @alias("decreasePrio") - def decrease_priority(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_decrease_priority`""" - self._client.torrents_decrease_priority( - torrent_hashes=self._torrent_hash, - **kwargs, - ) - @alias("topPrio") - def top_priority(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_top_priority`""" - self._client.torrents_top_priority(torrent_hashes=self._torrent_hash, **kwargs) +class WebSeedsList(List[WebSeed]): + """ + Response to :meth:`~TorrentsAPIMixIn.torrents_webseeds` - @alias("bottomPrio") - def bottom_priority(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_bottom_priority`""" - self._client.torrents_bottom_priority( - torrent_hashes=self._torrent_hash, - **kwargs, - ) + Definition: ``_ + """ # noqa: E501 - @alias("setShareLimits") - def set_share_limits( + def __init__( self, - ratio_limit=None, - seeding_time_limit=None, - inactive_seeding_time_limit=None, - **kwargs, + list_entries: ListInputT, + client: TorrentsAPIMixIn | None = None, ): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_share_limits`""" - self._client.torrents_set_share_limits( - ratio_limit=ratio_limit, - seeding_time_limit=seeding_time_limit, - inactive_seeding_time_limit=inactive_seeding_time_limit, - torrent_hashes=self._torrent_hash, - **kwargs, - ) + super().__init__(list_entries, entry_class=WebSeed, client=client) - @property - def download_limit(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_download_limit`""" - return self._client.torrents_download_limit( - torrent_hashes=self._torrent_hash - ).get(self._torrent_hash) - @download_limit.setter - def download_limit(self, v): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_download_limit`""" - self.set_download_limit(limit=v) +class Tracker(ListEntry): + """Item in :class:`TrackersList`""" - downloadLimit = download_limit - @downloadLimit.setter - def downloadLimit(self, v): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_download_limit`""" - self.download_limit = v +class TrackersList(List[Tracker]): + """ + Response to :meth:`~TorrentsAPIMixIn.torrents_trackers` - @alias("setDownloadLimit") - def set_download_limit(self, limit=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_download_limit`""" - self._client.torrents_set_download_limit( - limit=limit, - torrent_hashes=self._torrent_hash, - **kwargs, - ) + Definition: ``_ + """ # noqa: E501 - @property - def upload_limit(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_upload_limit`""" - return self._client.torrents_upload_limit( - torrent_hashes=self._torrent_hash - ).get(self._torrent_hash) + def __init__( + self, + list_entries: ListInputT, + client: TorrentsAPIMixIn | None = None, + ): + super().__init__(list_entries, entry_class=Tracker, client=client) - @upload_limit.setter - def upload_limit(self, v): - """Implements :meth:`~TorrentsAPIMixIn.set_upload_limit`""" - self.set_upload_limit(limit=v) - uploadLimit = upload_limit +class TorrentInfoList(List["TorrentDictionary"]): + """ + Response to :meth:`~TorrentsAPIMixIn.torrents_info` - @uploadLimit.setter - def uploadLimit(self, v): - """Implements :meth:`~TorrentsAPIMixIn.set_upload_limit`""" - self.upload_limit = v + Definition: ``_ + """ # noqa: E501 - @alias("setUploadLimit") - def set_upload_limit(self, limit=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_upload_limit`""" - self._client.torrents_set_upload_limit( - limit=limit, - torrent_hashes=self._torrent_hash, - **kwargs, - ) + def __init__( + self, + list_entries: ListInputT, + client: TorrentsAPIMixIn | None = None, + ): + super().__init__(list_entries, entry_class=TorrentDictionary, client=client) - @alias("setLocation") - def set_location(self, location=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_location`""" - self._client.torrents_set_location( - location=location, - torrent_hashes=self._torrent_hash, - **kwargs, - ) - @alias("setSavePath") - def set_save_path(self, save_path=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_save_path`""" - self._client.torrents_set_save_path( - save_path=save_path, - torrent_hashes=self._torrent_hash, - **kwargs, - ) +class TorrentPieceData(ListEntry): + """Item in :class:`TorrentPieceInfoList`""" - @alias("setDownloadPath") - def set_download_path(self, download_path=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_download_path`""" - self._client.torrents_set_download_path( - download_path=download_path, - torrent_hashes=self._torrent_hash, - **kwargs, - ) - @alias("setCategory") - def set_category(self, category=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_category`""" - self._client.torrents_set_category( - category=category, - torrent_hashes=self._torrent_hash, - **kwargs, - ) +class TorrentPieceInfoList(List[TorrentPieceData]): + """Response to :meth:`~TorrentsAPIMixIn.torrents_piece_states` and + :meth:`~TorrentsAPIMixIn.torrents_piece_hashes`""" - @alias("setAutoManagement") - def set_auto_management(self, enable=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_auto_management`""" - self._client.torrents_set_auto_management( - enable=enable, - torrent_hashes=self._torrent_hash, - **kwargs, - ) + def __init__( + self, + list_entries: ListInputT, + client: TorrentsAPIMixIn | None = None, + ): + super().__init__(list_entries, entry_class=TorrentPieceData, client=client) - @alias("toggleSequentialDownload") - def toggle_sequential_download(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_toggle_sequential_download`""" - self._client.torrents_toggle_sequential_download( - torrent_hashes=self._torrent_hash, **kwargs - ) - @alias("toggleFirstLastPiecePrio") - def toggle_first_last_piece_priority(self, **kwargs): - """Implements - :meth:`~TorrentsAPIMixIn.torrents_toggle_first_last_piece_priority`""" - self._client.torrents_toggle_first_last_piece_priority( - torrent_hashes=self._torrent_hash, - **kwargs, - ) +class Tag(ListEntry): + """Item in :class:`TagList`""" - @alias("setForceStart") - def set_force_start(self, enable=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_force_start`""" - self._client.torrents_set_force_start( - enable=enable, - torrent_hashes=self._torrent_hash, - **kwargs, - ) - @alias("setSuperSeeding") - def set_super_seeding(self, enable=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_set_super_seeding`""" - self._client.torrents_set_super_seeding( - enable=enable, - torrent_hashes=self._torrent_hash, - **kwargs, - ) +class TagList(List[Tag]): + """Response to :meth:`~TorrentsAPIMixIn.torrents_tags`""" - @property - def properties(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_properties`""" - return self._client.torrents_properties(torrent_hash=self._torrent_hash) + def __init__( + self, + list_entries: ListInputT, + client: TorrentsAPIMixIn | None = None, + ): + super().__init__(list_entries, entry_class=Tag, client=client) - @property - def trackers(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_trackers`""" - return self._client.torrents_trackers(torrent_hash=self._torrent_hash) - @trackers.setter - def trackers(self, v): - """Implements :meth:`~TorrentsAPIMixIn.add_trackers`""" - self.add_trackers(urls=v) +class TorrentsAPIMixIn(AppAPIMixIn): + """ + Implementation of all Torrents API methods. - @property - def webseeds(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_webseeds`""" - return self._client.torrents_webseeds(torrent_hash=self._torrent_hash) + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> client.torrents_add(urls='...') + >>> client.torrents_reannounce() + """ @property - def files(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_files`""" - return self._client.torrents_files(torrent_hash=self._torrent_hash) - - @alias("renameFile") - def rename_file( - self, - file_id=None, - new_file_name=None, - old_path=None, - new_path=None, - **kwargs, - ): - """Implements :meth:`~TorrentsAPIMixIn.torrents_rename_file`""" - self._client.torrents_rename_file( - torrent_hash=self._torrent_hash, - file_id=file_id, - new_file_name=new_file_name, - old_path=old_path, - new_path=new_path, - **kwargs, - ) + def torrents(self) -> Torrents: + """ + Allows for transparent interaction with Torrents endpoints. - @alias("renameFolder") - def rename_folder(self, old_path=None, new_path=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_rename_folder`""" - self._client.torrents_rename_folder( - torrent_hash=self._torrent_hash, - old_path=old_path, - new_path=new_path, - **kwargs, - ) + See Torrents and Torrent class for usage. + """ + if self._torrents is None: + self._torrents = Torrents(client=self) + return self._torrents @property - def piece_states(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_piece_states`""" - return self._client.torrents_piece_states(torrent_hash=self._torrent_hash) + def torrent_categories(self) -> TorrentCategories: + """ + Allows for transparent interaction with Torrent Categories endpoints. - pieceStates = piece_states + See Torrent_Categories class for usage. + """ + if self._torrent_categories is None: + self._torrent_categories = TorrentCategories(client=self) + return self._torrent_categories @property - def piece_hashes(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_piece_hashes`""" - return self._client.torrents_piece_hashes(torrent_hash=self._torrent_hash) + def torrent_tags(self) -> TorrentTags: + """ + Allows for transparent interaction with Torrent Tags endpoints. - pieceHashes = piece_hashes + See Torrent_Tags class for usage. + """ + if self._torrent_tags is None: + self._torrent_tags = TorrentTags(client=self) + return self._torrent_tags - @alias("addTrackers") - def add_trackers(self, urls=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_add_trackers`""" - self._client.torrents_add_trackers( - torrent_hash=self._torrent_hash, urls=urls, **kwargs - ) + def torrents_add( + self, + urls: Iterable[str] | None = None, + torrent_files: TorrentFilesT | None = None, + save_path: str | None = None, + cookie: str | None = None, + category: str | None = None, + is_skip_checking: bool | None = None, + is_paused: bool | None = None, + is_root_folder: bool | None = None, + rename: str | None = None, + upload_limit: str | int | None = None, + download_limit: str | int | None = None, + use_auto_torrent_management: bool | None = None, + is_sequential_download: bool | None = None, + is_first_last_piece_priority: bool | None = None, + tags: Iterable[str] | None = None, + content_layout: None | (Literal["Original", "Subfolder", "NoSubFolder"]) = None, + ratio_limit: str | float | None = None, + seeding_time_limit: str | int | None = None, + download_path: str | None = None, + use_download_path: bool | None = None, + stop_condition: Literal["MetadataReceived", "FilesChecked"] | None = None, + **kwargs: APIKwargsT, + ) -> str: + """ + Add one or more torrents by URLs and/or torrent files. - @alias("editTracker") - def edit_tracker(self, orig_url=None, new_url=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_edit_tracker`""" - self._client.torrents_edit_tracker( - torrent_hash=self._torrent_hash, - original_url=orig_url, - new_url=new_url, - **kwargs, - ) + Returns ``Ok.`` for success and ``Fails.`` for failure. - @alias("removeTrackers") - def remove_trackers(self, urls=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_remove_trackers`""" - self._client.torrents_remove_trackers( - torrent_hash=self._torrent_hash, - urls=urls, - **kwargs, - ) + :raises UnsupportedMediaType415Error: if file is not a valid torrent file + :raises TorrentFileNotFoundError: if a torrent file doesn't exist + :raises TorrentFilePermissionError: if read permission is denied to torrent file - @alias("filePriority") - def file_priority(self, file_ids=None, priority=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_file_priority`""" - self._client.torrents_file_priority( - torrent_hash=self._torrent_hash, - file_ids=file_ids, - priority=priority, - **kwargs, - ) - - def rename(self, new_name=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_rename`""" - self._client.torrents_rename( - torrent_hash=self._torrent_hash, new_torrent_name=new_name, **kwargs - ), - - @alias("addTags") - def add_tags(self, tags=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_add_tags`""" - self._client.torrents_add_tags( - torrent_hashes=self._torrent_hash, - tags=tags, - **kwargs, - ) + :param urls: single instance or an iterable of URLs (``http://``, ``https://``, ``magnet:``, ``bc://bt/``) + :param torrent_files: several options are available to send torrent files to qBittorrent: - @alias("removeTags") - def remove_tags(self, tags=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_remove_tags`""" - self._client.torrents_remove_tags( - torrent_hashes=self._torrent_hash, - tags=tags, - **kwargs, - ) + * single instance of bytes: useful if torrent file already read from disk or downloaded from internet. + * single instance of file handle to torrent file: use ``open(, 'rb')`` to open the torrent file. + * single instance of a filepath to torrent file: e.g. ``/home/user/torrent_filename.torrent`` + * an iterable of the single instances above to send more than one torrent file + * dictionary with key/value pairs of torrent name and single instance of above object - def export(self, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_export`""" - return self._client.torrents_export(torrent_hash=self._torrent_hash, **kwargs) + Note: The torrent name in a dictionary is useful to identify which torrent file + errored. qBittorrent provides back that name in the error text. If a torrent + name is not provided, then the name of the file will be used. And in the case of + bytes (or if filename cannot be determined), the value 'torrent__n' will be used. + :param save_path: location to save the torrent data + :param cookie: cookie to retrieve torrents by URL + :param category: category to assign to torrent(s) + :param is_skip_checking: ``True`` to skip hash checking + :param is_paused: ``True`` to add the torrent(s) without starting their downloading + :param is_root_folder: ``True`` or ``False`` to create root folder (superseded by content_layout with v4.3.2) + :param rename: new name for torrent(s) + :param upload_limit: upload limit in bytes/second + :param download_limit: download limit in bytes/second + :param use_auto_torrent_management: ``True`` or ``False`` to use automatic torrent management + :param is_sequential_download: ``True`` or ``False`` for sequential download + :param is_first_last_piece_priority: ``True`` or ``False`` for first and last piece download priority + :param tags: tag(s) to assign to torrent(s) (added in Web API 2.6.2) + :param content_layout: ``Original``, ``Subfolder``, or ``NoSubfolder`` to control filesystem structure for content (added in Web API 2.7) + :param ratio_limit: share limit as ratio of upload amt over download amt; e.g. 0.5 or 2.0 (added in Web API 2.8.1) + :param seeding_time_limit: number of minutes to seed torrent (added in Web API 2.8.1) + :param download_path: location to download torrent content before moving to ``save_path`` (added in Web API 2.8.4) + :param use_download_path: ``True`` or ``False`` whether ``download_path`` should be used...defaults to ``True`` if ``download_path`` is specified (added in Web API 2.8.4) + :param stop_condition: ``MetadataReceived`` or ``FilesChecked`` to stop the torrent when started automatically (added in Web API 2.8.15) + """ # noqa: E501 + # convert pre-v2.7 params to post-v2.7 params...or post-v2.7 to pre-v2.7 + api_version = self.app_web_api_version() + if ( + content_layout is None + and is_root_folder is not None + and v(api_version) >= v("2.7") + ): + content_layout = "Original" if is_root_folder else "NoSubfolder" # type: ignore[assignment] + is_root_folder = None + elif ( + content_layout is not None + and is_root_folder is None + and v(api_version) < v("2.7") + ): + is_root_folder = content_layout in {"Subfolder", "Original"} + content_layout = None -class TorrentPropertiesDictionary(Dictionary): - """Response to :meth:`~TorrentsAPIMixIn.torrents_properties`""" + # default to actually using the specified download path + if use_download_path is None and download_path is not None: + use_download_path = True + data = { + "urls": (None, self._list2string(urls, "\n")), + "savepath": (None, save_path), + "cookie": (None, cookie), + "category": (None, category), + "tags": (None, self._list2string(tags, ",")), + "skip_checking": (None, is_skip_checking), + "paused": (None, is_paused), + "root_folder": (None, is_root_folder), + "contentLayout": (None, content_layout), + "rename": (None, rename), + "upLimit": (None, upload_limit), + "dlLimit": (None, download_limit), + "ratioLimit": (None, ratio_limit), + "seedingTimeLimit": (None, seeding_time_limit), + "autoTMM": (None, use_auto_torrent_management), + "sequentialDownload": (None, is_sequential_download), + "firstLastPiecePrio": (None, is_first_last_piece_priority), + "downloadPath": (None, download_path), + "useDownloadPath": (None, use_download_path), + "stopCondition": (None, stop_condition), + } -class TorrentLimitsDictionary(Dictionary): - """Response to :meth:`~TorrentsAPIMixIn.torrents_download_limit`""" + return self._post_cast( + _name=APINames.Torrents, + _method="add", + data=data, + files=self._normalize_torrent_files(torrent_files), + response_class=str, + **kwargs, + ) + @staticmethod + def _normalize_torrent_files( + user_files: TorrentFilesT | None, + ) -> FilesToSendT | None: + """ + Normalize the torrent file(s) from the user. -class TorrentCategoriesDictionary(Dictionary): - """Response to :meth:`~TorrentsAPIMixIn.torrents_categories`""" + The file(s) can be the raw bytes, file handle, filepath for a + torrent file, or an iterable (e.g. list|set|tuple) of any of + these "files". Further, the file(s) can be in a dictionary with + the "names" of the torrents as the keys. These "names" can be + anything...but are mostly useful as identifiers for each file. + """ + if not user_files: + return None + prefix = "torrent__" + # if it's string-like and not a list|set|tuple, then make it a list + # checking for 'read' attr since a single file handle is iterable but also needs to be in a list + is_string_like = isinstance(user_files, (bytes, str)) + is_file_like = hasattr(user_files, "read") + if is_string_like or is_file_like or not isinstance(user_files, Iterable): + user_files = [user_files] # type: ignore -class TorrentsAddPeersDictionary(Dictionary): - """Response to :meth:`~TorrentsAPIMixIn.torrents_add_peers`""" + # up convert to a dictionary to add fabricated torrent names + norm_files = ( + user_files + if isinstance(user_files, Mapping) + else {prefix + str(i): f for i, f in enumerate(user_files)} + ) + files = {} + for name, torrent_file in norm_files.items(): + try: + if isinstance(torrent_file, (bytes, bytearray)): + # if bytes, assume it's a torrent file that was downloaded or read from disk + torrent_bytes = torrent_file + elif hasattr(torrent_file, "read"): + # if hasattr('read'), assume this is a file handle from open() or similar + torrent_bytes = torrent_file.read() + else: + # otherwise, coerce to a string and try to open it as a file + filepath = path.abspath( + path.realpath(path.expanduser(str(torrent_file))) + ) + name = path.basename(filepath) + with open(filepath, "rb") as file: + torrent_bytes = file.read() -class TorrentFilesList(List): - """Response to :meth:`~TorrentsAPIMixIn.torrents_files`""" + # if using default name, let Requests try to figure out the filename to send + # Requests will fall back to "name" as the dict key if fh doesn't provide a file name + files[name] = ( + torrent_bytes if name.startswith(prefix) else (name, torrent_bytes) + ) + except OSError as io_err: + if io_err.errno == errno.ENOENT: + raise TorrentFileNotFoundError( + errno.ENOENT, os_strerror(errno.ENOENT), torrent_file + ) + if io_err.errno == errno.EACCES: + raise TorrentFilePermissionError( + errno.ENOENT, os_strerror(errno.EACCES), torrent_file + ) + raise TorrentFileError(io_err) + return files - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=TorrentFile, client=client) - # until v4.3.5, the index key wasn't returned...default it to ID for older versions. - # when index is returned, maintain backwards compatibility and populate id with index value. - for i, entry in enumerate(self): - entry.update({"id": entry.setdefault("index", i)}) + ########################################################################## + # INDIVIDUAL TORRENT ENDPOINTS + ########################################################################## + def torrents_properties( + self, + torrent_hash: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentPropertiesDictionary: + """ + Retrieve individual torrent's properties. + :raises NotFound404Error: -class TorrentFile(ListEntry): - """Item in :class:`TorrentFilesList`""" + :param torrent_hash: hash for torrent + """ + data = {"hash": torrent_hash} + return self._post_cast( + _name=APINames.Torrents, + _method="properties", + data=data, + response_class=TorrentPropertiesDictionary, + **kwargs, + ) + def torrents_trackers( + self, + torrent_hash: str | None = None, + **kwargs: APIKwargsT, + ) -> TrackersList: + """ + Retrieve individual torrent's trackers. Tracker status is defined in + :class:`~qbittorrentapi.definitions.TrackerStatus`. -class WebSeedsList(List): - """Response to :meth:`~TorrentsAPIMixIn.torrents_webseeds`""" + :raises NotFound404Error: - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=WebSeed, client=client) + :param torrent_hash: hash for torrent + """ + data = {"hash": torrent_hash} + return self._post_cast( + _name=APINames.Torrents, + _method="trackers", + data=data, + response_class=TrackersList, + **kwargs, + ) + def torrents_webseeds( + self, + torrent_hash: str | None = None, + **kwargs: APIKwargsT, + ) -> WebSeedsList: + """ + Retrieve individual torrent's web seeds. -class WebSeed(ListEntry): - """Item in :class:`WebSeedsList`""" + :raises NotFound404Error: + :param torrent_hash: hash for torrent + """ # noqa: E501 + data = {"hash": torrent_hash} + return self._post_cast( + _name=APINames.Torrents, + _method="webseeds", + data=data, + response_class=WebSeedsList, + **kwargs, + ) -class TrackersList(List): - """Response to :meth:`~TorrentsAPIMixIn.torrents_trackers`""" + def torrents_files( + self, + torrent_hash: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentFilesList: + """ + Retrieve individual torrent's files. - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=Tracker, client=client) + :raises NotFound404Error: + :param torrent_hash: hash for torrent + """ + data = {"hash": torrent_hash} + return self._post_cast( + _name=APINames.Torrents, + _method="files", + data=data, + response_class=TorrentFilesList, + **kwargs, + ) -class Tracker(ListEntry): - """Item in :class:`TrackersList`""" + def torrents_piece_states( + self, + torrent_hash: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentPieceInfoList: + """ + Retrieve individual torrent's pieces' states. + :raises NotFound404Error: -class TorrentInfoList(List): - """Response to :meth:`~TorrentsAPIMixIn.torrents_info`""" + :param torrent_hash: hash for torrent + """ + data = {"hash": torrent_hash} + return self._post_cast( + _name=APINames.Torrents, + _method="pieceStates", + data=data, + response_class=TorrentPieceInfoList, + **kwargs, + ) - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=TorrentDictionary, client=client) + torrents_pieceStates = torrents_piece_states + def torrents_piece_hashes( + self, + torrent_hash: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentPieceInfoList: + """ + Retrieve individual torrent's pieces' hashes. -class TorrentPieceInfoList(List): - """Response to :meth:`~TorrentsAPIMixIn.torrents_piece_states` and - :meth:`~TorrentsAPIMixIn.torrents_piece_hashes`""" + :raises NotFound404Error: - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=TorrentPieceData, client=client) + :param torrent_hash: hash for torrent + """ + data = {"hash": torrent_hash} + return self._post_cast( + _name=APINames.Torrents, + _method="pieceHashes", + data=data, + response_class=TorrentPieceInfoList, + **kwargs, + ) + torrents_pieceHashes = torrents_piece_hashes -class TorrentPieceData(ListEntry): - """Item in :class:`TorrentPieceInfoList`""" + def torrents_add_trackers( + self, + torrent_hash: str | None = None, + urls: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Add trackers to a torrent. + :raises NotFound404Error: + :param torrent_hash: hash for torrent + :param urls: tracker URLs to add to torrent + """ + data = { + "hash": torrent_hash, + "urls": self._list2string(urls, "\n"), + } + self._post(_name=APINames.Torrents, _method="addTrackers", data=data, **kwargs) -class TagList(List): - """Response to :meth:`~TorrentsAPIMixIn.torrents_tags`""" + torrents_addTrackers = torrents_add_trackers - def __init__(self, list_entries, client=None): - super().__init__(list_entries, entry_class=Tag, client=client) + def torrents_edit_tracker( + self, + torrent_hash: str | None = None, + original_url: str | None = None, + new_url: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Replace a torrent's tracker with a different one. + This method was introduced with qBittorrent v4.1.4 (Web API v2.2.0). -class Tag(ListEntry): - """Item in :class:`TagList`""" + :raises InvalidRequest400Error: + :raises NotFound404Error: + :raises Conflict409Error: + :param torrent_hash: hash for torrent + :param original_url: URL for existing tracker + :param new_url: new URL to replace + """ + data = { + "hash": torrent_hash, + "origUrl": original_url, + "newUrl": new_url, + } + self._post( + _name=APINames.Torrents, + _method="editTracker", + data=data, + version_introduced="2.2.0", + **kwargs, + ) + torrents_editTracker = torrents_edit_tracker -class Torrents(ClientCache): - """ - Allows interaction with the ``Torrents`` API endpoints. + def torrents_remove_trackers( + self, + torrent_hash: str | None = None, + urls: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Remove trackers from a torrent. - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> # these are all the same attributes that are available as named in the - >>> # endpoints or the more pythonic names in Client (with or without 'torrents_' prepended) - >>> torrent_list = client.torrents.info() - >>> torrent_list_active = client.torrents.info.active() - >>> torrent_list_active_partial = client.torrents.info.active(limit=100, offset=200) - >>> torrent_list_downloading = client.torrents.info.downloading() - >>> # torrent looping - >>> for torrent in client.torrents.info.completed() - >>> # all torrents endpoints with a 'hashes' parameters support all method to apply action to all torrents - >>> client.torrents.pause.all() - >>> client.torrents.resume.all() - >>> # or specify the individual hashes - >>> client.torrents.downloadLimit(torrent_hashes=['...', '...']) - """ + This method was introduced with qBittorrent v4.1.4 (Web API v2.2.0). - def __init__(self, client): - super().__init__(client=client) - self.info = self._Info(client=client) - self.resume = self._ActionForAllTorrents( - client=client, func=client.torrents_resume - ) - self.pause = self._ActionForAllTorrents( - client=client, func=client.torrents_pause - ) - self.delete = self._ActionForAllTorrents( - client=client, func=client.torrents_delete - ) - self.recheck = self._ActionForAllTorrents( - client=client, func=client.torrents_recheck - ) - self.reannounce = self._ActionForAllTorrents( - client=client, func=client.torrents_reannounce - ) - self.increase_priority = self._ActionForAllTorrents( - client=client, func=client.torrents_increase_priority - ) - self.increasePrio = self.increase_priority - self.decrease_priority = self._ActionForAllTorrents( - client=client, func=client.torrents_decrease_priority - ) - self.decreasePrio = self.decrease_priority - self.top_priority = self._ActionForAllTorrents( - client=client, func=client.torrents_top_priority - ) - self.topPrio = self.top_priority - self.bottom_priority = self._ActionForAllTorrents( - client=client, func=client.torrents_bottom_priority - ) - self.bottomPrio = self.bottom_priority - self.download_limit = self._ActionForAllTorrents( - client=client, func=client.torrents_download_limit - ) - self.downloadLimit = self.download_limit - self.upload_limit = self._ActionForAllTorrents( - client=client, func=client.torrents_upload_limit - ) - self.uploadLimit = self.upload_limit - self.set_download_limit = self._ActionForAllTorrents( - client=client, func=client.torrents_set_download_limit - ) - self.setDownloadLimit = self.set_download_limit - self.set_share_limits = self._ActionForAllTorrents( - client=client, func=client.torrents_set_share_limits - ) - self.setShareLimits = self.set_share_limits - self.set_upload_limit = self._ActionForAllTorrents( - client=client, func=client.torrents_set_upload_limit - ) - self.setUploadLimit = self.set_upload_limit - self.set_location = self._ActionForAllTorrents( - client=client, func=client.torrents_set_location - ) - self.set_save_path = self._ActionForAllTorrents( - client=client, func=client.torrents_set_save_path - ) - self.setSavePath = self.set_save_path - self.set_download_path = self._ActionForAllTorrents( - client=client, func=client.torrents_set_download_path - ) - self.setDownloadPath = self.set_download_path - self.setLocation = self.set_location - self.set_category = self._ActionForAllTorrents( - client=client, func=client.torrents_set_category - ) - self.setCategory = self.set_category - self.set_auto_management = self._ActionForAllTorrents( - client=client, func=client.torrents_set_auto_management + :raises NotFound404Error: + :raises Conflict409Error: + :param torrent_hash: hash for torrent + :param urls: tracker URLs to removed from torrent + """ + data = { + "hash": torrent_hash, + "urls": self._list2string(urls, "|"), + } + self._post( + _name=APINames.Torrents, + _method="removeTrackers", + data=data, + version_introduced="2.2.0", + **kwargs, ) - self.setAutoManagement = self.set_auto_management - self.toggle_sequential_download = self._ActionForAllTorrents( - client=client, func=client.torrents_toggle_sequential_download + + torrents_removeTrackers = torrents_remove_trackers + + def torrents_file_priority( + self, + torrent_hash: str | None = None, + file_ids: int | Iterable[str | int] | None = None, + priority: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set priority for one or more files. + + :raises InvalidRequest400Error: if priority is invalid or at least one file ID is not an integer + :raises NotFound404Error: + :raises Conflict409Error: if torrent metadata has not finished downloading or at least one file was not found + :param torrent_hash: hash for torrent + :param file_ids: single file ID or a list. + :param priority: priority for file(s) - ``_ + """ # noqa: E501 + data = { + "hash": torrent_hash, + "id": self._list2string(file_ids, "|"), + "priority": priority, + } + self._post(_name=APINames.Torrents, _method="filePrio", data=data, **kwargs) + + torrents_filePrio = torrents_file_priority + + def torrents_rename( + self, + torrent_hash: str | None = None, + new_torrent_name: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Rename a torrent. + + :raises NotFound404Error: + :param torrent_hash: hash for torrent + :param new_torrent_name: new name for torrent + """ + data = {"hash": torrent_hash, "name": new_torrent_name} + self._post(_name=APINames.Torrents, _method="rename", data=data, **kwargs) + + def torrents_rename_file( + self, + torrent_hash: str | None = None, + file_id: str | int | None = None, + new_file_name: str | None = None, + old_path: str | None = None, + new_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Rename a torrent file. + + This method was introduced with qBittorrent v4.2.1 (Web API v2.4.0). + + :raises MissingRequiredParameters400Error: + :raises NotFound404Error: + :raises Conflict409Error: + :param torrent_hash: hash for torrent + :param file_id: id for file (removed in Web API 2.7) + :param new_file_name: new name for file (removed in Web API 2.7) + :param old_path: path of file to rename (added in Web API 2.7) + :param new_path: new path of file to rename (added in Web API 2.7) + """ + # convert pre-v2.7 params to post-v2.7...or post-v2.7 to pre-v2.7 + # HACK: v4.3.2 and v4.3.3 both use Web API 2.7 but old/new_path were introduced in v4.3.3 + if ( + old_path is None + and new_path is None + and file_id is not None + and v(self.app_version()) >= v("v4.3.3") + ): + try: + old_path = self.torrents_files(torrent_hash=torrent_hash)[file_id].name # type: ignore[index] + except (IndexError, AttributeError, TypeError): + logger.debug( + "ERROR: File ID '%s' isn't valid...'oldPath' cannot be determined.", + file_id, + ) + old_path = "" + new_path = new_file_name or "" + elif ( + old_path is not None + and new_path is not None + and file_id is None + and v(self.app_version()) < v("v4.3.3") + ): + # previous only allowed renaming the file...not also moving it + new_file_name = new_path.split("/")[-1] + for file in self.torrents_files(torrent_hash=torrent_hash): + if file.name == old_path: + file_id = file.id + break + else: + logger.debug( + "ERROR: old_path '%s' isn't valid...'file_id' cannot be determined.", + old_path, + ) + file_id = "" + + data = { + "hash": torrent_hash, + "id": file_id, + "name": new_file_name, + "oldPath": old_path, + "newPath": new_path, + } + self._post( + _name=APINames.Torrents, + _method="renameFile", + data=data, + version_introduced="2.4.0", + **kwargs, ) - self.toggleSequentialDownload = self.toggle_sequential_download - self.toggle_first_last_piece_priority = self._ActionForAllTorrents( - client=client, func=client.torrents_toggle_first_last_piece_priority + + torrents_renameFile = torrents_rename_file + + def torrents_rename_folder( + self, + torrent_hash: str | None = None, + old_path: str | None = None, + new_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Rename a torrent folder. + + This method was introduced with qBittorrent v4.3.2 (Web API v2.7). + + :raises MissingRequiredParameters400Error: + :raises NotFound404Error: + :raises Conflict409Error: + :param torrent_hash: hash for torrent + :param old_path: path of file to rename (added in Web API 2.7) + :param new_path: new path of file to rename (added in Web API 2.7) + """ + # HACK: v4.3.2 and v4.3.3 both use Web API 2.7 but rename_folder was introduced in v4.3.3 + if v(self.app_version()) >= v("v4.3.3"): + data = { + "hash": torrent_hash, + "oldPath": old_path, + "newPath": new_path, + } + self._post( + _name=APINames.Torrents, + _method="renameFolder", + data=data, + version_introduced="2.7", + **kwargs, + ) + else: + # only get here on v4.3.2...so hack in raising exception + if self._RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: + raise NotImplementedError( + "ERROR: Endpoint 'torrents/renameFolder' is Not Implemented in this version of qBittorrent. " + "This endpoint is available starting in Web API 2.7." + ) + + torrents_renameFolder = torrents_rename_folder + + def torrents_export( + self, + torrent_hash: str | None = None, + **kwargs: APIKwargsT, + ) -> bytes: + """ + Export a .torrent file for the torrent. + + This method was introduced with qBittorrent v4.5.0 (Web API v2.8.14). + + :raises NotFound404Error: torrent not found + :raises Conflict409Error: unable to export .torrent file + :param torrent_hash: hash for torrent + """ + data = {"hash": torrent_hash} + return self._post_cast( + _name=APINames.Torrents, + _method="export", + data=data, + response_class=bytes, + version_introduced="2.8.14", + **kwargs, ) - self.toggleFirstLastPiecePrio = self.toggle_first_last_piece_priority - self.set_force_start = self._ActionForAllTorrents( - client=client, func=client.torrents_set_force_start + + ########################################################################## + # MULTIPLE TORRENT ENDPOINTS + ########################################################################## + def torrents_info( + self, + status_filter: TorrentStatusesT | None = None, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + """ + Retrieves list of info for torrents. + + :param status_filter: Filter list by torrent status. + ``all``, ``downloading``, ``seeding``, ``completed``, ``paused`` + ``active``, ``inactive``, ``resumed``, ``errored`` + Added in Web API 2.4.1: + ``stalled``, ``stalled_uploading``, and ``stalled_downloading`` + Added in Web API 2.8.4: + ``checking`` + Added in Web API 2.8.18: + ``moving`` + :param category: Filter list by category + :param sort: Sort list by any property returned + :param reverse: Reverse sorting + :param limit: Limit length of list + :param offset: Start of list (if < 0, offset from end of list) + :param torrent_hashes: Filter list by hash (separate multiple hashes with a '|') (added in Web API 2.0.1) + :param tag: Filter list by tag (empty string means "untagged"; no "tag" parameter means "any tag"; added in Web API 2.8.3) + """ # noqa: E501 + data = { + "filter": status_filter, + "category": category, + "sort": sort, + "reverse": reverse, + "limit": limit, + "offset": offset, + "hashes": self._list2string(torrent_hashes, "|"), + "tag": tag, + } + return self._post_cast( + _name=APINames.Torrents, + _method="info", + data=data, + response_class=TorrentInfoList, + **kwargs, ) - self.setForceStart = self.set_force_start - self.set_super_seeding = self._ActionForAllTorrents( - client=client, func=client.torrents_set_super_seeding + + def torrents_resume( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Resume one or more torrents in qBittorrent. + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + self._post(_name=APINames.Torrents, _method="resume", data=data, **kwargs) + + def torrents_pause( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Pause one or more torrents in qBittorrent. + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + self._post(_name=APINames.Torrents, _method="pause", data=data, **kwargs) + + def torrents_delete( + self, + delete_files: bool | None = False, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Remove a torrent from qBittorrent and optionally delete its files. + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + :param delete_files: True to delete the torrent's files + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "deleteFiles": bool(delete_files), + } + self._post(_name=APINames.Torrents, _method="delete", data=data, **kwargs) + + def torrents_recheck( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Recheck a torrent in qBittorrent. + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + self._post(_name=APINames.Torrents, _method="recheck", data=data, **kwargs) + + def torrents_reannounce( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Reannounce a torrent. + + This method was introduced with qBittorrent v4.1.2 (Web API v2.0.2). + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + self._post( + _name=APINames.Torrents, + _method="reannounce", + data=data, + version_introduced="2.0.2", + **kwargs, ) - self.setSuperSeeding = self.set_super_seeding - self.add_peers = self._ActionForAllTorrents( - client=client, func=client.torrents_add_peers + + def torrents_increase_priority( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Increase the priority of a torrent. Torrent Queuing must be enabled. + + :raises Conflict409Error: + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + self._post(_name=APINames.Torrents, _method="increasePrio", data=data, **kwargs) + + torrents_increasePrio = torrents_increase_priority + + def torrents_decrease_priority( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Decrease the priority of a torrent. Torrent Queuing must be enabled. + + :raises Conflict409Error: + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + self._post(_name=APINames.Torrents, _method="decreasePrio", data=data, **kwargs) + + torrents_decreasePrio = torrents_decrease_priority + + def torrents_top_priority( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set torrent as highest priority. Torrent Queuing must be enabled. + + :raises Conflict409Error: + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + self._post(_name=APINames.Torrents, _method="topPrio", data=data, **kwargs) + + torrents_topPrio = torrents_top_priority + + def torrents_bottom_priority( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set torrent as lowest priority. Torrent Queuing must be enabled. + + :raises Conflict409Error: + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + self._post(_name=APINames.Torrents, _method="bottomPrio", data=data, **kwargs) + + torrents_bottomPrio = torrents_bottom_priority + + def torrents_download_limit( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> TorrentLimitsDictionary: + """Retrieve the download limit for one or more torrents.""" + data = {"hashes": self._list2string(torrent_hashes, "|")} + return self._post_cast( + _name=APINames.Torrents, + _method="downloadLimit", + data=data, + response_class=TorrentLimitsDictionary, + **kwargs, ) - self.addPeers = self.add_peers - def add( + torrents_downloadLimit = torrents_download_limit + + def torrents_set_download_limit( self, - urls=None, - torrent_files=None, - save_path=None, - cookie=None, - category=None, - is_skip_checking=None, - is_paused=None, - is_root_folder=None, - rename=None, - upload_limit=None, - download_limit=None, - use_auto_torrent_management=None, - is_sequential_download=None, - is_first_last_piece_priority=None, - tags=None, - content_layout=None, - ratio_limit=None, - seeding_time_limit=None, - download_path=None, - use_download_path=None, - stop_condition=None, - **kwargs, - ): - return self._client.torrents_add( - urls=urls, - torrent_files=torrent_files, - save_path=save_path, - cookie=cookie, - category=category, - is_skip_checking=is_skip_checking, - is_paused=is_paused, - is_root_folder=is_root_folder, - rename=rename, - upload_limit=upload_limit, - download_limit=download_limit, - is_sequential_download=is_sequential_download, - use_auto_torrent_management=use_auto_torrent_management, - is_first_last_piece_priority=is_first_last_piece_priority, - tags=tags, - content_layout=content_layout, - ratio_limit=ratio_limit, - seeding_time_limit=seeding_time_limit, - download_path=download_path, - use_download_path=use_download_path, - stop_condition=stop_condition, + limit: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set the download limit for one or more torrents. + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + :param limit: bytes/second (-1 sets the limit to infinity) + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "limit": limit, + } + self._post( + _name=APINames.Torrents, + _method="setDownloadLimit", + data=data, **kwargs, ) - class _ActionForAllTorrents(ClientCache): - def __init__(self, client, func): - super().__init__(client=client) - self.func = func + torrents_setDownloadLimit = torrents_set_download_limit - def __call__(self, torrent_hashes=None, **kwargs): - return self.func(torrent_hashes=torrent_hashes, **kwargs) + def torrents_set_share_limits( + self, + ratio_limit: str | int | None = None, + seeding_time_limit: str | int | None = None, + inactive_seeding_time_limit: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set share limits for one or more torrents. - def all(self, **kwargs): - return self.func(torrent_hashes="all", **kwargs) + This method was introduced with qBittorrent v4.1.1 (Web API v2.0.1). - class _Info(ClientCache): - def __call__( - self, - status_filter=None, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + :param ratio_limit: max ratio to seed a torrent. (-2 means use the global value and -1 is no limit) + :param seeding_time_limit: minutes (-2 means use the global value and -1 is no limit) + :param inactive_seeding_time_limit: minutes (-2 means use the global value and -1 is no limit) + (added in Web API v2.9.2) + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "ratioLimit": ratio_limit, + "seedingTimeLimit": seeding_time_limit, + "inactiveSeedingTimeLimit": inactive_seeding_time_limit, + } + self._post( + _name=APINames.Torrents, + _method="setShareLimits", + data=data, + version_introduced="2.0.1", **kwargs, - ): - return self._client.torrents_info( - status_filter=status_filter, - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + ) - def all( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="all", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + torrents_setShareLimits = torrents_set_share_limits - def downloading( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="downloading", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + def torrents_upload_limit( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> TorrentLimitsDictionary: + """ + Retrieve the upload limit for one or more torrents. - def seeding( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + return self._post_cast( + _name=APINames.Torrents, + _method="uploadLimit", + data=data, + response_class=TorrentLimitsDictionary, **kwargs, - ): - return self._client.torrents_info( - status_filter="seeding", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + ) - def completed( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="completed", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + torrents_uploadLimit = torrents_upload_limit - def paused( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="paused", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + def torrents_set_upload_limit( + self, + limit: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set the upload limit for one or more torrents. - def active( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + :param limit: bytes/second (-1 sets the limit to infinity) + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "limit": limit, + } + self._post( + _name=APINames.Torrents, + _method="setUploadLimit", + data=data, **kwargs, - ): - return self._client.torrents_info( - status_filter="active", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + ) - def inactive( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="inactive", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + torrents_setUploadLimit = torrents_set_upload_limit - def resumed( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="resumed", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + def torrents_set_location( + self, + location: str | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set location for torrents' files. + + :raises Forbidden403Error: if the user doesn't have permissions to write to the + location (only before v4.5.2 - write check was removed.) + :raises Conflict409Error: if the directory cannot be created at the location + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + :param location: disk location to move torrent's files + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "location": location, + } + self._post(_name=APINames.Torrents, _method="setLocation", data=data, **kwargs) + + torrents_setLocation = torrents_set_location + + def torrents_set_save_path( + self, + save_path: str | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set the Save Path for one or more torrents. + + This method was introduced with qBittorrent v4.4.0 (Web API v2.8.4). - def stalled( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="stalled", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + :raises Forbidden403Error: cannot write to directory + :raises Conflict409Error: cannot create directory - def stalled_uploading( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, + :param save_path: file path to save torrent contents + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = { + "id": self._list2string(torrent_hashes, "|"), + "path": save_path, + } + self._post( + _name=APINames.Torrents, + _method="setSavePath", + data=data, + version_introduced="2.8.4", **kwargs, - ): - return self._client.torrents_info( - status_filter="stalled_uploading", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + ) - def stalled_downloading( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="stalled_downloading", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + torrents_setSavePath = torrents_set_save_path - def checking( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="checking", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + def torrents_set_download_path( + self, + download_path: str | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set the Download Path for one or more torrents. - def moving( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - return self._client.torrents_info( - status_filter="moving", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + This method was introduced with qBittorrent v4.4.0 (Web API v2.8.4). - def errored( - self, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, + :raises Forbidden403Error: cannot write to directory + :raises Conflict409Error: cannot create directory + + :param download_path: file path to save torrent contents before torrent finishes downloading + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = { + "id": self._list2string(torrent_hashes, "|"), + "path": download_path, + } + self._post( + _name=APINames.Torrents, + _method="setDownloadPath", + data=data, + version_introduced="2.8.4", **kwargs, - ): - return self._client.torrents_info( - status_filter="errored", - category=category, - sort=sort, - reverse=reverse, - limit=limit, - offset=offset, - torrent_hashes=torrent_hashes, - tag=tag, - **kwargs, - ) + ) + torrents_setDownloadPath = torrents_set_download_path -@aliased -class TorrentCategories(ClientCache): - """ - Allows interaction with torrent categories within the ``Torrents`` API endpoints. + def torrents_set_category( + self, + category: str | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set a category for one or more torrents. - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> # these are all the same attributes that are available as named in the - >>> # endpoints or the more pythonic names in Client (with or without 'torrents_' prepended) - >>> categories = client.torrent_categories.categories - >>> # create or edit categories - >>> client.torrent_categories.create_category(name='Video', save_path='/home/user/torrents/Video') - >>> client.torrent_categories.edit_category(name='Video', save_path='/data/torrents/Video') - >>> # edit or create new by assignment - >>> client.torrent_categories.categories = dict(name='Video', save_path='/hone/user/') - >>> # delete categories - >>> client.torrent_categories.removeCategories(categories='Video') - >>> client.torrent_categories.removeCategories(categories=['Audio', "ISOs"]) - """ + :raises Conflict409Error: for bad category - @property - def categories(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_categories`""" - return self._client.torrents_categories() + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + :param category: category to assign to torrent + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "category": category, + } + self._post(_name=APINames.Torrents, _method="setCategory", data=data, **kwargs) - @categories.setter - def categories(self, v): - """Implements :meth:`~TorrentsAPIMixIn.edit_category` or - :meth:`~TorrentsAPIMixIn.create_category`""" - if v.get("name", "") in self.categories: - self.edit_category(**v) - else: - self.create_category(**v) + torrents_setCategory = torrents_set_category - @alias("createCategory") - def create_category( + def torrents_set_auto_management( self, - name=None, - save_path=None, - download_path=None, - enable_download_path=None, - **kwargs, - ): - """Implements :meth:`~TorrentsAPIMixIn.torrents_create_category`""" - return self._client.torrents_create_category( - name=name, - save_path=save_path, - download_path=download_path, - enable_download_path=enable_download_path, + enable: bool | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Enable or disable automatic torrent management for one or more torrents. + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + :param enable: Defaults to ``True`` if ``None`` or unset; use ``False`` to disable + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "enable": True if enable is None else bool(enable), + } + self._post( + _name=APINames.Torrents, + _method="setAutoManagement", + data=data, **kwargs, ) - @alias("editCategory") - def edit_category( + torrents_setAutoManagement = torrents_set_auto_management + + def torrents_toggle_sequential_download( self, - name=None, - save_path=None, - download_path=None, - enable_download_path=None, - **kwargs, - ): - """Implements :meth:`~TorrentsAPIMixIn.torrents_edit_category`""" - return self._client.torrents_edit_category( - name=name, - save_path=save_path, - download_path=download_path, - enable_download_path=enable_download_path, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Toggle sequential download for one or more torrents. + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes)} + self._post( + _name=APINames.Torrents, + _method="toggleSequentialDownload", + data=data, **kwargs, ) - @alias("removeCategories") - def remove_categories(self, categories=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_remove_categories`""" - return self._client.torrents_remove_categories(categories=categories, **kwargs) - + torrents_toggleSequentialDownload = torrents_toggle_sequential_download -@aliased -class TorrentTags(ClientCache): - """ - Allows interaction with torrent tags within the "Torrent" API endpoints. + def torrents_toggle_first_last_piece_priority( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Toggle priority of first/last piece downloading. - Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> tags = client.torrent_tags.tags - >>> client.torrent_tags.tags = 'tv show' # create category - >>> client.torrent_tags.create_tags(tags=['tv show', 'linux distro']) - >>> client.torrent_tags.delete_tags(tags='tv show') - """ + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = {"hashes": self._list2string(torrent_hashes, "|")} + self._post( + _name=APINames.Torrents, + _method="toggleFirstLastPiecePrio", + data=data, + **kwargs, + ) - @property - def tags(self): - """Implements :meth:`~TorrentsAPIMixIn.torrents_tags`""" - return self._client.torrents_tags() + torrents_toggleFirstLastPiecePrio = torrents_toggle_first_last_piece_priority - @tags.setter - def tags(self, v): - """Implements :meth:`~TorrentsAPIMixIn.torrents_create_tags`""" - self._client.torrents_create_tags(tags=v) + def torrents_set_force_start( + self, + enable: bool | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Force start one or more torrents. - @alias("addTags") - def add_tags(self, tags=None, torrent_hashes=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_add_tags`""" - self._client.torrents_add_tags( - tags=tags, - torrent_hashes=torrent_hashes, + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + :param enable: Defaults to ``True`` if ``None`` or unset; ``False`` is equivalent to + :meth:`~TorrentsAPIMixIn.torrents_resume()`. + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "value": True if enable is None else bool(enable), + } + self._post( + _name=APINames.Torrents, + _method="setForceStart", + data=data, **kwargs, ) - @alias("removeTags") - def remove_tags(self, tags=None, torrent_hashes=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_remove_tags`""" - self._client.torrents_remove_tags( - tags=tags, - torrent_hashes=torrent_hashes, + torrents_setForceStart = torrents_set_force_start + + def torrents_set_super_seeding( + self, + enable: bool | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Set one or more torrents as super seeding. + + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + :param enable: Defaults to ``True`` if ``None`` or unset; ``False`` to disable + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "value": True if enable is None else bool(enable), + } + self._post( + _name=APINames.Torrents, + _method="setSuperSeeding", + data=data, **kwargs, ) - @alias("createTags") - def create_tags(self, tags=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_create_tags`""" - self._client.torrents_create_tags(tags=tags, **kwargs) - - @alias("deleteTags") - def delete_tags(self, tags=None, **kwargs): - """Implements :meth:`~TorrentsAPIMixIn.torrents_delete_tags`""" - self._client.torrents_delete_tags(tags=tags, **kwargs) + torrents_setSuperSeeding = torrents_set_super_seeding + def torrents_add_peers( + self, + peers: Iterable[str] | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> TorrentsAddPeersDictionary: + """ + Add one or more peers to one or more torrents. -@aliased -class TorrentsAPIMixIn(AppAPIMixIn): - """ - Implementation of all Torrents API methods. + This method was introduced with qBittorrent v4.4.0 (Web API v2.3.0). - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> client.torrents_add(urls='...') - >>> client.torrents_reannounce() - """ + :raises InvalidRequest400Error: for invalid peers - @property - def torrents(self): + :param peers: one or more peers to add. each peer should take the form 'host:port' + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. """ - Allows for transparent interaction with Torrents endpoints. + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "peers": self._list2string(peers, "|"), + } + return self._post_cast( + _name=APINames.Torrents, + _method="addPeers", + data=data, + response_class=TorrentsAddPeersDictionary, + version_introduced="2.3.0", + **kwargs, + ) - See Torrents and Torrent class for usage. - :return: Torrents object - """ - if self._torrents is None: - self._torrents = Torrents(client=self) - return self._torrents + torrents_addPeers = torrents_add_peers - @property - def torrent_categories(self): + # TORRENT CATEGORIES ENDPOINTS + def torrents_categories(self, **kwargs: APIKwargsT) -> TorrentCategoriesDictionary: """ - Allows for transparent interaction with Torrent Categories endpoints. + Retrieve all category definitions. - See Torrent_Categories class for usage. - :return: Torrent Categories object - """ - if self._torrent_categories is None: - self._torrent_categories = TorrentCategories(client=self) - return self._torrent_categories + This method was introduced with qBittorrent v4.1.4 (Web API v2.1.1). - @property - def torrent_tags(self): + Note: ``torrents/categories`` is not available until v2.1.0 """ - Allows for transparent interaction with Torrent Tags endpoints. + return self._get_cast( + _name=APINames.Torrents, + _method="categories", + response_class=TorrentCategoriesDictionary, + version_introduced="2.1.1", + **kwargs, + ) - See Torrent_Tags class for usage. - :return: Torrent Tags object + def torrents_create_category( + self, + name: str | None = None, + save_path: str | None = None, + download_path: str | None = None, + enable_download_path: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: """ - if self._torrent_tags is None: - self._torrent_tags = TorrentTags(client=self) - return self._torrent_tags + Create a new torrent category. - @login_required - def torrents_add( - self, - urls=None, - torrent_files=None, - save_path=None, - cookie=None, - category=None, - is_skip_checking=None, - is_paused=None, - is_root_folder=None, - rename=None, - upload_limit=None, - download_limit=None, - use_auto_torrent_management=None, - is_sequential_download=None, - is_first_last_piece_priority=None, - tags=None, - content_layout=None, - ratio_limit=None, - seeding_time_limit=None, - download_path=None, - use_download_path=None, - stop_condition=None, - **kwargs, - ): + :raises Conflict409Error: if category name is not valid or unable to create + :param name: name for new category + :param save_path: location to save torrents for this category (added in Web API + 2.1.0) + :param download_path: download location for torrents with this category + :param enable_download_path: True or False to enable or disable download path """ - Add one or more torrents by URLs and/or torrent files. + # default to actually using the specified download path + if enable_download_path is None and download_path is not None: + enable_download_path = True - :raises UnsupportedMediaType415Error: if file is not a valid torrent file - :raises TorrentFileNotFoundError: if a torrent file doesn't exist - :raises TorrentFilePermissionError: if read permission is denied to torrent file + data = { + "category": name, + "savePath": save_path, + "downloadPath": download_path, + "downloadPathEnabled": enable_download_path, + } + self._post( + _name=APINames.Torrents, + _method="createCategory", + data=data, + **kwargs, + ) - :param urls: single instance or an iterable of URLs (``http://``, ``https://``, ``magnet:``, ``bc://bt/``) - :param torrent_files: several options are available to send torrent files to qBittorrent: + torrents_createCategory = torrents_create_category - * single instance of bytes: useful if torrent file already read from disk or downloaded from internet. - * single instance of file handle to torrent file: use ``open(, 'rb')`` to open the torrent file. - * single instance of a filepath to torrent file: e.g. ``/home/user/torrent_filename.torrent`` - * an iterable of the single instances above to send more than one torrent file - * dictionary with key/value pairs of torrent name and single instance of above object + def torrents_edit_category( + self, + name: str | None = None, + save_path: str | None = None, + download_path: str | None = None, + enable_download_path: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: + """ + Edit an existing category. - Note: The torrent name in a dictionary is useful to identify which torrent file - errored. qBittorrent provides back that name in the error text. If a torrent - name is not provided, then the name of the file will be used. And in the case of - bytes (or if filename cannot be determined), the value 'torrent__n' will be used. - :param save_path: location to save the torrent data - :param cookie: cookie to retrieve torrents by URL - :param category: category to assign to torrent(s) - :param is_skip_checking: ``True`` to skip hash checking - :param is_paused: ``True`` to add the torrent(s) without starting their downloading - :param is_root_folder: ``True`` or ``False`` to create root folder (superseded by content_layout with v4.3.2) - :param rename: new name for torrent(s) - :param upload_limit: upload limit in bytes/second - :param download_limit: download limit in bytes/second - :param use_auto_torrent_management: ``True`` or ``False`` to use automatic torrent management - :param is_sequential_download: ``True`` or ``False`` for sequential download - :param is_first_last_piece_priority: ``True`` or ``False`` for first and last piece download priority - :param tags: tag(s) to assign to torrent(s) (added in Web API 2.6.2) - :param content_layout: ``Original``, ``Subfolder``, or ``NoSubfolder`` to control filesystem structure for content (added in Web API 2.7) - :param ratio_limit: share limit as ratio of upload amt over download amt; e.g. 0.5 or 2.0 (added in Web API 2.8.1) - :param seeding_time_limit: number of minutes to seed torrent (added in Web API 2.8.1) - :param download_path: location to download torrent content before moving to ``save_path`` (added in Web API 2.8.4) - :param use_download_path: ``True`` or ``False`` whether ``download_path`` should be used...defaults to ``True`` if ``download_path`` is specified (added in Web API 2.8.4) - :param stop_condition: ``MetadataReceived`` or ``FilesChecked`` to stop the torrent when started automatically (added in Web API 2.8.15) - :return: ``Ok.`` for success and ``Fails.`` for failure - """ # noqa: E501 + This method was introduced with qBittorrent v4.1.3 (Web API v2.1.0). - # convert pre-v2.7 params to post-v2.7 params...or post-v2.7 to pre-v2.7 - api_version = self.app_web_api_version() - if ( - content_layout is None - and is_root_folder is not None - and v(api_version) >= v("2.7") - ): - content_layout = "Original" if is_root_folder else "NoSubfolder" - is_root_folder = None - elif ( - content_layout is not None - and is_root_folder is None - and v(api_version) < v("2.7") - ): - is_root_folder = content_layout in {"Subfolder", "Original"} - content_layout = None + :raises Conflict409Error: if category name is not valid or unable to create + :param name: category to edit + :param save_path: new location to save files for this category + :param download_path: download location for torrents with this category + :param enable_download_path: True or False to enable or disable download path + """ # default to actually using the specified download path - if use_download_path is None and download_path is not None: - use_download_path = True + if enable_download_path is None and download_path is not None: + enable_download_path = True data = { - "urls": (None, self._list2string(urls, "\n")), - "savepath": (None, save_path), - "cookie": (None, cookie), - "category": (None, category), - "tags": (None, self._list2string(tags, ",")), - "skip_checking": (None, is_skip_checking), - "paused": (None, is_paused), - "root_folder": (None, is_root_folder), - "contentLayout": (None, content_layout), - "rename": (None, rename), - "upLimit": (None, upload_limit), - "dlLimit": (None, download_limit), - "ratioLimit": (None, ratio_limit), - "seedingTimeLimit": (None, seeding_time_limit), - "autoTMM": (None, use_auto_torrent_management), - "sequentialDownload": (None, is_sequential_download), - "firstLastPiecePrio": (None, is_first_last_piece_priority), - "downloadPath": (None, download_path), - "useDownloadPath": (None, use_download_path), - "stopCondition": (None, stop_condition), + "category": name, + "savePath": save_path, + "downloadPath": download_path, + "downloadPathEnabled": enable_download_path, } - - return self._post( + self._post( _name=APINames.Torrents, - _method="add", + _method="editCategory", data=data, - files=self._normalize_torrent_files(torrent_files), - response_class=str, + version_introduced="2.1.0", **kwargs, ) - @staticmethod - def _normalize_torrent_files(user_files): - """ - Normalize the torrent file(s) from the user. + torrents_editCategory = torrents_edit_category - The file(s) can be the raw bytes, file handle, filepath for a - torrent file, or an iterable (e.g. list|set|tuple) of any of - these "files". Further, the file(s) can be in a dictionary with - the "names" of the torrents as the keys. These "names" can be - anything...but are mostly useful as identifiers for each file. + def torrents_remove_categories( + self, + categories: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ - if not user_files: - return None - - prefix = "torrent__" - # if it's string-like and not a list|set|tuple, then make it a list - # checking for 'read' attr since a single file handle is iterable but also needs to be in a list - is_string_like = isinstance(user_files, (bytes, str)) - is_file_like = hasattr(user_files, "read") - if is_string_like or is_file_like or not isinstance(user_files, Iterable): - user_files = [user_files] + Delete one or more categories. - # up convert to a dictionary to add fabricated torrent names - norm_files = ( - user_files - if isinstance(user_files, Mapping) - else {prefix + str(i): f for i, f in enumerate(user_files)} + :param categories: categories to delete + """ + data = {"categories": self._list2string(categories, "\n")} + self._post( + _name=APINames.Torrents, + _method="removeCategories", + data=data, + **kwargs, ) - files = {} - for name, torrent_file in norm_files.items(): - try: - if isinstance(torrent_file, (bytes, bytearray)): - # if bytes, assume it's a torrent file that was downloaded or read from disk - torrent_bytes = torrent_file - elif hasattr(torrent_file, "read"): - # if hasattr('read'), assume this is a file handle from open() or similar - torrent_bytes = torrent_file.read() - else: - # otherwise, coerce to a string and try to open it as a file - filepath = path.abspath( - path.realpath(path.expanduser(str(torrent_file))) - ) - name = path.basename(filepath) - with open(filepath, "rb") as file: - torrent_bytes = file.read() - - # if using default name, let Requests try to figure out the filename to send - # Requests will fall back to "name" as the dict key if fh doesn't provide a file name - files[name] = ( - torrent_bytes if name.startswith(prefix) else (name, torrent_bytes) - ) - except OSError as io_err: - if io_err.errno == errno.ENOENT: - raise TorrentFileNotFoundError( - errno.ENOENT, os_strerror(errno.ENOENT), torrent_file - ) - if io_err.errno == errno.EACCES: - raise TorrentFilePermissionError( - errno.ENOENT, os_strerror(errno.EACCES), torrent_file - ) - raise TorrentFileError(io_err) - return files + torrents_removeCategories = torrents_remove_categories - ########################################################################## - # INDIVIDUAL TORRENT ENDPOINTS - ########################################################################## - @handle_hashes - @login_required - def torrents_properties(self, torrent_hash=None, **kwargs): + # TORRENT TAGS ENDPOINTS + def torrents_tags(self, **kwargs: APIKwargsT) -> TagList: """ - Retrieve individual torrent's properties. - - :raises NotFound404Error: + Retrieve all tag definitions. - :param torrent_hash: hash for torrent - :return: :class:`TorrentPropertiesDictionary` - ``_ - """ # noqa: E501 - data = {"hash": torrent_hash} - return self._post( - _name=APINames.Torrents, - _method="properties", - data=data, - response_class=TorrentPropertiesDictionary, + This method was introduced with qBittorrent v4.2.0 (Web API v2.3.0). + """ + return self._get_cast( + _name=APINames.Torrents, + _method="tags", + response_class=TagList, + version_introduced="2.3.0", **kwargs, ) - @handle_hashes - @login_required - def torrents_trackers(self, torrent_hash=None, **kwargs): + def torrents_add_tags( + self, + tags: Iterable[str] | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ - Retrieve individual torrent's trackers. Tracker status is defined in - :class:`~qbittorrentapi.definitions.TrackerStatus`. + Add one or more tags to one or more torrents. - :raises NotFound404Error: + Note: Tags that do not exist will be created on-the-fly. - :param torrent_hash: hash for torrent - :return: :class:`TrackersList` - ``_ - """ # noqa: E501 - data = {"hash": torrent_hash} - return self._post( + This method was introduced with qBittorrent v4.2.0 (Web API v2.3.0). + + :param tags: tag name or list of tags + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "tags": self._list2string(tags, ","), + } + self._post( _name=APINames.Torrents, - _method="trackers", + _method="addTags", data=data, - response_class=TrackersList, + version_introduced="2.3.0", **kwargs, ) - @handle_hashes - @login_required - def torrents_webseeds(self, torrent_hash=None, **kwargs): + torrents_addTags = torrents_add_tags + + def torrents_remove_tags( + self, + tags: Iterable[str] | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ - Retrieve individual torrent's web seeds. + Add one or more tags to one or more torrents. - :raises NotFound404Error: + This method was introduced with qBittorrent v4.2.0 (Web API v2.3.0). - :param torrent_hash: hash for torrent - :return: :class:`WebSeedsList` - ``_ - """ # noqa: E501 - data = {"hash": torrent_hash} - return self._post( + :param tags: tag name or list of tags + :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. + """ + data = { + "hashes": self._list2string(torrent_hashes, "|"), + "tags": self._list2string(tags, ","), + } + self._post( _name=APINames.Torrents, - _method="webseeds", + _method="removeTags", data=data, - response_class=WebSeedsList, + version_introduced="2.3.0", **kwargs, ) - @handle_hashes - @login_required - def torrents_files(self, torrent_hash=None, **kwargs): + torrents_removeTags = torrents_remove_tags + + def torrents_create_tags( + self, + tags: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ - Retrieve individual torrent's files. + Create one or more tags. - :raises NotFound404Error: + This method was introduced with qBittorrent v4.2.0 (Web API v2.3.0). - :param torrent_hash: hash for torrent - :return: :class:`TorrentFilesList` - ``_ - """ # noqa: E501 - data = {"hash": torrent_hash} - return self._post( + :param tags: tag name or list of tags + """ + data = {"tags": self._list2string(tags, ",")} + self._post( _name=APINames.Torrents, - _method="files", + _method="createTags", data=data, - response_class=TorrentFilesList, + version_introduced="2.3.0", **kwargs, ) - @alias("torrents_pieceStates") - @handle_hashes - @login_required - def torrents_piece_states(self, torrent_hash=None, **kwargs): + torrents_createTags = torrents_create_tags + + def torrents_delete_tags( + self, + tags: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ - Retrieve individual torrent's pieces' states. + Delete one or more tags. - :raises NotFound404Error: + This method was introduced with qBittorrent v4.2.0 (Web API v2.3.0). - :param torrent_hash: hash for torrent - :return: :class:`TorrentPieceInfoList` + :param tags: tag name or list of tags """ - data = {"hash": torrent_hash} - return self._post( + data = {"tags": self._list2string(tags, ",")} + self._post( _name=APINames.Torrents, - _method="pieceStates", + _method="deleteTags", data=data, - response_class=TorrentPieceInfoList, + version_introduced="2.3.0", **kwargs, ) - @alias("torrents_pieceHashes") - @handle_hashes - @login_required - def torrents_piece_hashes(self, torrent_hash=None, **kwargs): - """ - Retrieve individual torrent's pieces' hashes. + torrents_deleteTags = torrents_delete_tags - :raises NotFound404Error: - :param torrent_hash: hash for torrent - :return: :class:`TorrentPieceInfoList` - """ - data = {"hash": torrent_hash} - return self._post( - _name=APINames.Torrents, - _method="pieceHashes", - data=data, - response_class=TorrentPieceInfoList, +class TorrentDictionary(ClientCache[TorrentsAPIMixIn], ListEntry): + """ + Item in :class:`TorrentInfoList`. Allows interaction with individual torrents via + the ``Torrents`` API endpoints. + + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> # these are all the same attributes that are available as named in the + >>> # endpoints or the more pythonic names in Client (with or without 'transfer_' prepended) + >>> torrent = client.torrents.info()[0] + >>> torrent_hash = torrent.info.hash + >>> # Attributes without inputs and a return value are properties + >>> properties = torrent.properties + >>> trackers = torrent.trackers + >>> files = torrent.files + >>> # Action methods + >>> torrent.edit_tracker(original_url="...", new_url="...") + >>> torrent.remove_trackers(urls='http://127.0.0.2/') + >>> torrent.rename(new_torrent_name="...") + >>> torrent.resume() + >>> torrent.pause() + >>> torrent.recheck() + >>> torrent.torrents_top_priority() + >>> torrent.setLocation(location='/home/user/torrents/') + >>> torrent.setCategory(category='video') + """ + + def __init__( + self, + data: MutableMapping[str, JsonValueT], + client: TorrentsAPIMixIn, + ) -> None: + self._torrent_hash: str | None = cast(str, data.get("hash", None)) + # The attribute for "# of secs til the next announce" was added in v5.0.0. + # To avoid clashing with `reannounce()`, rename to `reannounce_in`. + if "reannounce" in data: + data["reannounce_in"] = data.pop("reannounce") + super().__init__(client=client, data=data) + + def sync_local(self) -> None: + """Update local cache of torrent info.""" + for name, value in self.info.items(): + setattr(self, name, value) + + @property + def state_enum(self) -> TorrentState: + """Torrent state enum.""" + try: + return TorrentState(self.state) + except ValueError: + return TorrentState.UNKNOWN + + @property + def info(self) -> TorrentDictionary: + """Returns data from :meth:`~TorrentsAPIMixIn.torrents_info` for the torrent.""" + info = self._client.torrents_info(torrent_hashes=self._torrent_hash) + if len(info) == 1 and info[0].hash == self._torrent_hash: + return info[0] + + # qBittorrent v4.1.0 didn't support torrent hash parameter + info = [t for t in self._client.torrents_info() if t.hash == self._torrent_hash] # type: ignore[assignment] + if len(info) == 1: + return info[0] + + return TorrentDictionary(data={}, client=self._client) + + @wraps(TorrentsAPIMixIn.torrents_resume) + def resume(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_resume(torrent_hashes=self._torrent_hash, **kwargs) + + @wraps(TorrentsAPIMixIn.torrents_pause) + def pause(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_pause(torrent_hashes=self._torrent_hash, **kwargs) + + @wraps(TorrentsAPIMixIn.torrents_delete) + def delete( + self, + delete_files: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_delete( + delete_files=delete_files, + torrent_hashes=self._torrent_hash, **kwargs, ) - @alias("torrents_addTrackers") - @handle_hashes - @login_required - def torrents_add_trackers(self, torrent_hash=None, urls=None, **kwargs): - """ - Add trackers to a torrent. + @wraps(TorrentsAPIMixIn.torrents_recheck) + def recheck(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_recheck(torrent_hashes=self._torrent_hash, **kwargs) - :raises NotFound404Error: - :param torrent_hash: hash for torrent - :param urls: tracker URLs to add to torrent - :return: None - """ - data = { - "hash": torrent_hash, - "urls": self._list2string(urls, "\n"), - } - self._post(_name=APINames.Torrents, _method="addTrackers", data=data, **kwargs) + @wraps(TorrentsAPIMixIn.torrents_reannounce) + def reannounce(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_reannounce(torrent_hashes=self._torrent_hash, **kwargs) - @alias("torrents_editTracker") - @endpoint_introduced("2.2.0", "torrents/editTracker") - @handle_hashes - @login_required - def torrents_edit_tracker( - self, torrent_hash=None, original_url=None, new_url=None, **kwargs - ): - """ - Replace a torrent's tracker with a different one. + @wraps(TorrentsAPIMixIn.torrents_increase_priority) + def increase_priority(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_increase_priority( + torrent_hashes=self._torrent_hash, + **kwargs, + ) - :raises InvalidRequest400Error: - :raises NotFound404Error: - :raises Conflict409Error: - :param torrent_hash: hash for torrent - :param original_url: URL for existing tracker - :param new_url: new URL to replace - :return: None - """ - data = { - "hash": torrent_hash, - "origUrl": original_url, - "newUrl": new_url, - } - self._post(_name=APINames.Torrents, _method="editTracker", data=data, **kwargs) + increasePrio = increase_priority - @alias("torrents_removeTrackers") - @endpoint_introduced("2.2.0", "torrents/removeTrackers") - @handle_hashes - @login_required - def torrents_remove_trackers(self, torrent_hash=None, urls=None, **kwargs): - """ - Remove trackers from a torrent. + @wraps(TorrentsAPIMixIn.torrents_decrease_priority) + def decrease_priority(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_decrease_priority( + torrent_hashes=self._torrent_hash, + **kwargs, + ) - :raises NotFound404Error: - :raises Conflict409Error: - :param torrent_hash: hash for torrent - :param urls: tracker URLs to removed from torrent - :return: None - """ - data = { - "hash": torrent_hash, - "urls": self._list2string(urls, "|"), - } - self._post( - _name=APINames.Torrents, _method="removeTrackers", data=data, **kwargs + decreasePrio = decrease_priority + + @wraps(TorrentsAPIMixIn.torrents_top_priority) + def top_priority(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_top_priority(torrent_hashes=self._torrent_hash, **kwargs) + + topPrio = top_priority + + @wraps(TorrentsAPIMixIn.torrents_bottom_priority) + def bottom_priority(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_bottom_priority( + torrent_hashes=self._torrent_hash, + **kwargs, ) - @alias("torrents_filePrio") - @handle_hashes - @login_required - def torrents_file_priority( - self, torrent_hash=None, file_ids=None, priority=None, **kwargs - ): - """ - Set priority for one or more files. + bottomPrio = bottom_priority - :raises InvalidRequest400Error: if priority is invalid or at least one file ID is not an integer - :raises NotFound404Error: - :raises Conflict409Error: if torrent metadata has not finished downloading or at least one file was not found - :param torrent_hash: hash for torrent - :param file_ids: single file ID or a list. - :param priority: priority for file(s) - ``_ - :return: None - """ # noqa: E501 - data = { - "hash": torrent_hash, - "id": self._list2string(file_ids, "|"), - "priority": priority, - } - self._post(_name=APINames.Torrents, _method="filePrio", data=data, **kwargs) + @wraps(TorrentsAPIMixIn.torrents_set_share_limits) + def set_share_limits( + self, + ratio_limit: str | int | None = None, + seeding_time_limit: str | int | None = None, + inactive_seeding_time_limit: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_share_limits( + ratio_limit=ratio_limit, + seeding_time_limit=seeding_time_limit, + inactive_seeding_time_limit=inactive_seeding_time_limit, + torrent_hashes=self._torrent_hash, + **kwargs, + ) + + setShareLimits = set_share_limits + + @property + @wraps(TorrentsAPIMixIn.torrents_set_download_limit) + def download_limit(self) -> int: + return cast( + int, + self._client.torrents_download_limit(torrent_hashes=self._torrent_hash).get( + self._torrent_hash or "" + ), + ) + + @download_limit.setter + @wraps(TorrentsAPIMixIn.torrents_set_download_limit) + def download_limit(self, val: str | int) -> None: + self.set_download_limit(limit=val) + + @property + @wraps(TorrentsAPIMixIn.torrents_set_download_limit) + def downloadLimit(self) -> int: + return cast( + int, + self._client.torrents_download_limit(torrent_hashes=self._torrent_hash).get( + self._torrent_hash or "" + ), + ) + + @downloadLimit.setter + @wraps(TorrentsAPIMixIn.torrents_set_download_limit) + def downloadLimit(self, val: str | int) -> None: + self.set_download_limit(limit=val) + + @wraps(TorrentsAPIMixIn.torrents_set_download_limit) + def set_download_limit( + self, + limit: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_download_limit( + limit=limit, + torrent_hashes=self._torrent_hash, + **kwargs, + ) + + setDownloadLimit = set_download_limit + + @property + @wraps(TorrentsAPIMixIn.torrents_upload_limit) + def upload_limit(self) -> int: + return cast( + int, + self._client.torrents_upload_limit(torrent_hashes=self._torrent_hash).get( + self._torrent_hash or "" + ), + ) + + @upload_limit.setter + @wraps(TorrentsAPIMixIn.torrents_set_upload_limit) + def upload_limit(self, val: str | int) -> None: + self.set_upload_limit(limit=val) + + @property + @wraps(TorrentsAPIMixIn.torrents_upload_limit) + def uploadLimit(self) -> int: + return cast( + int, + self._client.torrents_upload_limit(torrent_hashes=self._torrent_hash).get( + self._torrent_hash or "" + ), + ) + + @uploadLimit.setter + @wraps(TorrentsAPIMixIn.torrents_set_upload_limit) + def uploadLimit(self, val: str | int) -> None: + self.set_upload_limit(limit=val) + + @wraps(TorrentsAPIMixIn.torrents_set_upload_limit) + def set_upload_limit( + self, + limit: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_upload_limit( + limit=limit, + torrent_hashes=self._torrent_hash, + **kwargs, + ) - @handle_hashes - @login_required - def torrents_rename(self, torrent_hash=None, new_torrent_name=None, **kwargs): - """ - Rename a torrent. + setUploadLimit = set_upload_limit - :raises NotFound404Error: - :param torrent_hash: hash for torrent - :param new_torrent_name: new name for torrent - :return: None - """ - data = {"hash": torrent_hash, "name": new_torrent_name} - self._post(_name=APINames.Torrents, _method="rename", data=data, **kwargs) + @wraps(TorrentsAPIMixIn.torrents_set_location) + def set_location( + self, + location: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_location( + location=location, + torrent_hashes=self._torrent_hash, + **kwargs, + ) - @alias("torrents_renameFile") - @handle_hashes - @endpoint_introduced("2.4.0", "torrents/renameFile") - @login_required - def torrents_rename_file( + setLocation = set_location + + @wraps(TorrentsAPIMixIn.torrents_set_save_path) + def set_save_path( self, - torrent_hash=None, - file_id=None, - new_file_name=None, - old_path=None, - new_path=None, - **kwargs, - ): - """ - Rename a torrent file. + save_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_save_path( + save_path=save_path, + torrent_hashes=self._torrent_hash, + **kwargs, + ) - :raises MissingRequiredParameters400Error: - :raises NotFound404Error: - :raises Conflict409Error: - :param torrent_hash: hash for torrent - :param file_id: id for file (removed in Web API 2.7) - :param new_file_name: new name for file (removed in Web API 2.7) - :param old_path: path of file to rename (added in Web API 2.7) - :param new_path: new path of file to rename (added in Web API 2.7) - :return: None - """ - # convert pre-v2.7 params to post-v2.7...or post-v2.7 to pre-v2.7 - # HACK: v4.3.2 and v4.3.3 both use Web API 2.7 but old/new_path were introduced in v4.3.3 - if ( - old_path is None - and new_path is None - and file_id is not None - and v(self.app_version()) >= v("v4.3.3") - ): - try: - old_path = self.torrents_files(torrent_hash=torrent_hash)[file_id].name - except (IndexError, AttributeError, TypeError): - logger.debug( - "ERROR: File ID '%s' isn't valid...'oldPath' cannot be determined.", - file_id, - ) - old_path = "" - new_path = new_file_name or "" - elif ( - old_path is not None - and new_path is not None - and file_id is None - and v(self.app_version()) < v("v4.3.3") - ): - # previous only allowed renaming the file...not also moving it - new_file_name = new_path.split("/")[-1] - for file in self.torrents_files(torrent_hash=torrent_hash): - if file.name == old_path: - file_id = file.id - break - else: - logger.debug( - "ERROR: old_path '%s' isn't valid...'file_id' cannot be determined.", - old_path, - ) - file_id = "" + setSavePath = set_save_path - data = { - "hash": torrent_hash, - "id": file_id, - "name": new_file_name, - "oldPath": old_path, - "newPath": new_path, - } - self._post(_name=APINames.Torrents, _method="renameFile", data=data, **kwargs) + @wraps(TorrentsAPIMixIn.torrents_set_download_path) + def set_download_path( + self, + download_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_download_path( + download_path=download_path, + torrent_hashes=self._torrent_hash, + **kwargs, + ) - @alias("torrents_renameFolder") - @endpoint_introduced("2.7", "torrents/renameFolder") - @handle_hashes - @login_required - def torrents_rename_folder( + setDownloadPath = set_download_path + + @wraps(TorrentsAPIMixIn.torrents_set_category) + def set_category( self, - torrent_hash=None, - old_path=None, - new_path=None, - **kwargs, - ): - """ - Rename a torrent folder. + category: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_category( + category=category, + torrent_hashes=self._torrent_hash, + **kwargs, + ) - :raises MissingRequiredParameters400Error: - :raises NotFound404Error: - :raises Conflict409Error: - :param torrent_hash: hash for torrent - :param old_path: path of file to rename (added in Web API 2.7) - :param new_path: new path of file to rename (added in Web API 2.7) - :return: None - """ - # HACK: v4.3.2 and v4.3.3 both use Web API 2.7 but rename_folder was introduced in v4.3.3 - if v(self.app_version()) >= v("v4.3.3"): - data = { - "hash": torrent_hash, - "oldPath": old_path, - "newPath": new_path, - } - self._post( - _name=APINames.Torrents, - _method="renameFolder", - data=data, - **kwargs, - ) - else: - # only get here on v4.3.2....so hack in raising exception - check_for_raise( - client=self, - error_message=( - "ERROR: Endpoint 'torrents/renameFolder' is Not Implemented in this version of qBittorrent. " - "This endpoint is available starting in Web API 2.7." - ), - ) + setCategory = set_category - @endpoint_introduced("2.8.14", "torrents/export") - @handle_hashes - @login_required - def torrents_export(self, torrent_hash=None, **kwargs): - """ - Export a .torrent file for the torrent. + @wraps(TorrentsAPIMixIn.torrents_set_auto_management) + def set_auto_management( + self, + enable: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_auto_management( + enable=enable, + torrent_hashes=self._torrent_hash, + **kwargs, + ) - :raises NotFound404Error: torrent not found - :raises Conflict409Error: unable to export .torrent file - :param torrent_hash: hash for torrent - :return: bytes .torrent file - """ - data = {"hash": torrent_hash} - return self._post( - _name=APINames.Torrents, - _method="export", - data=data, - response_class=bytes, + setAutoManagement = set_auto_management + + @wraps(TorrentsAPIMixIn.torrents_toggle_sequential_download) + def toggle_sequential_download(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_toggle_sequential_download( + torrent_hashes=self._torrent_hash, **kwargs, ) - ########################################################################## - # MULTIPLE TORRENT ENDPOINTS - ########################################################################## - @handle_hashes - @login_required - def torrents_info( - self, - status_filter=None, - category=None, - sort=None, - reverse=None, - limit=None, - offset=None, - torrent_hashes=None, - tag=None, - **kwargs, - ): - """ - Retrieves list of info for torrents. + toggleSequentialDownload = toggle_sequential_download - :param status_filter: Filter list by torrent status. - ``all``, ``downloading``, ``seeding``, ``completed``, ``paused`` - ``active``, ``inactive``, ``resumed``, ``errored`` - Added in Web API 2.4.1: - ``stalled``, ``stalled_uploading``, and ``stalled_downloading`` - Added in Web API 2.8.4: - ``checking`` - Added in Web API 2.8.18: - ``moving`` - :param category: Filter list by category - :param sort: Sort list by any property returned - :param reverse: Reverse sorting - :param limit: Limit length of list - :param offset: Start of list (if < 0, offset from end of list) - :param torrent_hashes: Filter list by hash (separate multiple hashes with a '|') (added in Web API 2.0.1) - :param tag: Filter list by tag (empty string means "untagged"; no "tag" parameter means "any tag"; added in Web API 2.8.3) - :return: :class:`TorrentInfoList` - ``_ - """ # noqa: E501 - data = { - "filter": status_filter, - "category": category, - "sort": sort, - "reverse": reverse, - "limit": limit, - "offset": offset, - "hashes": self._list2string(torrent_hashes, "|"), - "tag": tag, - } - return self._post( - _name=APINames.Torrents, - _method="info", - data=data, - response_class=TorrentInfoList, + @wraps(TorrentsAPIMixIn.torrents_toggle_first_last_piece_priority) + def toggle_first_last_piece_priority(self, **kwargs: APIKwargsT) -> None: + self._client.torrents_toggle_first_last_piece_priority( + torrent_hashes=self._torrent_hash, **kwargs, ) - @handle_hashes - @login_required - def torrents_resume(self, torrent_hashes=None, **kwargs): - """ - Resume one or more torrents in qBittorrent. + toggleFirstLastPiecePrio = toggle_first_last_piece_priority - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - self._post(_name=APINames.Torrents, _method="resume", data=data, **kwargs) + @wraps(TorrentsAPIMixIn.torrents_set_force_start) + def set_force_start( + self, + enable: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_force_start( + enable=enable, + torrent_hashes=self._torrent_hash, + **kwargs, + ) - @handle_hashes - @login_required - def torrents_pause(self, torrent_hashes=None, **kwargs): - """ - Pause one or more torrents in qBittorrent. + setForceStart = set_force_start - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - self._post(_name=APINames.Torrents, _method="pause", data=data, **kwargs) + @wraps(TorrentsAPIMixIn.torrents_set_super_seeding) + def set_super_seeding( + self, + enable: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_set_super_seeding( + enable=enable, + torrent_hashes=self._torrent_hash, + **kwargs, + ) - @handle_hashes - @login_required - def torrents_delete(self, delete_files=False, torrent_hashes=None, **kwargs): - """ - Remove a torrent from qBittorrent and optionally delete its files. + setSuperSeeding = set_super_seeding - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :param delete_files: True to delete the torrent's files - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "deleteFiles": bool(delete_files), - } - self._post(_name=APINames.Torrents, _method="delete", data=data, **kwargs) + @property + @wraps(TorrentsAPIMixIn.torrents_properties) + def properties(self) -> TorrentPropertiesDictionary: + return self._client.torrents_properties(torrent_hash=self._torrent_hash) - @handle_hashes - @login_required - def torrents_recheck(self, torrent_hashes=None, **kwargs): - """ - Recheck a torrent in qBittorrent. + @property + @wraps(TorrentsAPIMixIn.torrents_trackers) + def trackers(self) -> TrackersList: + return self._client.torrents_trackers(torrent_hash=self._torrent_hash) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - self._post(_name=APINames.Torrents, _method="recheck", data=data, **kwargs) + @trackers.setter + @wraps(TorrentsAPIMixIn.torrents_add_trackers) + def trackers(self, val: Iterable[str]) -> None: + self.add_trackers(urls=val) - @endpoint_introduced("2.0.2", "torrents/reannounce") - @handle_hashes - @login_required - def torrents_reannounce(self, torrent_hashes=None, **kwargs): - """ - Reannounce a torrent. + @property + @wraps(TorrentsAPIMixIn.torrents_webseeds) + def webseeds(self) -> WebSeedsList: + return self._client.torrents_webseeds(torrent_hash=self._torrent_hash) - Note: ``torrents/reannounce`` introduced in Web API 2.0.2 + @property + @wraps(TorrentsAPIMixIn.torrents_files) + def files(self) -> TorrentFilesList: + return self._client.torrents_files(torrent_hash=self._torrent_hash) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - self._post(_name=APINames.Torrents, _method="reannounce", data=data, **kwargs) + @wraps(TorrentsAPIMixIn.torrents_rename_file) + def rename_file( + self, + file_id: str | int | None = None, + new_file_name: str | None = None, + old_path: str | None = None, + new_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_rename_file( + torrent_hash=self._torrent_hash, + file_id=file_id, + new_file_name=new_file_name, + old_path=old_path, + new_path=new_path, + **kwargs, + ) - @alias("torrents_increasePrio") - @handle_hashes - @login_required - def torrents_increase_priority(self, torrent_hashes=None, **kwargs): - """ - Increase the priority of a torrent. Torrent Queuing must be enabled. + renameFile = rename_file - :raises Conflict409Error: + @wraps(TorrentsAPIMixIn.torrents_rename_folder) + def rename_folder( + self, + old_path: str | None = None, + new_path: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_rename_folder( + torrent_hash=self._torrent_hash, + old_path=old_path, + new_path=new_path, + **kwargs, + ) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - self._post(_name=APINames.Torrents, _method="increasePrio", data=data, **kwargs) + renameFolder = rename_folder - @alias("torrents_decreasePrio") - @handle_hashes - @login_required - def torrents_decrease_priority(self, torrent_hashes=None, **kwargs): - """ - Decrease the priority of a torrent. Torrent Queuing must be enabled. + @property + @wraps(TorrentsAPIMixIn.torrents_piece_states) + def piece_states(self) -> TorrentPieceInfoList: + return self._client.torrents_piece_states(torrent_hash=self._torrent_hash) - :raises Conflict409Error: + pieceStates = piece_states - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - self._post(_name=APINames.Torrents, _method="decreasePrio", data=data, **kwargs) + @property + @wraps(TorrentsAPIMixIn.torrents_piece_hashes) + def piece_hashes(self) -> TorrentPieceInfoList: + return self._client.torrents_piece_hashes(torrent_hash=self._torrent_hash) - @alias("torrents_topPrio") - @handle_hashes - @login_required - def torrents_top_priority(self, torrent_hashes=None, **kwargs): - """ - Set torrent as highest priority. Torrent Queuing must be enabled. + pieceHashes = piece_hashes - :raises Conflict409Error: + @wraps(TorrentsAPIMixIn.torrents_add_trackers) + def add_trackers( + self, + urls: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_add_trackers( + torrent_hash=self._torrent_hash, + urls=urls, + **kwargs, + ) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - self._post(_name=APINames.Torrents, _method="topPrio", data=data, **kwargs) + addTrackers = add_trackers - @alias("torrents_bottomPrio") - @handle_hashes - @login_required - def torrents_bottom_priority(self, torrent_hashes=None, **kwargs): - """ - Set torrent as lowest priority. Torrent Queuing must be enabled. + @wraps(TorrentsAPIMixIn.torrents_edit_tracker) + def edit_tracker( + self, + orig_url: str | None = None, + new_url: str | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_edit_tracker( + torrent_hash=self._torrent_hash, + original_url=orig_url, + new_url=new_url, + **kwargs, + ) - :raises Conflict409Error: + editTracker = edit_tracker - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - self._post(_name=APINames.Torrents, _method="bottomPrio", data=data, **kwargs) + @wraps(TorrentsAPIMixIn.torrents_remove_trackers) + def remove_trackers( + self, + urls: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_remove_trackers( + torrent_hash=self._torrent_hash, + urls=urls, + **kwargs, + ) - @alias("torrents_downloadLimit") - @handle_hashes - @login_required - def torrents_download_limit(self, torrent_hashes=None, **kwargs): - """ - Retrieve the download limit for one or more torrents. + removeTrackers = remove_trackers - :return: :class:`TorrentLimitsDictionary` - ``{hash: limit}`` (-1 represents no limit) - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - return self._post( - _name=APINames.Torrents, - _method="downloadLimit", - data=data, - response_class=TorrentLimitsDictionary, + @wraps(TorrentsAPIMixIn.torrents_file_priority) + def file_priority( + self, + file_ids: int | Iterable[str | int] | None = None, + priority: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_file_priority( + torrent_hash=self._torrent_hash, + file_ids=file_ids, + priority=priority, **kwargs, ) - @alias("torrents_setDownloadLimit") - @handle_hashes - @login_required - def torrents_set_download_limit(self, limit=None, torrent_hashes=None, **kwargs): - """ - Set the download limit for one or more torrents. + filePriority = file_priority - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :param limit: bytes/second (-1 sets the limit to infinity) - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "limit": limit, - } - self._post( - _name=APINames.Torrents, - _method="setDownloadLimit", - data=data, + @wraps(TorrentsAPIMixIn.torrents_rename) + def rename(self, new_name: str | None = None, **kwargs: APIKwargsT) -> None: + self._client.torrents_rename( + torrent_hash=self._torrent_hash, + new_torrent_name=new_name, **kwargs, ) - @alias("torrents_setShareLimits") - @endpoint_introduced("2.0.1", "torrents/setShareLimits") - @handle_hashes - @login_required - def torrents_set_share_limits( + @wraps(TorrentsAPIMixIn.torrents_add_tags) + def add_tags( self, - ratio_limit=None, - seeding_time_limit=None, - inactive_seeding_time_limit=None, - torrent_hashes=None, - **kwargs, - ): - """ - Set share limits for one or more torrents. - - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :param ratio_limit: max ratio to seed a torrent. (-2 means use the global value and -1 is no limit) - :param seeding_time_limit: minutes (-2 means use the global value and -1 is no limit) - :param inactive_seeding_time_limit: minutes (-2 means use the global value and -1 is no limit) - (added in Web API v2.9.2) - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "ratioLimit": ratio_limit, - "seedingTimeLimit": seeding_time_limit, - "inactiveSeedingTimeLimit": inactive_seeding_time_limit, - } - self._post( - _name=APINames.Torrents, - _method="setShareLimits", - data=data, + tags: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_add_tags( + torrent_hashes=self._torrent_hash, + tags=tags, **kwargs, ) - @alias("torrents_uploadLimit") - @handle_hashes - @login_required - def torrents_upload_limit(self, torrent_hashes=None, **kwargs): - """ - Retrieve the upload limit for one or more torrents. + addTags = add_tags - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: :class:`TorrentLimitsDictionary` - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - return self._post( - _name=APINames.Torrents, - _method="uploadLimit", - data=data, - response_class=TorrentLimitsDictionary, + @wraps(TorrentsAPIMixIn.torrents_remove_tags) + def remove_tags( + self, + tags: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_remove_tags( + torrent_hashes=self._torrent_hash, + tags=tags, **kwargs, ) - @alias("torrents_setUploadLimit") - @handle_hashes - @login_required - def torrents_set_upload_limit(self, limit=None, torrent_hashes=None, **kwargs): - """ - Set the upload limit for one or more torrents. + removeTags = remove_tags - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :param limit: bytes/second (-1 sets the limit to infinity) - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "limit": limit, - } - self._post( - _name=APINames.Torrents, - _method="setUploadLimit", - data=data, + @wraps(TorrentsAPIMixIn.torrents_export) + def export(self, **kwargs: APIKwargsT) -> bytes: + return self._client.torrents_export(torrent_hash=self._torrent_hash, **kwargs) + + +class Torrents(ClientCache[TorrentsAPIMixIn]): + """ + Allows interaction with the ``Torrents`` API endpoints. + + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> # these are all the same attributes that are available as named in the + >>> # endpoints or the more pythonic names in Client (with or without 'torrents_' prepended) + >>> torrent_list = client.torrents.info() + >>> torrent_list_active = client.torrents.info.active() + >>> torrent_list_active_partial = client.torrents.info.active(limit=100, offset=200) + >>> torrent_list_downloading = client.torrents.info.downloading() + >>> # torrent looping + >>> for torrent in client.torrents.info.completed() + >>> # all torrents endpoints with a 'hashes' parameters support all method to apply action to all torrents + >>> client.torrents.pause.all() + >>> client.torrents.resume.all() + >>> # or specify the individual hashes + >>> client.torrents.downloadLimit(torrent_hashes=['...', '...']) + """ + + def __init__(self, client: TorrentsAPIMixIn) -> None: + super().__init__(client=client) + self.info = self._Info(client=client) + self.resume = self._ActionForAllTorrents( + client=client, func=client.torrents_resume + ) + self.pause = self._ActionForAllTorrents( + client=client, func=client.torrents_pause + ) + self.delete = self._ActionForAllTorrents( + client=client, func=client.torrents_delete + ) + self.recheck = self._ActionForAllTorrents( + client=client, func=client.torrents_recheck + ) + self.reannounce = self._ActionForAllTorrents( + client=client, func=client.torrents_reannounce + ) + self.increase_priority = self._ActionForAllTorrents( + client=client, func=client.torrents_increase_priority + ) + self.increasePrio = self.increase_priority + self.decrease_priority = self._ActionForAllTorrents( + client=client, func=client.torrents_decrease_priority + ) + self.decreasePrio = self.decrease_priority + self.top_priority = self._ActionForAllTorrents( + client=client, func=client.torrents_top_priority + ) + self.topPrio = self.top_priority + self.bottom_priority = self._ActionForAllTorrents( + client=client, func=client.torrents_bottom_priority + ) + self.bottomPrio = self.bottom_priority + self.download_limit = self._ActionForAllTorrents( + client=client, func=client.torrents_download_limit + ) + self.downloadLimit = self.download_limit + self.upload_limit = self._ActionForAllTorrents( + client=client, func=client.torrents_upload_limit + ) + self.uploadLimit = self.upload_limit + self.set_download_limit = self._ActionForAllTorrents( + client=client, func=client.torrents_set_download_limit + ) + self.setDownloadLimit = self.set_download_limit + self.set_share_limits = self._ActionForAllTorrents( + client=client, func=client.torrents_set_share_limits + ) + self.setShareLimits = self.set_share_limits + self.set_upload_limit = self._ActionForAllTorrents( + client=client, func=client.torrents_set_upload_limit + ) + self.setUploadLimit = self.set_upload_limit + self.set_location = self._ActionForAllTorrents( + client=client, func=client.torrents_set_location + ) + self.set_save_path = self._ActionForAllTorrents( + client=client, func=client.torrents_set_save_path + ) + self.setSavePath = self.set_save_path + self.set_download_path = self._ActionForAllTorrents( + client=client, func=client.torrents_set_download_path + ) + self.setDownloadPath = self.set_download_path + self.setLocation = self.set_location + self.set_category = self._ActionForAllTorrents( + client=client, func=client.torrents_set_category + ) + self.setCategory = self.set_category + self.set_auto_management = self._ActionForAllTorrents( + client=client, func=client.torrents_set_auto_management + ) + self.setAutoManagement = self.set_auto_management + self.toggle_sequential_download = self._ActionForAllTorrents( + client=client, func=client.torrents_toggle_sequential_download + ) + self.toggleSequentialDownload = self.toggle_sequential_download + self.toggle_first_last_piece_priority = self._ActionForAllTorrents( + client=client, func=client.torrents_toggle_first_last_piece_priority + ) + self.toggleFirstLastPiecePrio = self.toggle_first_last_piece_priority + self.set_force_start = self._ActionForAllTorrents( + client=client, func=client.torrents_set_force_start + ) + self.setForceStart = self.set_force_start + self.set_super_seeding = self._ActionForAllTorrents( + client=client, func=client.torrents_set_super_seeding + ) + self.setSuperSeeding = self.set_super_seeding + self.add_peers = self._ActionForAllTorrents( + client=client, func=client.torrents_add_peers + ) + self.addPeers = self.add_peers + + @wraps(TorrentsAPIMixIn.torrents_add) + def add( + self, + urls: Iterable[str] | None = None, + torrent_files: TorrentFilesT | None = None, + save_path: str | None = None, + cookie: str | None = None, + category: str | None = None, + is_skip_checking: bool | None = None, + is_paused: bool | None = None, + is_root_folder: bool | None = None, + rename: str | None = None, + upload_limit: str | int | None = None, + download_limit: str | int | None = None, + use_auto_torrent_management: bool | None = None, + is_sequential_download: bool | None = None, + is_first_last_piece_priority: bool | None = None, + tags: Iterable[str] | None = None, + content_layout: None | (Literal["Original", "Subfolder", "NoSubFolder"]) = None, + ratio_limit: str | float | None = None, + seeding_time_limit: str | int | None = None, + download_path: str | None = None, + use_download_path: bool | None = None, + stop_condition: Literal["MetadataReceived", "FilesChecked"] | None = None, + **kwargs: APIKwargsT, + ) -> str: + return self._client.torrents_add( + urls=urls, + torrent_files=torrent_files, + save_path=save_path, + cookie=cookie, + category=category, + is_skip_checking=is_skip_checking, + is_paused=is_paused, + is_root_folder=is_root_folder, + rename=rename, + upload_limit=upload_limit, + download_limit=download_limit, + is_sequential_download=is_sequential_download, + use_auto_torrent_management=use_auto_torrent_management, + is_first_last_piece_priority=is_first_last_piece_priority, + tags=tags, + content_layout=content_layout, + ratio_limit=ratio_limit, + seeding_time_limit=seeding_time_limit, + download_path=download_path, + use_download_path=use_download_path, + stop_condition=stop_condition, **kwargs, ) - @alias("torrents_setLocation") - @handle_hashes - @login_required - def torrents_set_location(self, location=None, torrent_hashes=None, **kwargs): - """ - Set location for torrents' files. - - :raises Forbidden403Error: if the user doesn't have permissions to write to the - location (only before v4.5.2 - write check was removed.) - :raises Conflict409Error: if the directory cannot be created at the location - - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :param location: disk location to move torrent's files - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "location": location, - } - self._post(_name=APINames.Torrents, _method="setLocation", data=data, **kwargs) - - @alias("torrents_setSavePath") - @endpoint_introduced("2.8.4", "torrents/setSavePath") - @handle_hashes - @login_required - def torrents_set_save_path(self, save_path=None, torrent_hashes=None, **kwargs): - """ - Set the Save Path for one or more torrents. - - :raises Forbidden403Error: cannot write to directory - :raises Conflict409Error: directory cannot be created + class _ActionForAllTorrents(ClientCache["TorrentsAPIMixIn"]): + def __init__( + self, + client: TorrentsAPIMixIn, + func: Callable[..., Any], + ) -> None: + super().__init__(client=client) + self.func = func - :param save_path: file path to save torrent contents - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - """ - data = { - "id": self._list2string(torrent_hashes, "|"), - "path": save_path, - } - self._post(_name=APINames.Torrents, _method="setSavePath", data=data, **kwargs) + def __call__( + self, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> Any | None: + return self.func(torrent_hashes=torrent_hashes, **kwargs) - @alias("torrents_setDownloadPath") - @endpoint_introduced("2.8.4", "torrents/setDownloadPath") - @handle_hashes - @login_required - def torrents_set_download_path( - self, download_path=None, torrent_hashes=None, **kwargs - ): - """ - Set the Download Path for one or more torrents. + def all(self, **kwargs: APIKwargsT) -> Any | None: + return self.func(torrent_hashes="all", **kwargs) - :raises Forbidden403Error: cannot write to directory - :raises Conflict409Error: directory cannot be created + class _Info(ClientCache["TorrentsAPIMixIn"]): + def __call__( + self, + status_filter: TorrentStatusesT | None = None, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter=status_filter, + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - :param download_path: file path to save torrent contents before torrent finishes downloading - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - """ - data = { - "id": self._list2string(torrent_hashes, "|"), - "path": download_path, - } - self._post( - _name=APINames.Torrents, - _method="setDownloadPath", - data=data, - **kwargs, - ) + def all( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="all", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - @alias("torrents_setCategory") - @handle_hashes - @login_required - def torrents_set_category(self, category=None, torrent_hashes=None, **kwargs): - """ - Set a category for one or more torrents. + def downloading( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="downloading", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - :raises Conflict409Error: for bad category + def seeding( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="seeding", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :param category: category to assign to torrent - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "category": category, - } - self._post(_name=APINames.Torrents, _method="setCategory", data=data, **kwargs) + def completed( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="completed", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - @alias("torrents_setAutoManagement") - @handle_hashes - @login_required - def torrents_set_auto_management(self, enable=None, torrent_hashes=None, **kwargs): - """ - Enable or disable automatic torrent management for one or more torrents. + def paused( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="paused", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :param enable: Defaults to ``True`` if ``None`` or unset; use ``False`` to disable - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "enable": True if enable is None else bool(enable), - } - self._post( - _name=APINames.Torrents, - _method="setAutoManagement", - data=data, - **kwargs, - ) + def active( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="active", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - @alias("torrents_toggleSequentialDownload") - @handle_hashes - @login_required - def torrents_toggle_sequential_download(self, torrent_hashes=None, **kwargs): - """ - Toggle sequential download for one or more torrents. + def inactive( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="inactive", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes)} - self._post( - _name=APINames.Torrents, - _method="toggleSequentialDownload", - data=data, - **kwargs, - ) + def resumed( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="resumed", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - @alias("torrents_toggleFirstLastPiecePrio") - @handle_hashes - @login_required - def torrents_toggle_first_last_piece_priority(self, torrent_hashes=None, **kwargs): - """ - Toggle priority of first/last piece downloading. + def stalled( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="stalled", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = {"hashes": self._list2string(torrent_hashes, "|")} - self._post( - _name=APINames.Torrents, - _method="toggleFirstLastPiecePrio", - data=data, - **kwargs, - ) + def stalled_uploading( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="stalled_uploading", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - @alias("torrents_setForceStart") - @handle_hashes - @login_required - def torrents_set_force_start(self, enable=None, torrent_hashes=None, **kwargs): - """ - Force start one or more torrents. + def stalled_downloading( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="stalled_downloading", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :param enable: Defaults to ``True`` if ``None`` or unset; ``False`` is equivalent to - :meth:`~TorrentsAPIMixIn.torrents_resume()`. - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "value": True if enable is None else bool(enable), - } - self._post( - _name=APINames.Torrents, - _method="setForceStart", - data=data, - **kwargs, - ) + def checking( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="checking", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - @alias("torrents_setSuperSeeding") - @handle_hashes - @login_required - def torrents_set_super_seeding(self, enable=None, torrent_hashes=None, **kwargs): - """ - Set one or more torrents as super seeding. + def moving( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="moving", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :param enable: Defaults to ``True`` if ``None`` or unset; ``False`` to disable - :return: - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "value": True if enable is None else bool(enable), - } - self._post( - _name=APINames.Torrents, - _method="setSuperSeeding", - data=data, - **kwargs, - ) + def errored( + self, + category: str | None = None, + sort: str | None = None, + reverse: bool | None = None, + limit: str | int | None = None, + offset: str | int | None = None, + torrent_hashes: Iterable[str] | None = None, + tag: str | None = None, + **kwargs: APIKwargsT, + ) -> TorrentInfoList: + return self._client.torrents_info( + status_filter="errored", + category=category, + sort=sort, + reverse=reverse, + limit=limit, + offset=offset, + torrent_hashes=torrent_hashes, + tag=tag, + **kwargs, + ) - @alias("torrents_addPeers") - @endpoint_introduced("2.3.0", "torrents/addPeers") - @handle_hashes - @login_required - def torrents_add_peers(self, peers=None, torrent_hashes=None, **kwargs): - """ - Add one or more peers to one or more torrents. - :raises InvalidRequest400Error: for invalid peers +class TorrentCategories(ClientCache[TorrentsAPIMixIn]): + """ + Allows interaction with torrent categories within the ``Torrents`` API endpoints. - :param peers: one or more peers to add. each peer should take the form 'host:port' - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: :class:`TorrentsAddPeersDictionary` - ``{: {'added': #, 'failed': #}}`` - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "peers": self._list2string(peers, "|"), - } - return self._post( - _name=APINames.Torrents, - _method="addPeers", - data=data, - response_class=TorrentsAddPeersDictionary, - **kwargs, - ) + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> # these are all the same attributes that are available as named in the + >>> # endpoints or the more pythonic names in Client (with or without 'torrents_' prepended) + >>> categories = client.torrent_categories.categories + >>> # create or edit categories + >>> client.torrent_categories.create_category(name='Video', save_path='/home/user/torrents/Video') + >>> client.torrent_categories.edit_category(name='Video', save_path='/data/torrents/Video') + >>> # edit or create new by assignment + >>> client.torrent_categories.categories = dict(name='Video', save_path='/hone/user/') + >>> # delete categories + >>> client.torrent_categories.removeCategories(categories='Video') + >>> client.torrent_categories.removeCategories(categories=['Audio', "ISOs"]) + """ - # TORRENT CATEGORIES ENDPOINTS - @endpoint_introduced("2.1.1", "torrents/categories") - @login_required - def torrents_categories(self, **kwargs): - """ - Retrieve all category definitions. + @property + @wraps(TorrentsAPIMixIn.torrents_categories) + def categories(self) -> TorrentCategoriesDictionary: + return self._client.torrents_categories() - Note: ``torrents/categories`` is not available until v2.1.0 + @categories.setter + @wraps(TorrentsAPIMixIn.torrents_edit_category) + def categories(self, val: Mapping[str, str | bool]) -> None: + if val.get("name", "") in self.categories: + self.edit_category(**val) # type: ignore[arg-type] + else: + self.create_category(**val) # type: ignore[arg-type] - :return: :class:`TorrentCategoriesDictionary` - """ - return self._get( - _name=APINames.Torrents, - _method="categories", - response_class=TorrentCategoriesDictionary, + @wraps(TorrentsAPIMixIn.torrents_create_category) + def create_category( + self, + name: str | None = None, + save_path: str | None = None, + download_path: str | None = None, + enable_download_path: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.torrents_create_category( + name=name, + save_path=save_path, + download_path=download_path, + enable_download_path=enable_download_path, **kwargs, ) - @alias("torrents_createCategory") - @login_required - def torrents_create_category( - self, - name=None, - save_path=None, - download_path=None, - enable_download_path=None, - **kwargs, - ): - """ - Create a new torrent category. - - :raises Conflict409Error: if category name is not valid or unable to create - :param name: name for new category - :param save_path: location to save torrents for this category (added in Web API - 2.1.0) - :param download_path: download location for torrents with this category - :param enable_download_path: True or False to enable or disable download path - :return: None - """ - # default to actually using the specified download path - if enable_download_path is None and download_path is not None: - enable_download_path = True + createCategory = create_category - data = { - "category": name, - "savePath": save_path, - "downloadPath": download_path, - "downloadPathEnabled": enable_download_path, - } - self._post( - _name=APINames.Torrents, - _method="createCategory", - data=data, + @wraps(TorrentsAPIMixIn.torrents_edit_category) + def edit_category( + self, + name: str | None = None, + save_path: str | None = None, + download_path: str | None = None, + enable_download_path: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.torrents_edit_category( + name=name, + save_path=save_path, + download_path=download_path, + enable_download_path=enable_download_path, **kwargs, ) - @endpoint_introduced("2.1.0", "torrents/editCategory") - @alias("torrents_editCategory") - @login_required - def torrents_edit_category( + editCategory = edit_category + + @wraps(TorrentsAPIMixIn.torrents_remove_categories) + def remove_categories( self, - name=None, - save_path=None, - download_path=None, - enable_download_path=None, - **kwargs, - ): - """ - Edit an existing category. + categories: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.torrents_remove_categories(categories=categories, **kwargs) - Note: ``torrents/editCategory`` was introduced in Web API 2.1.0 + removeCategories = remove_categories - :raises Conflict409Error: if category name is not valid or unable to create - :param name: category to edit - :param save_path: new location to save files for this category - :param download_path: download location for torrents with this category - :param enable_download_path: True or False to enable or disable download path - :return: None - """ +class TorrentTags(ClientCache[TorrentsAPIMixIn]): + """ + Allows interaction with torrent tags within the "Torrent" API endpoints. - # default to actually using the specified download path - if enable_download_path is None and download_path is not None: - enable_download_path = True + Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> tags = client.torrent_tags.tags + >>> client.torrent_tags.tags = 'tv show' # create category + >>> client.torrent_tags.create_tags(tags=['tv show', 'linux distro']) + >>> client.torrent_tags.delete_tags(tags='tv show') + """ - data = { - "category": name, - "savePath": save_path, - "downloadPath": download_path, - "downloadPathEnabled": enable_download_path, - } - self._post(_name=APINames.Torrents, _method="editCategory", data=data, **kwargs) + @property + @wraps(TorrentsAPIMixIn.torrents_tags) + def tags(self) -> TagList: + return self._client.torrents_tags() - @alias("torrents_removeCategories") - @login_required - def torrents_remove_categories(self, categories=None, **kwargs): - """ - Delete one or more categories. + @tags.setter + @wraps(TorrentsAPIMixIn.torrents_create_tags) + def tags(self, val: Iterable[str] | None = None) -> None: + self._client.torrents_create_tags(tags=val) - :param categories: categories to delete - :return: None - """ - data = {"categories": self._list2string(categories, "\n")} - self._post( - _name=APINames.Torrents, - _method="removeCategories", - data=data, + @wraps(TorrentsAPIMixIn.torrents_add_tags) + def add_tags( + self, + tags: Iterable[str] | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_add_tags( + tags=tags, + torrent_hashes=torrent_hashes, **kwargs, ) - # TORRENT TAGS ENDPOINTS - @endpoint_introduced("2.3.0", "torrents/tags") - @login_required - def torrents_tags(self, **kwargs): - """ - Retrieve all tag definitions. + addTags = add_tags - :return: :class:`TagList` - """ - return self._get( - _name=APINames.Torrents, - _method="tags", - response_class=TagList, + @wraps(TorrentsAPIMixIn.torrents_remove_tags) + def remove_tags( + self, + tags: Iterable[str] | None = None, + torrent_hashes: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_remove_tags( + tags=tags, + torrent_hashes=torrent_hashes, **kwargs, ) - @alias("torrents_addTags") - @endpoint_introduced("2.3.0", "torrents/addTags") - @handle_hashes - @login_required - def torrents_add_tags(self, tags=None, torrent_hashes=None, **kwargs): - """ - Add one or more tags to one or more torrents. - - Note: Tags that do not exist will be created on-the-fly. - - :param tags: tag name or list of tags - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "tags": self._list2string(tags, ","), - } - self._post(_name=APINames.Torrents, _method="addTags", data=data, **kwargs) - - @alias("torrents_removeTags") - @endpoint_introduced("2.3.0", "torrents/removeTags") - @handle_hashes - @login_required - def torrents_remove_tags(self, tags=None, torrent_hashes=None, **kwargs): - """ - Add one or more tags to one or more torrents. - - :param tags: tag name or list of tags - :param torrent_hashes: single torrent hash or list of torrent hashes. Or ``all`` for all torrents. - :return: None - """ - data = { - "hashes": self._list2string(torrent_hashes, "|"), - "tags": self._list2string(tags, ","), - } - self._post(_name=APINames.Torrents, _method="removeTags", data=data, **kwargs) + removeTags = remove_tags - @alias("torrents_createTags") - @endpoint_introduced("2.3.0", "torrents/createTags") - @login_required - def torrents_create_tags(self, tags=None, **kwargs): - """ - Create one or more tags. + @wraps(TorrentsAPIMixIn.torrents_create_tags) + def create_tags( + self, + tags: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_create_tags(tags=tags, **kwargs) - :param tags: tag name or list of tags - :return: None - """ - data = {"tags": self._list2string(tags, ",")} - self._post(_name=APINames.Torrents, _method="createTags", data=data, **kwargs) + createTags = create_tags - @alias("torrents_deleteTags") - @endpoint_introduced("2.3.0", "torrents/deleteTags") - @login_required - def torrents_delete_tags(self, tags=None, **kwargs): - """ - Delete one or more tags. + @wraps(TorrentsAPIMixIn.torrents_delete_tags) + def delete_tags( + self, + tags: Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.torrents_delete_tags(tags=tags, **kwargs) - :param tags: tag name or list of tags - :return: None - """ - data = {"tags": self._list2string(tags, ",")} - self._post(_name=APINames.Torrents, _method="deleteTags", data=data, **kwargs) + deleteTags = delete_tags diff --git a/src/qbittorrentapi/torrents.pyi b/src/qbittorrentapi/torrents.pyi deleted file mode 100644 index c596cfea7..000000000 --- a/src/qbittorrentapi/torrents.pyi +++ /dev/null @@ -1,949 +0,0 @@ -from logging import Logger -from typing import IO -from typing import Any -from typing import Callable -from typing import Iterable -from typing import Literal -from typing import Mapping -from typing import Optional -from typing import Text -from typing import TypeVar - -from qbittorrentapi._types import DictMutableInputT -from qbittorrentapi._types import FilesToSendT -from qbittorrentapi._types import JsonDictionaryT -from qbittorrentapi._types import KwargsT -from qbittorrentapi._types import ListInputT -from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.definitions import ClientCache -from qbittorrentapi.definitions import List -from qbittorrentapi.definitions import ListEntry -from qbittorrentapi.definitions import TorrentState - -logger: Logger - -TorrentStatusesT = Literal[ - "all", - "downloading", - "seeding", - "completed", - "paused", - "active", - "inactive", - "resumed", - "stalled", - "stalled_uploading", - "stalled_downloading", - "checking", - "moving", - "errored", -] - -TorrentFilesT = TypeVar( - "TorrentFilesT", - bytes, - Text, - IO[bytes], - Mapping[Text, bytes | Text | IO[bytes]], - Iterable[bytes | Text | IO[bytes]], -) - -class TorrentDictionary(JsonDictionaryT): - def __init__(self, data: DictMutableInputT, client: TorrentsAPIMixIn) -> None: ... - def sync_local(self) -> None: ... - @property - def state_enum(self) -> TorrentState: ... - @property - def info(self) -> TorrentDictionary: ... - def resume(self, **kwargs: KwargsT) -> None: ... - def pause(self, **kwargs: KwargsT) -> None: ... - def delete( - self, - delete_files: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - def recheck(self, **kwargs: KwargsT) -> None: ... - def reannounce(self, **kwargs: KwargsT) -> None: ... - def increase_priority(self, **kwargs: KwargsT) -> None: ... - increasePrio = increase_priority - def decrease_priority(self, **kwargs: KwargsT) -> None: ... - decreasePrio = decrease_priority - def top_priority(self, **kwargs: KwargsT) -> None: ... - topPrio = top_priority - def bottom_priority(self, **kwargs: KwargsT) -> None: ... - bottomPrio = bottom_priority - def set_share_limits( - self, - ratio_limit: Optional[Text | int] = None, - seeding_time_limit: Optional[Text | int] = None, - inactive_seeding_time_limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - setShareLimits = set_share_limits - @property - def download_limit(self) -> TorrentLimitsDictionary: ... - @download_limit.setter - def download_limit(self, v: Text | int) -> None: ... - @property - def downloadLimit(self) -> TorrentLimitsDictionary: ... - @downloadLimit.setter - def downloadLimit(self, v: Text | int) -> None: ... - def set_download_limit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - setDownloadLimit = set_download_limit - @property - def upload_limit(self) -> TorrentLimitsDictionary: ... - @upload_limit.setter - def upload_limit(self, v: Text | int) -> None: ... - @property - def uploadLimit(self) -> TorrentLimitsDictionary: ... - @uploadLimit.setter - def uploadLimit(self, v: Text | int) -> None: ... - def set_upload_limit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - setUploadLimit = set_upload_limit - def set_location( - self, location: Optional[Text] = None, **kwargs: KwargsT - ) -> None: ... - setLocation = set_location - def set_download_path( - self, - download_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - def set_save_path( - self, - save_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - setSavePath = set_save_path - setDownloadPath = set_download_path - def set_category( - self, - category: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - setCategory = set_category - def set_auto_management( - self, - enable: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - setAutoManagement = set_auto_management - def toggle_sequential_download(self, **kwargs: KwargsT) -> None: ... - toggleSequentialDownload = toggle_sequential_download - def toggle_first_last_piece_priority(self, **kwargs: KwargsT) -> None: ... - toggleFirstLastPiecePrio = toggle_first_last_piece_priority - def set_force_start( - self, - enable: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - setForceStart = set_force_start - def set_super_seeding( - self, - enable: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - setSuperSeeding = set_super_seeding - @property - def properties(self) -> TorrentPropertiesDictionary: ... - @property - def trackers(self) -> TrackersList: ... - @trackers.setter - def trackers(self, v: Iterable[Text]) -> None: ... - @property - def webseeds(self) -> WebSeedsList: ... - @property - def files(self) -> TorrentFilesList: ... - def rename_file( - self, - file_id: Optional[Text | int] = None, - new_file_name: Optional[Text] = None, - old_path: Optional[Text] = None, - new_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - renameFile = rename_file - def rename_folder( - self, - old_path: Optional[Text] = None, - new_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - renameFolder = rename_folder - @property - def piece_states(self) -> TorrentPieceInfoList: ... - @property - def pieceStates(self) -> TorrentPieceInfoList: ... - @property - def piece_hashes(self) -> TorrentPieceInfoList: ... - @property - def pieceHashes(self) -> TorrentPieceInfoList: ... - def add_trackers( - self, - urls: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - addTrackers = add_trackers - def edit_tracker( - self, - orig_url: Optional[Text] = None, - new_url: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - editTracker = edit_tracker - def remove_trackers( - self, - urls: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - removeTrackers = remove_trackers - def file_priority( - self, - file_ids: Optional[int | Iterable[Text | int]] = None, - priority: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - filePriority = file_priority - def rename(self, new_name: Optional[Text] = None, **kwargs: KwargsT) -> None: ... - def add_tags( - self, - tags: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - addTags = add_tags - def remove_tags( - self, - tags: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - removeTags = remove_tags - def export(self, **kwargs: KwargsT) -> bytes: ... - -class TorrentPropertiesDictionary(JsonDictionaryT): ... -class TorrentLimitsDictionary(JsonDictionaryT): ... -class TorrentCategoriesDictionary(JsonDictionaryT): ... -class TorrentsAddPeersDictionary(JsonDictionaryT): ... -class TorrentFile(ListEntry): ... - -class TorrentFilesList(List[TorrentFile]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[TorrentsAPIMixIn] = None, - ) -> None: ... - -class WebSeed(ListEntry): ... - -class WebSeedsList(List[WebSeed]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[TorrentsAPIMixIn] = None, - ) -> None: ... - -class Tracker(ListEntry): ... - -class TrackersList(List[Tracker]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[TorrentsAPIMixIn] = None, - ) -> None: ... - -class TorrentInfoList(List[TorrentDictionary]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[TorrentsAPIMixIn] = None, - ) -> None: ... - -class TorrentPieceData(ListEntry): ... - -class TorrentPieceInfoList(List[TorrentPieceData]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[TorrentsAPIMixIn] = None, - ) -> None: ... - -class Tag(ListEntry): ... - -class TagList(List[Tag]): - def __init__( - self, - list_entries: ListInputT, - client: Optional[TorrentsAPIMixIn] = None, - ) -> None: ... - -class Torrents(ClientCache): - info: _Info - resume: _ActionForAllTorrents - pause: _ActionForAllTorrents - delete: _ActionForAllTorrents - recheck: _ActionForAllTorrents - reannounce: _ActionForAllTorrents - increase_priority: _ActionForAllTorrents - increasePrio: _ActionForAllTorrents - decrease_priority: _ActionForAllTorrents - decreasePrio: _ActionForAllTorrents - top_priority: _ActionForAllTorrents - topPrio: _ActionForAllTorrents - bottom_priority: _ActionForAllTorrents - bottomPrio: _ActionForAllTorrents - download_limit: _ActionForAllTorrents - downloadLimit: _ActionForAllTorrents - upload_limit: _ActionForAllTorrents - uploadLimit: _ActionForAllTorrents - set_download_limit: _ActionForAllTorrents - setDownloadLimit: _ActionForAllTorrents - set_share_limits: _ActionForAllTorrents - setShareLimits: _ActionForAllTorrents - set_upload_limit: _ActionForAllTorrents - setUploadLimit: _ActionForAllTorrents - set_location: _ActionForAllTorrents - setLocation: _ActionForAllTorrents - set_save_path: _ActionForAllTorrents - setSavePath: _ActionForAllTorrents - set_download_path: _ActionForAllTorrents - setDownloadPath: _ActionForAllTorrents - set_category: _ActionForAllTorrents - setCategory: _ActionForAllTorrents - set_auto_management: _ActionForAllTorrents - setAutoManagement: _ActionForAllTorrents - toggle_sequential_download: _ActionForAllTorrents - toggleSequentialDownload: _ActionForAllTorrents - toggle_first_last_piece_priority: _ActionForAllTorrents - toggleFirstLastPiecePrio: _ActionForAllTorrents - set_force_start: _ActionForAllTorrents - setForceStart: _ActionForAllTorrents - set_super_seeding: _ActionForAllTorrents - setSuperSeeding: _ActionForAllTorrents - add_peers: _ActionForAllTorrents - addPeers: _ActionForAllTorrents - def __init__(self, client: TorrentsAPIMixIn) -> None: ... - def add( - self, - urls: Optional[Iterable[Text]] = None, - torrent_files: Optional[TorrentFilesT] = None, - save_path: Optional[Text] = None, - cookie: Optional[Text] = None, - category: Optional[Text] = None, - is_skip_checking: Optional[bool] = None, - is_paused: Optional[bool] = None, - is_root_folder: Optional[bool] = None, - rename: Optional[Text] = None, - upload_limit: Optional[Text | int] = None, - download_limit: Optional[Text | int] = None, - use_auto_torrent_management: Optional[bool] = None, - is_sequential_download: Optional[bool] = None, - is_first_last_piece_priority: Optional[bool] = None, - tags: Optional[Iterable[Text]] = None, - content_layout: Optional[ - Literal["Original", "Subfolder", "NoSubFolder"] - ] = None, - ratio_limit: Optional[Text | float] = None, - seeding_time_limit: Optional[Text | int] = None, - download_path: Optional[Text] = None, - use_download_path: Optional[bool] = None, - stop_condition: Optional[Literal["MetadataReceived", "FilesChecked"]] = None, - **kwargs: KwargsT, - ) -> Text: ... - - class _ActionForAllTorrents(ClientCache): - func: Callable[..., Any] - def __init__( - self, - client: TorrentsAPIMixIn, - func: Callable[..., Any], - ) -> None: ... - def __call__( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> Optional[Any]: ... - def all(self, **kwargs: KwargsT) -> Optional[Any]: ... - - class _Info(ClientCache): - def __call__( - self, - status_filter: Optional[TorrentStatusesT] = None, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def all( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def downloading( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def seeding( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def completed( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def paused( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def active( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def inactive( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def resumed( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def stalled( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def stalled_uploading( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def stalled_downloading( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def checking( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def moving( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def errored( - self, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - -class TorrentCategories(ClientCache): - @property - def categories(self) -> TorrentCategoriesDictionary: ... - @categories.setter - def categories(self, v: Iterable[Text]) -> None: ... - def create_category( - self, - name: Optional[Text] = None, - save_path: Optional[Text] = None, - download_path: Optional[Text] = None, - enable_download_path: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - createCategory = create_category - def edit_category( - self, - name: Optional[Text] = None, - save_path: Optional[Text] = None, - download_path: Optional[Text] = None, - enable_download_path: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - editCategory = edit_category - def remove_categories( - self, - categories: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - removeCategories = remove_categories - -class TorrentTags(ClientCache): - @property - def tags(self) -> TagList: ... - @tags.setter - def tags(self, v: Optional[Iterable[Text]] = None) -> None: ... - def add_tags( - self, - tags: Optional[Iterable[Text]] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - addTags = add_tags - def remove_tags( - self, - tags: Optional[Iterable[Text]] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - removeTags = remove_tags - def create_tags( - self, - tags: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - createTags = create_tags - def delete_tags( - self, - tags: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - deleteTags = delete_tags - -class TorrentsAPIMixIn(AppAPIMixIn): - @property - def torrents(self) -> Torrents: ... - @property - def torrent_categories(self) -> TorrentCategories: ... - @property - def torrent_tags(self) -> TorrentTags: ... - def torrents_add( - self, - urls: Optional[Iterable[Text]] = None, - torrent_files: Optional[TorrentFilesT] = None, - save_path: Optional[Text] = None, - cookie: Optional[Text] = None, - category: Optional[Text] = None, - is_skip_checking: Optional[bool] = None, - is_paused: Optional[bool] = None, - is_root_folder: Optional[bool] = None, - rename: Optional[Text] = None, - upload_limit: Optional[Text | int] = None, - download_limit: Optional[Text | int] = None, - use_auto_torrent_management: Optional[bool] = None, - is_sequential_download: Optional[bool] = None, - is_first_last_piece_priority: Optional[bool] = None, - tags: Optional[Iterable[Text]] = None, - content_layout: Optional[ - Literal["Original", "Subfolder", "NoSubFolder"] - ] = None, - ratio_limit: Optional[Text | float] = None, - seeding_time_limit: Optional[Text | int] = None, - download_path: Optional[Text] = None, - use_download_path: Optional[bool] = None, - stop_condition: Optional[Literal["MetadataReceived", "FilesChecked"]] = None, - **kwargs: KwargsT, - ) -> Literal["Ok.", "Fails."]: ... - @staticmethod - def _normalize_torrent_files( - user_files: TorrentFilesT, - ) -> FilesToSendT | None: ... - def torrents_properties( - self, - torrent_hash: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentPropertiesDictionary: ... - def torrents_trackers( - self, - torrent_hash: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TrackersList: ... - def torrents_webseeds( - self, - torrent_hash: Optional[Text] = None, - **kwargs: KwargsT, - ) -> WebSeedsList: ... - def torrents_files( - self, - torrent_hash: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentFilesList: ... - def torrents_piece_states( - self, - torrent_hash: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentPieceInfoList: ... - torrents_pieceStates = torrents_piece_states - def torrents_piece_hashes( - self, - torrent_hash: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentPieceInfoList: ... - torrents_pieceHashes = torrents_piece_hashes - def torrents_add_trackers( - self, - torrent_hash: Optional[Text] = None, - urls: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_addTrackers = torrents_add_trackers - def torrents_edit_tracker( - self, - torrent_hash: Optional[Text] = None, - original_url: Optional[Text] = None, - new_url: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_editTracker = torrents_edit_tracker - def torrents_remove_trackers( - self, - torrent_hash: Optional[Text] = None, - urls: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_removeTrackers = torrents_remove_trackers - def torrents_file_priority( - self, - torrent_hash: Optional[Text] = None, - file_ids: Optional[int | Iterable[Text | int]] = None, - priority: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_filePrio = torrents_file_priority - def torrents_rename( - self, - torrent_hash: Optional[Text] = None, - new_torrent_name: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - def torrents_rename_file( - self, - torrent_hash: Optional[Text] = None, - file_id: Optional[Text | int] = None, - new_file_name: Optional[Text] = None, - old_path: Optional[Text] = None, - new_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_renameFile = torrents_rename_file - def torrents_rename_folder( - self, - torrent_hash: Optional[Text] = None, - old_path: Optional[Text] = None, - new_path: Optional[Text] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_renameFolder = torrents_rename_folder - def torrents_export( - self, - torrent_hash: Optional[Text] = None, - **kwargs: KwargsT, - ) -> bytes: ... - def torrents_info( - self, - status_filter: Optional[TorrentStatusesT] = None, - category: Optional[Text] = None, - sort: Optional[Text] = None, - reverse: Optional[bool] = None, - limit: Optional[Text | int] = None, - offset: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - tag: Optional[Text] = None, - **kwargs: KwargsT, - ) -> TorrentInfoList: ... - def torrents_resume( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - def torrents_pause( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - def torrents_delete( - self, - delete_files: Optional[bool] = False, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - def torrents_recheck( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - def torrents_reannounce( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - def torrents_increase_priority( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_increasePrio = torrents_increase_priority - def torrents_decrease_priority( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_decreasePrio = torrents_decrease_priority - def torrents_top_priority( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_topPrio = torrents_top_priority - def torrents_bottom_priority( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_bottomPrio = torrents_bottom_priority - def torrents_download_limit( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> TorrentLimitsDictionary: ... - torrents_downloadLimit = torrents_download_limit - def torrents_set_download_limit( - self, - limit: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setDownloadLimit = torrents_set_download_limit - def torrents_set_share_limits( - self, - ratio_limit: Optional[Text | int] = None, - seeding_time_limit: Optional[Text | int] = None, - inactive_seeding_time_limit: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setShareLimits = torrents_set_share_limits - def torrents_upload_limit( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> TorrentLimitsDictionary: ... - torrents_uploadLimit = torrents_upload_limit - def torrents_set_upload_limit( - self, - limit: Optional[Text | int] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setUploadLimit = torrents_set_upload_limit - def torrents_set_location( - self, - location: Optional[Text] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setLocation = torrents_set_location - def torrents_set_save_path( - self, - save_path: Optional[Text] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setSavePath = torrents_set_save_path - def torrents_set_download_path( - self, - download_path: Optional[Text] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setDownloadPath = torrents_set_download_path - def torrents_set_category( - self, - category: Optional[Text] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setCategory = torrents_set_category - def torrents_set_auto_management( - self, - enable: Optional[bool] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setAutoManagement = torrents_set_auto_management - def torrents_toggle_sequential_download( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_toggleSequentialDownload = torrents_toggle_sequential_download - def torrents_toggle_first_last_piece_priority( - self, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_toggleFirstLastPiecePrio = torrents_toggle_first_last_piece_priority - def torrents_set_force_start( - self, - enable: Optional[bool] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setForceStart = torrents_set_force_start - def torrents_set_super_seeding( - self, - enable: Optional[bool] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_setSuperSeeding = torrents_set_super_seeding - def torrents_add_peers( - self, - peers: Optional[Iterable[Text]] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> TorrentsAddPeersDictionary: ... - torrents_addPeers = torrents_add_peers - def torrents_categories(self, **kwargs: KwargsT) -> TorrentCategoriesDictionary: ... - def torrents_create_category( - self, - name: Optional[Text] = None, - save_path: Optional[Text] = None, - download_path: Optional[Text] = None, - enable_download_path: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_createCategory = torrents_create_category - def torrents_edit_category( - self, - name: Optional[Text] = None, - save_path: Optional[Text] = None, - download_path: Optional[Text] = None, - enable_download_path: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_editCategory = torrents_edit_category - def torrents_remove_categories( - self, - categories: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_removeCategories = torrents_remove_categories - def torrents_tags(self, **kwargs: KwargsT) -> TagList: ... - def torrents_add_tags( - self, - tags: Optional[Iterable[Text]] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_addTags = torrents_add_tags - def torrents_remove_tags( - self, - tags: Optional[Iterable[Text]] = None, - torrent_hashes: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_removeTags = torrents_remove_tags - def torrents_create_tags( - self, - tags: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_createTags = torrents_create_tags - def torrents_delete_tags( - self, - tags: Optional[Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - torrents_deleteTags = torrents_delete_tags diff --git a/src/qbittorrentapi/transfer.py b/src/qbittorrentapi/transfer.py index 675aa03d5..c82090a50 100644 --- a/src/qbittorrentapi/transfer.py +++ b/src/qbittorrentapi/transfer.py @@ -1,118 +1,25 @@ +from __future__ import annotations + +from functools import wraps +from typing import Iterable + from qbittorrentapi._version_support import v from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.decorators import alias -from qbittorrentapi.decorators import aliased -from qbittorrentapi.decorators import endpoint_introduced -from qbittorrentapi.decorators import login_required +from qbittorrentapi.definitions import APIKwargsT from qbittorrentapi.definitions import APINames from qbittorrentapi.definitions import ClientCache from qbittorrentapi.definitions import Dictionary +from qbittorrentapi.definitions import JsonValueT -class TransferInfoDictionary(Dictionary): - """Response to :meth:`~TransferAPIMixIn.transfer_info`""" - - -@aliased -class Transfer(ClientCache): +class TransferInfoDictionary(Dictionary[JsonValueT]): """ - Allows interaction with the ``Transfer`` API endpoints. + Response to :meth:`~TransferAPIMixIn.transfer_info` - :Usage: - >>> from qbittorrentapi import Client - >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') - >>> # these are all the same attributes that are available as named in the - >>> # endpoints or the more pythonic names in Client (with or without 'transfer_' prepended) - >>> transfer_info = client.transfer.info - >>> # access and set download/upload limits as attributes - >>> dl_limit = client.transfer.download_limit - >>> # this updates qBittorrent in real-time - >>> client.transfer.download_limit = 1024000 - >>> # update speed limits mode to alternate or not - >>> client.transfer.speedLimitsMode = True - """ + Definition: ``_ + """ # noqa: E501 - @property - def info(self): - """Implements :meth:`~TransferAPIMixIn.transfer_info`""" - return self._client.transfer_info() - - @property - def speed_limits_mode(self): - """Implements :meth:`~TransferAPIMixIn.transfer_speed_limits_mode`""" - return self._client.transfer_speed_limits_mode() - speedLimitsMode = speed_limits_mode - - @speedLimitsMode.setter - def speedLimitsMode(self, v): - """Implements :meth:`~TransferAPIMixIn.transfer_set_speed_limits_mode`""" - self.speed_limits_mode = v - - @speed_limits_mode.setter - def speed_limits_mode(self, v): - """Implements :meth:`~TransferAPIMixIn.transfer_set_speed_limits_mode`""" - self.set_speed_limits_mode(intended_state=v) - - @alias("setSpeedLimitsMode", "toggleSpeedLimitsMode", "toggle_speed_limits_mode") - def set_speed_limits_mode(self, intended_state=None, **kwargs): - """Implements :meth:`~TransferAPIMixIn.transfer_set_speed_limits_mode`""" - return self._client.transfer_set_speed_limits_mode( - intended_state=intended_state, - **kwargs, - ) - - @property - def download_limit(self): - """Implements :meth:`~TransferAPIMixIn.transfer_download_limit`""" - return self._client.transfer_download_limit() - - downloadLimit = download_limit - - @downloadLimit.setter - def downloadLimit(self, v): - """Implements :meth:`~TransferAPIMixIn.transfer_set_download_limit`""" - self.download_limit = v - - @download_limit.setter - def download_limit(self, v): - """Implements :meth:`~TransferAPIMixIn.transfer_set_download_limit`""" - self.set_download_limit(limit=v) - - @property - def upload_limit(self): - """Implements :meth:`~TransferAPIMixIn.transfer_upload_limit`""" - return self._client.transfer_upload_limit() - - uploadLimit = upload_limit - - @uploadLimit.setter - def uploadLimit(self, v): - """Implements :meth:`~TransferAPIMixIn.transfer_set_upload_limit`""" - self.upload_limit = v - - @upload_limit.setter - def upload_limit(self, v): - """Implements :meth:`~TransferAPIMixIn.transfer_set_upload_limit`""" - self.set_upload_limit(limit=v) - - @alias("setDownloadLimit") - def set_download_limit(self, limit=None, **kwargs): - """Implements :meth:`~TransferAPIMixIn.transfer_set_download_limit`""" - return self._client.transfer_set_download_limit(limit=limit, **kwargs) - - @alias("setUploadLimit") - def set_upload_limit(self, limit=None, **kwargs): - """Implements :meth:`~TransferAPIMixIn.transfer_set_upload_limit`""" - return self._client.transfer_set_upload_limit(limit=limit, **kwargs) - - @alias("banPeers") - def ban_peers(self, peers=None, **kwargs): - """Implements :meth:`~TransferAPIMixIn.transfer_ban_peers`""" - return self._client.transfer_ban_peers(peers=peers, **kwargs) - - -@aliased class TransferAPIMixIn(AppAPIMixIn): """ Implementation of all ``Transfer`` API methods. @@ -125,59 +32,47 @@ class TransferAPIMixIn(AppAPIMixIn): """ @property - def transfer(self): + def transfer(self) -> Transfer: """ Allows for transparent interaction with Transfer endpoints. See Transfer class for usage. - :return: Transfer object """ if self._transfer is None: self._transfer = Transfer(client=self) return self._transfer - @login_required - def transfer_info(self, **kwargs): - """ - Retrieves the global transfer info found in qBittorrent status bar. - - :return: :class:`TransferInfoDictionary` - ``_ - """ # noqa: E501 - return self._get( + def transfer_info(self, **kwargs: APIKwargsT) -> TransferInfoDictionary: + """Retrieves the global transfer info found in qBittorrent status bar.""" + return self._get_cast( _name=APINames.Transfer, _method="info", response_class=TransferInfoDictionary, **kwargs, ) - @alias("transfer_speedLimitsMode") - @login_required - def transfer_speed_limits_mode(self, **kwargs): - """ - Retrieves whether alternative speed limits are enabled. - - :return: ``1`` if alternative speed limits are currently enabled, ``0`` otherwise - """ - return self._get( + def transfer_speed_limits_mode(self, **kwargs: APIKwargsT) -> str: + """Returns ``1`` if alternative speed limits are currently enabled, ``0`` + otherwise.""" + return self._get_cast( _name=APINames.Transfer, _method="speedLimitsMode", response_class=str, **kwargs, ) - @alias( - "transfer_setSpeedLimitsMode", - "transfer_toggleSpeedLimitsMode", - "transfer_toggle_speed_limits_mode", - ) - @login_required - def transfer_set_speed_limits_mode(self, intended_state=None, **kwargs): + transfer_speedLimitsMode = transfer_speed_limits_mode + + def transfer_set_speed_limits_mode( + self, + intended_state: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Sets whether alternative speed limits are enabled. :param intended_state: True to enable alt speed and False to disable. Leaving None will toggle the current state. - :return: None """ if intended_state is None: self._post( @@ -202,44 +97,41 @@ def transfer_set_speed_limits_mode(self, intended_state=None, **kwargs): **kwargs, ) - @alias("transfer_downloadLimit") - @login_required - def transfer_download_limit(self, **kwargs): - """ - Retrieves download limit. 0 is unlimited. + transfer_setSpeedLimitsMode = transfer_set_speed_limits_mode + transfer_toggleSpeedLimitsMode = transfer_set_speed_limits_mode + transfer_toggle_speed_limits_mode = transfer_set_speed_limits_mode - :return: integer - """ - return self._get( + def transfer_download_limit(self, **kwargs: APIKwargsT) -> int: + """Retrieves download limit; 0 is unlimited.""" + return self._get_cast( _name=APINames.Transfer, _method="downloadLimit", response_class=int, **kwargs, ) - @alias("transfer_uploadLimit") - @login_required - def transfer_upload_limit(self, **kwargs): - """ - Retrieves upload limit. 0 is unlimited. + transfer_downloadLimit = transfer_download_limit - :return: integer - """ - return self._get( + def transfer_upload_limit(self, **kwargs: APIKwargsT) -> int: + """Retrieves upload limit; 0 is unlimited.""" + return self._get_cast( _name=APINames.Transfer, _method="uploadLimit", response_class=int, **kwargs, ) - @alias("transfer_setDownloadLimit") - @login_required - def transfer_set_download_limit(self, limit=None, **kwargs): + transfer_uploadLimit = transfer_upload_limit + + def transfer_set_download_limit( + self, + limit: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Set the global download limit in bytes/second. :param limit: download limit in bytes/second (0 or -1 for no limit) - :return: None """ data = {"limit": limit} self._post( @@ -249,14 +141,17 @@ def transfer_set_download_limit(self, limit=None, **kwargs): **kwargs, ) - @alias("transfer_setUploadLimit") - @login_required - def transfer_set_upload_limit(self, limit=None, **kwargs): + transfer_setDownloadLimit = transfer_set_download_limit + + def transfer_set_upload_limit( + self, + limit: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Set the global download limit in bytes/second. :param limit: upload limit in bytes/second (0 or -1 for no limit) - :return: None """ data = {"limit": limit} self._post( @@ -266,16 +161,157 @@ def transfer_set_upload_limit(self, limit=None, **kwargs): **kwargs, ) - @alias("transfer_banPeers") - @endpoint_introduced("2.3", "transfer/banPeers") - @login_required - def transfer_ban_peers(self, peers=None, **kwargs): + transfer_setUploadLimit = transfer_set_upload_limit + + def transfer_ban_peers( + self, + peers: str | Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: """ Ban one or more peers. + This method was introduced with qBittorrent v4.2.0 (Web API v2.3.0). + :param peers: one or more peers to ban. each peer should take the form 'host:port' - :return: None """ data = {"peers": self._list2string(peers, "|")} - self._post(_name=APINames.Transfer, _method="banPeers", data=data, **kwargs) + self._post( + _name=APINames.Transfer, + _method="banPeers", + data=data, + version_introduced="2.3", + **kwargs, + ) + + transfer_banPeers = transfer_ban_peers + + +class Transfer(ClientCache[TransferAPIMixIn]): + """ + Allows interaction with the ``Transfer`` API endpoints. + + :Usage: + >>> from qbittorrentapi import Client + >>> client = Client(host='localhost:8080', username='admin', password='adminadmin') + >>> # these are all the same attributes that are available as named in the + >>> # endpoints or the more pythonic names in Client (with or without 'transfer_' prepended) + >>> transfer_info = client.transfer.info + >>> # access and set download/upload limits as attributes + >>> dl_limit = client.transfer.download_limit + >>> # this updates qBittorrent in real-time + >>> client.transfer.download_limit = 1024000 + >>> # update speed limits mode to alternate or not + >>> client.transfer.speedLimitsMode = True + """ + + @property + @wraps(TransferAPIMixIn.transfer_info) + def info(self) -> TransferInfoDictionary: + return self._client.transfer_info() + + @property + @wraps(TransferAPIMixIn.transfer_speed_limits_mode) + def speed_limits_mode(self) -> str: + return self._client.transfer_speed_limits_mode() + + @speed_limits_mode.setter + @wraps(TransferAPIMixIn.transfer_set_speed_limits_mode) + def speed_limits_mode(self, val: bool) -> None: + self.set_speed_limits_mode(intended_state=val) + + @property + @wraps(TransferAPIMixIn.transfer_speed_limits_mode) + def speedLimitsMode(self) -> str: + return self._client.transfer_speed_limits_mode() + + @speedLimitsMode.setter + @wraps(TransferAPIMixIn.transfer_set_speed_limits_mode) + def speedLimitsMode(self, val: bool) -> None: + self.set_speed_limits_mode(intended_state=val) + + @wraps(TransferAPIMixIn.transfer_set_speed_limits_mode) + def set_speed_limits_mode( + self, + intended_state: bool | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.transfer_set_speed_limits_mode( + intended_state=intended_state, + **kwargs, + ) + + setSpeedLimitsMode = set_speed_limits_mode + toggleSpeedLimitsMode = set_speed_limits_mode + toggle_speed_limits_mode = set_speed_limits_mode + + @property + @wraps(TransferAPIMixIn.transfer_download_limit) + def download_limit(self) -> int: + return self._client.transfer_download_limit() + + @download_limit.setter + @wraps(TransferAPIMixIn.transfer_set_download_limit) + def download_limit(self, val: int | str) -> None: + self.set_download_limit(limit=val) + + @property + @wraps(TransferAPIMixIn.transfer_download_limit) + def downloadLimit(self) -> int: + return self._client.transfer_download_limit() + + @downloadLimit.setter + @wraps(TransferAPIMixIn.transfer_set_download_limit) + def downloadLimit(self, val: int | str) -> None: + self.set_download_limit(limit=val) + + @property + @wraps(TransferAPIMixIn.transfer_upload_limit) + def upload_limit(self) -> int: + return self._client.transfer_upload_limit() + + @upload_limit.setter + @wraps(TransferAPIMixIn.transfer_set_upload_limit) + def upload_limit(self, val: int | str) -> None: + self.set_upload_limit(limit=val) + + @property + @wraps(TransferAPIMixIn.transfer_upload_limit) + def uploadLimit(self) -> int: + return self._client.transfer_upload_limit() + + @uploadLimit.setter + @wraps(TransferAPIMixIn.transfer_set_upload_limit) + def uploadLimit(self, val: int | str) -> None: + self.set_upload_limit(limit=val) + + @wraps(TransferAPIMixIn.transfer_set_download_limit) + def set_download_limit( + self, + limit: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.transfer_set_download_limit(limit=limit, **kwargs) + + setDownloadLimit = set_download_limit + + @wraps(TransferAPIMixIn.transfer_set_upload_limit) + def set_upload_limit( + self, + limit: str | int | None = None, + **kwargs: APIKwargsT, + ) -> None: + return self._client.transfer_set_upload_limit(limit=limit, **kwargs) + + setUploadLimit = set_upload_limit + + @wraps(TransferAPIMixIn.transfer_ban_peers) + def ban_peers( + self, + peers: str | Iterable[str] | None = None, + **kwargs: APIKwargsT, + ) -> None: + self._client.transfer_ban_peers(peers=peers, **kwargs) + + banPeers = ban_peers diff --git a/src/qbittorrentapi/transfer.pyi b/src/qbittorrentapi/transfer.pyi deleted file mode 100644 index 86b48ae3c..000000000 --- a/src/qbittorrentapi/transfer.pyi +++ /dev/null @@ -1,149 +0,0 @@ -from typing import Iterable -from typing import Optional -from typing import Text - -from qbittorrentapi._types import JsonValueT -from qbittorrentapi._types import KwargsT -from qbittorrentapi.app import AppAPIMixIn -from qbittorrentapi.definitions import ClientCache -from qbittorrentapi.definitions import Dictionary - -# mypy crashes when this is imported from _types... -JsonDictionaryT = Dictionary[Text, JsonValueT] - -class TransferInfoDictionary(JsonDictionaryT): ... - -class Transfer(ClientCache): - @property - def info(self) -> TransferInfoDictionary: ... - @property - def speed_limits_mode(self) -> Text: ... - @speed_limits_mode.setter - def speed_limits_mode(self, v: Text | int) -> None: ... - @property - def speedLimitsMode(self) -> Text: ... - @speedLimitsMode.setter - def speedLimitsMode(self, v: Text | int) -> None: ... - def set_speed_limits_mode( - self, - intended_state: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - setSpeedLimitsMode = set_speed_limits_mode - def toggleSpeedLimitsMode( - self, - intended_state: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - def toggle_speed_limits_mode( - self, - intended_state: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - @property - def download_limit(self) -> int: ... - @download_limit.setter - def download_limit(self, v: Text | int) -> None: ... - @property - def downloadLimit(self) -> int: ... - @downloadLimit.setter - def downloadLimit(self, v: Text | int) -> None: ... - @property - def upload_limit(self) -> int: ... - @upload_limit.setter - def upload_limit(self, v: Text | int) -> None: ... - @property - def uploadLimit(self) -> int: ... - @uploadLimit.setter - def uploadLimit(self, v: Text | int) -> None: ... - def set_download_limit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def setDownloadLimit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def set_upload_limit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def setUploadLimit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def ban_peers( - self, - peers: Optional[Text | Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - def banPeers( - self, - peers: Optional[Text | Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - -class TransferAPIMixIn(AppAPIMixIn): - @property - def transfer(self) -> Transfer: ... - def transfer_info(self, **kwargs: KwargsT) -> TransferInfoDictionary: ... - def transfer_speed_limits_mode(self, **kwargs: KwargsT) -> str: ... - def transfer_speedLimitsMode(self, **kwargs: KwargsT) -> str: ... - def transfer_set_speed_limits_mode( - self, - intended_state: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - def transfer_setSpeedLimitsMode( - self, - intended_state: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - def transfer_toggleSpeedLimitsMode( - self, - intended_state: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - def transfer_toggle_speed_limits_mode( - self, - intended_state: Optional[bool] = None, - **kwargs: KwargsT, - ) -> None: ... - def transfer_download_limit(self, **kwargs: KwargsT) -> int: ... - def transfer_downloadLimit(self, **kwargs: KwargsT) -> int: ... - def transfer_upload_limit(self, **kwargs: KwargsT) -> int: ... - def transfer_uploadLimit(self, **kwargs: KwargsT) -> int: ... - def transfer_set_download_limit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def transfer_setDownloadLimit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def transfer_set_upload_limit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def transfer_setUploadLimit( - self, - limit: Optional[Text | int] = None, - **kwargs: KwargsT, - ) -> None: ... - def transfer_ban_peers( - self, - peers: Optional[Text | Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... - def transfer_banPeers( - self, - peers: Optional[Text | Iterable[Text]] = None, - **kwargs: KwargsT, - ) -> None: ... diff --git a/tests/_resources/mypy_stubtest_allowlist.txt b/tests/_resources/mypy_stubtest_allowlist.txt deleted file mode 100644 index 9d4c69915..000000000 --- a/tests/_resources/mypy_stubtest_allowlist.txt +++ /dev/null @@ -1,16 +0,0 @@ -qbittorrentapi._attrdict.K -qbittorrentapi._attrdict.KOther -qbittorrentapi._attrdict.KwargsT -qbittorrentapi._attrdict.V -qbittorrentapi._attrdict.VOther -qbittorrentapi._types -qbittorrentapi.decorators.APIClassT -qbittorrentapi.decorators.APIReturnValueT -qbittorrentapi.definitions.K -qbittorrentapi.definitions.ListEntryT -qbittorrentapi.definitions.V -qbittorrentapi.request.FinalResponseT -qbittorrentapi.sync.JsonDictionaryT -qbittorrentapi.torrents.TorrentFilesT -qbittorrentapi.torrents.TorrentStatusesT -qbittorrentapi.transfer.JsonDictionaryT diff --git a/tests/conftest.py b/tests/conftest.py index ac509f293..65331a34d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -120,12 +120,16 @@ def client(): def client_mock(client): """qBittorrent Client for testing with request mocks.""" client._get = MagicMock(wraps=client._get) + client._get_cast = MagicMock(wraps=client._get_cast) client._post = MagicMock(wraps=client._post) + client._post_cast = MagicMock(wraps=client._post_cast) try: yield client finally: client._get = client._get + client._get_cast = client._get_cast client._post = client._post + client._post_cast = client._post_cast @pytest.fixture diff --git a/tests/test_decorators.py b/tests/test_decorators.py deleted file mode 100644 index 386d5cd00..000000000 --- a/tests/test_decorators.py +++ /dev/null @@ -1,115 +0,0 @@ -import logging - -import pytest - -from qbittorrentapi import Client -from qbittorrentapi.decorators import endpoint_introduced -from qbittorrentapi.decorators import handle_hashes -from qbittorrentapi.decorators import version_removed -from qbittorrentapi.request import Request - -list2str = Request._list2string - - -def test_login_required(caplog, app_version): - client = Client( - RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=True, - VERIFY_WEBUI_CERTIFICATE=False, - ) - # check if first API call works if already logged in - client.auth_log_in() - with caplog.at_level(logging.DEBUG, logger="qbittorrentapi"): - qbt_version = client.app.version - assert "Login may have expired...attempting new login" not in caplog.text - assert qbt_version == app_version - - # ensure login happens after first API call fails - client.auth_log_out() - with caplog.at_level(logging.DEBUG, logger="qbittorrentapi"): - qbt_version = client.app.version - assert "Login may have expired...attempting new login" in caplog.text - assert qbt_version == app_version - - -@pytest.mark.parametrize("test_hash", (None, "", "xcvbxcvbxcvb")) -@pytest.mark.parametrize("other_param", (None, "", "irivgjkd")) -def test_hash_handler_single_hash(test_hash, other_param): - class FakeClient: - @handle_hashes - def single_torrent_func(self, torrent_hash=None, param=None): - assert torrent_hash == test_hash - assert param == other_param - - FakeClient().single_torrent_func(test_hash, other_param) - FakeClient().single_torrent_func(test_hash, param=other_param) - FakeClient().single_torrent_func(hash=test_hash, param=other_param) - FakeClient().single_torrent_func(torrent_hash=test_hash, param=other_param) - - -@pytest.mark.parametrize( - "test_hashes", (None, "", "xcvbxcvbxcvb", ("xcvbxcvbxcvb", "ertyertye")) -) -@pytest.mark.parametrize("other_param", (None, "", "irivgjkd")) -def test_hash_handler_multiple_hashes(test_hashes, other_param): - class FakeClient: - @handle_hashes - def multiple_torrent_func(self, torrent_hashes=None, param=None): - assert list2str(torrent_hashes) == list2str(test_hashes) - assert param == other_param - - FakeClient().multiple_torrent_func(test_hashes, other_param) - FakeClient().multiple_torrent_func(test_hashes, param=other_param) - FakeClient().multiple_torrent_func(hashes=test_hashes, param=other_param) - FakeClient().multiple_torrent_func(torrent_hashes=test_hashes, param=other_param) - - -def test_version_implemented(): - class FakeClient: - _RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS = True - version = "1.0" - - def app_web_api_version(self): - return self.version - - @endpoint_introduced("1.1", "test1") - def endpoint_not_implemented(self): - return - - @endpoint_introduced("0.9", "test2") - def endpoint_implemented(self): - return - - with pytest.raises(NotImplementedError): - FakeClient().endpoint_not_implemented() - - assert FakeClient().endpoint_implemented() is None - - fake_client = FakeClient() - fake_client._RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS = False - assert fake_client.endpoint_not_implemented() is None - - -def test_version_removed(): - class FakeClient: - _RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS = True - version = "1.0" - - def app_web_api_version(self): - return self.version - - @version_removed("0.0.0", "test1") - def endpoint_not_implemented(self): - return - - @version_removed("9999999", "test2") - def endpoint_implemented(self): - return - - with pytest.raises(NotImplementedError): - FakeClient().endpoint_not_implemented() - - assert FakeClient().endpoint_implemented() is None - - fake_client = FakeClient() - fake_client._RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS = False - assert fake_client.endpoint_not_implemented() is None diff --git a/tests/test_definitions.py b/tests/test_definitions.py index 8632a2e18..60caa6148 100644 --- a/tests/test_definitions.py +++ b/tests/test_definitions.py @@ -168,39 +168,31 @@ def test_dictionary(): def test_list(client): assert len(List()) == 0 list_entries = [{"one": "1"}, {"two": "2"}, {"three": "3"}] - test_list = List(list_entries, entry_class=ListEntry, client=client) + test_list = List(list_entries, entry_class=ListEntry) assert len(test_list) == 3 assert issubclass(type(test_list[0]), ListEntry) assert test_list[0].one == "1" -def test_list_without_client(): - list_one = List([{"one": "1"}, {"two": "2"}], entry_class=ListEntry) - # without client, the entries will not be converted - assert not isinstance(list_one[0], ListEntry) - assert isinstance(list_one[0], dict) - - def test_list_actions(client): list_one = List( [{"one": "1"}, {"two": "2"}, {"three": "3"}], entry_class=ListEntry, - client=client, ) - list_two = List([{"four": "4"}], entry_class=ListEntry, client=client) + list_two = List([{"four": "4"}], entry_class=ListEntry) assert list_one[1:3] == [ - ListEntry({"two": "2"}, client=client), - ListEntry({"three": "3"}, client=client), + ListEntry({"two": "2"}), + ListEntry({"three": "3"}), ] assert list_one + list_two == [ - ListEntry({"one": "1"}, client=client), - ListEntry({"two": "2"}, client=client), - ListEntry({"three": "3"}, client=client), - ] + [ListEntry({"four": "4"}, client=client)] + ListEntry({"one": "1"}), + ListEntry({"two": "2"}), + ListEntry({"three": "3"}), + ] + [ListEntry({"four": "4"})] assert list_one.copy() == [ - ListEntry({"one": "1"}, client=client), - ListEntry({"two": "2"}, client=client), - ListEntry({"three": "3"}, client=client), + ListEntry({"one": "1"}), + ListEntry({"two": "2"}), + ListEntry({"three": "3"}), ] diff --git a/tests/test_log.py b/tests/test_log.py index e771d442c..5028a3134 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -29,7 +29,7 @@ def test_log_main_slice(client, main_func): def test_log_main_info(client_mock): assert isinstance(client_mock.log.main.info(), LogMainList) - client_mock._get.assert_called_with( + client_mock._get_cast.assert_called_with( _name=APINames.Log, _method="main", params={ @@ -45,7 +45,7 @@ def test_log_main_info(client_mock): def test_log_main_normal(client_mock): assert isinstance(client_mock.log.main.normal(), LogMainList) - client_mock._get.assert_called_with( + client_mock._get_cast.assert_called_with( _name=APINames.Log, _method="main", params={ @@ -61,7 +61,7 @@ def test_log_main_normal(client_mock): def test_log_main_warning(client_mock): assert isinstance(client_mock.log.main.warning(), LogMainList) - client_mock._get.assert_called_with( + client_mock._get_cast.assert_called_with( _name=APINames.Log, _method="main", params={ @@ -77,7 +77,7 @@ def test_log_main_warning(client_mock): def test_log_main_critical(client_mock): assert isinstance(client_mock.log.main.critical(), LogMainList) - client_mock._get.assert_called_with( + client_mock._get_cast.assert_called_with( _name=APINames.Log, _method="main", params={ @@ -102,7 +102,7 @@ def test_log_main_levels(client_mock, main_func, include_level): ) actual_include = None if include_level is None else bool(include_level) - client_mock._get.assert_called_with( + client_mock._get_cast.assert_called_with( _name=APINames.Log, _method="main", params={ diff --git a/tests/test_request.py b/tests/test_request.py index ac06870db..c3a9ea5d2 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -90,7 +90,7 @@ def test_hostname_format(app_version, hostname): ) assert client.app.version == app_version # ensure the base URL is always normalized - assert re.match(r"(http|https)://localhost:8080/", client._API_BASE_URL) + assert re.match(r"(http|https)://localhost:8080/", client._url._base_url) @pytest.mark.parametrize( @@ -106,7 +106,7 @@ def test_hostname_user_base_path(hostname): with pytest.raises(exceptions.APIConnectionError): _ = client.app.version # ensure user provided base paths are preserved - assert re.match(r"(http|https)://localhost:8080/qbt/", client._API_BASE_URL) + assert re.match(r"(http|https)://localhost:8080/qbt/", client._url._base_url) def test_port_from_host(app_version): @@ -144,7 +144,7 @@ def test_force_user_scheme(client, app_version, use_https): assert client.app.version == app_version else: assert client.app.version == app_version - assert client._API_BASE_URL.startswith("http://") + assert client._url._base_url.startswith("http://") client = Client( host=default_host, @@ -154,9 +154,9 @@ def test_force_user_scheme(client, app_version, use_https): ) assert client.app.version == app_version if use_https: - assert client._API_BASE_URL.startswith("https://") + assert client._url._base_url.startswith("https://") else: - assert client._API_BASE_URL.startswith("http://") + assert client._url._base_url.startswith("http://") client = Client( host="https://" + default_host, @@ -169,7 +169,7 @@ def test_force_user_scheme(client, app_version, use_https): assert client.app.version == app_version else: assert client.app.version == app_version - assert client._API_BASE_URL.startswith("https://") + assert client._url._base_url.startswith("https://") @pytest.mark.skipif_before_api_version("2.2.1") @@ -185,7 +185,7 @@ def test_both_https_http_not_working(client, app_version, scheme): ) with pytest.raises(exceptions.APIConnectionError): assert test_client.app.version == app_version - assert test_client._API_BASE_URL.startswith("https://") + assert test_client._url._base_url.startswith("https://") _enable_disable_https(client, use_https=False) @@ -244,12 +244,12 @@ def test_log_out(client): client.auth_log_out() with pytest.raises(exceptions.Forbidden403Error): # cannot call client.app.version directly since it will auto log back in - client._get(APINames.Application, "version") + client._request_manager("get", APINames.Application, "version") client.auth_log_in() client.auth.log_out() with pytest.raises(exceptions.Forbidden403Error): # cannot call client.app.version directly since it will auto log back in - client._get(APINames.Application, "version") + client._request_manager("get", APINames.Application, "version") client.auth_log_in() @@ -261,13 +261,6 @@ def test_port(app_version): assert client.app.version == app_version -def test_response_none(client): - response = MagicMock(spec_set=Response) - - assert client._cast(response, None) == response - assert client._cast(response, response_class=None) == response - - def test_response_str(client): response = MagicMock(spec_set=Response) @@ -462,22 +455,17 @@ def test_request_extra_params(client, orig_torrent): assert isinstance(torrent, TorrentDictionary) -def test_mock_api_version(): - client = Client(MOCK_WEB_API_VERSION="1.5", VERIFY_WEBUI_CERTIFICATE=False) - assert client.app_web_api_version() == "1.5" - - -def test_unsupported_version_error(): +def test_unsupported_version_error(monkeypatch): if IS_QBT_DEV: return client = Client( - MOCK_WEB_API_VERSION="0.0.0", VERIFY_WEBUI_CERTIFICATE=False, RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS=True, ) + monkeypatch.setattr(client, "app_version", MagicMock(return_value="1.0.0")) with pytest.raises(exceptions.UnsupportedQbittorrentVersion): - client.app_version() + client.app_web_api_version() client = Client( VERIFY_WEBUI_CERTIFICATE=False, @@ -568,27 +556,31 @@ def test_http404(client, params): client.torrents_rename(hash="zxcv", new_torrent_name="erty") assert "zxcv" in exc_info.value.args[0] - response = MagicMock(spec=Response, status_code=404, text="") + response = MagicMock(spec=Response, status_code=404, text="", request=object()) with pytest.raises(exceptions.HTTPError, match="") as exc_info: Request._handle_error_responses(data={}, params=params, response=response) assert exc_info.value.http_status_code == 404 if params: assert params[list(params.keys())[0]] in exc_info.value.args[0] - response = MagicMock(spec=Response, status_code=404, text="unexpected msg") + response = MagicMock( + spec=Response, status_code=404, text="unexpected msg", request=object() + ) with pytest.raises(exceptions.HTTPError, match="unexpected msg") as exc_info: Request._handle_error_responses(data={}, params=params, response=response) assert exc_info.value.http_status_code == 404 assert exc_info.value.args[0] == "unexpected msg" - response = MagicMock(spec=Response, status_code=404, text="") + response = MagicMock(spec=Response, status_code=404, text="", request=object()) with pytest.raises(exceptions.HTTPError, match="") as exc_info: Request._handle_error_responses(data=params, params={}, response=response) assert exc_info.value.http_status_code == 404 if params: assert params[list(params.keys())[0]] in exc_info.value.args[0] - response = MagicMock(spec=Response, status_code=404, text="unexpected msg") + response = MagicMock( + spec=Response, status_code=404, text="unexpected msg", request=object() + ) with pytest.raises(exceptions.HTTPError, match="unexpected msg") as exc_info: Request._handle_error_responses(data=params, params={}, response=response) assert exc_info.value.http_status_code == 404 @@ -618,7 +610,9 @@ def test_http415(client): @pytest.mark.parametrize("status_code", (500, 503)) def test_http500(status_code): - response = MagicMock(spec=Response, status_code=status_code, text="asdf") + response = MagicMock( + spec=Response, status_code=status_code, text="asdf", request=object() + ) with pytest.raises(exceptions.InternalServerError500Error) as exc_info: Request._handle_error_responses(data={}, params={}, response=response) assert exc_info.value.http_status_code == status_code @@ -626,7 +620,9 @@ def test_http500(status_code): @pytest.mark.parametrize("status_code", (402, 406)) def test_http_error(status_code): - response = MagicMock(spec=Response, status_code=status_code, text="asdf") + response = MagicMock( + spec=Response, status_code=status_code, text="asdf", request=object() + ) with pytest.raises(exceptions.HTTPError) as exc_info: Request._handle_error_responses(data={}, params={}, response=response) assert exc_info.value.http_status_code == status_code @@ -664,7 +660,48 @@ def test_verbose_logging(caplog): def test_stack_printing(capsys): - client = Client(PRINT_STACK_FOR_EACH_REQUEST=True, VERIFY_WEBUI_CERTIFICATE=False) + client = Client(VERIFY_WEBUI_CERTIFICATE=False) + client._PRINT_STACK_FOR_EACH_REQUEST = True client.app_version() assert "print_stack()" in capsys.readouterr().err + + +def test_auto_authentication(caplog, app_version): + client = Client( + RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=True, + VERIFY_WEBUI_CERTIFICATE=False, + ) + # check if first API call works if already logged in + client.auth_log_in() + with caplog.at_level(logging.DEBUG, logger="qbittorrentapi"): + qbt_version = client.app.version + assert "Login may have expired...attempting new login" not in caplog.text + assert qbt_version == app_version + + # ensure login happens after first API call fails + client.auth_log_out() + with caplog.at_level(logging.DEBUG, logger="qbittorrentapi"): + qbt_version = client.app.version + assert "Login may have expired...attempting new login" in caplog.text + assert qbt_version == app_version + + +def test_not_implemented_no_error(monkeypatch): + client = Client(VERIFY_WEBUI_CERTIFICATE=False) + + monkeypatch.setattr(client, "app_web_api_version", MagicMock(return_value="1.0.0")) + assert client.search_categories() is None + + monkeypatch.setattr(client, "app_web_api_version", MagicMock(return_value="10.0.0")) + assert client.search_categories() is None + + +def test_not_implemented_error(monkeypatch, client): + monkeypatch.setattr(client, "app_web_api_version", MagicMock(return_value="1.0.0")) + with pytest.raises(NotImplementedError, match=r"This endpoint is available"): + client.search_categories() + + monkeypatch.setattr(client, "app_web_api_version", MagicMock(return_value="10.0.0")) + with pytest.raises(NotImplementedError, match=r"This endpoint was removed"): + client.search_categories() diff --git a/tests/test_torrent.py b/tests/test_torrent.py index ade3c29cb..be771a77a 100644 --- a/tests/test_torrent.py +++ b/tests/test_torrent.py @@ -1,6 +1,7 @@ import platform from time import sleep from types import MethodType +from unittest.mock import MagicMock import pytest @@ -24,9 +25,9 @@ def test_info(orig_torrent, monkeypatch): assert orig_torrent.info.hash == orig_torrent.hash # mimic <=v2.0.1 where torrents_info() doesn't support hash arg - orig_torrent._client._MOCK_WEB_API_VERSION = "2" - assert orig_torrent.info.hash == orig_torrent.hash - orig_torrent._client._MOCK_WEB_API_VERSION = None + with monkeypatch.context() as m: + m.setattr(orig_torrent._client, "app_version", MagicMock(return_value="2.0.0")) + assert orig_torrent.info.hash == orig_torrent.hash # ensure if things are really broken, an empty TorrentDictionary is returned... if platform.python_implementation() == "CPython": diff --git a/tests/utils.py b/tests/utils.py index 964687943..f98a5bb2f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,6 +6,7 @@ from qbittorrentapi import APIConnectionError from qbittorrentapi import Client +from qbittorrentapi import TorrentDictionary from qbittorrentapi._version_support import ( APP_VERSION_2_API_VERSION_MAP as api_version_map, ) @@ -72,7 +73,7 @@ def wrapper(*args, **kwargs): return inner -def get_torrent(client, torrent_hash): +def get_torrent(client, torrent_hash) -> TorrentDictionary: """Retrieve a torrent from qBittorrent.""" try: # not all versions of torrents_info() support passing a hash diff --git a/tox.ini b/tox.ini index 1d3b39c30..1003f6431 100644 --- a/tox.ini +++ b/tox.ini @@ -40,6 +40,7 @@ sphinx_args = -W --keep-going -j auto -n sphinx_args_extra = {[docs]sphinx_args} -v -E -T -a -d {envtmpdir}/doctrees [testenv:docs{,-lint,-all,-man}] +base_python = py311 package = wheel wheel_build_env = .pkg change_dir = docs