From da3ae165e7ac02a249e407fa8ca3b440cb7cf1ee Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Sat, 20 Apr 2024 07:37:38 +0200 Subject: [PATCH] Add methods for controling pyLoad and tests (#4) Add new methods: pause, unpause, toggle_pause: : Pause/Resume download queue. stop_all_downloads: Abort all running downloads. restart_failed: Restart all failed files. toggle_reconnect: Toggle reconnect activation delete_finished: Delete all finished files and completly finished packages. restart: Restart pyload core. free_space: Get available free space at download directory in bytes. Add pytest unit testing for login method Refactored get_status and version methods --- CHANGELOG.md | 13 +++ pyproject.toml | 5 +- setup.cfg | 2 +- src/pyloadapi/api.py | 235 +++++++++++++++++++++++++++++++++++------ src/pyloadapi/types.py | 17 +++ tests/__init__.py | 1 + tests/conftest.py | 53 ++++++++++ tests/ruff.toml | 22 ++++ tests/test_api.py | 69 ++++++++++++ 9 files changed, 383 insertions(+), 34 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/ruff.toml create mode 100644 tests/test_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cda3b3..5a7cae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# 1.1.0 + +* Add new methods: + * `pause`, `unpause`, `toggle_pause`: : Pause/Resume download queue. + * `stop_all_downloads`: Abort all running downloads. + * `restart_failed`: Restart all failed files. + * `toggle_reconnect`: Toggle reconnect activation + * `delete_finished`: Delete all finished files and completly finished packages. + * `restart`: Restart pyload core. + * `free_space`: Get available free space at download directory in bytes. +* Add pytest unit testing for login method +* Refactored `get_status` and `version` methods + # 1.0.3 * Change logging to debug diff --git a/pyproject.toml b/pyproject.toml index 14eef17..ed9e9d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,6 +140,9 @@ max-complexity = 25 [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = [ - + "tests", ] +pythonpath = [ + "src" +] diff --git a/setup.cfg b/setup.cfg index 3cdb605..962b0af 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = PyLoadAPI -version = 1.0.3 +version = 1.1.0 author = Manfred Dennerlein Rodelo author_email = manfred@dennerlein.name description = "Simple wrapper for pyLoad's API." diff --git a/src/pyloadapi/api.py b/src/pyloadapi/api.py index 583d63a..33e6560 100644 --- a/src/pyloadapi/api.py +++ b/src/pyloadapi/api.py @@ -4,11 +4,12 @@ from json import JSONDecodeError import logging import traceback +from typing import Any import aiohttp from .exceptions import CannotConnect, InvalidAuth, ParserError -from .types import LoginResponse, StatusServerResponse +from .types import LoginResponse, PyLoadCommand, StatusServerResponse _LOGGER = logging.getLogger(__name__) @@ -44,7 +45,7 @@ async def login(self) -> LoginResponse: if not data: raise InvalidAuth return LoginResponse.from_dict(data) - except JSONDecodeError as e: + except (JSONDecodeError, TypeError, aiohttp.ContentTypeError) as e: _LOGGER.debug( "Exception: Cannot parse login response:\n %s", traceback.format_exc(), @@ -56,52 +57,222 @@ async def login(self) -> LoginResponse: _LOGGER.debug("Exception: Cannot login:\n %s", traceback.format_exc()) raise CannotConnect from e - async def get_status(self) -> StatusServerResponse: - """Get general status information of pyLoad.""" - url = f"{self.api_url}api/statusServer" + async def get( + self, command: PyLoadCommand, params: dict[str, Any] | None = None + ) -> Any: + """Execute a pyLoad command.""" + url = f"{self.api_url}api/{command}" try: - async with self._session.get(url) as r: - _LOGGER.debug("Response from %s [%s]: %s", url, r.status, r.text) + async with self._session.get(url, params=params) as r: + _LOGGER.debug("Response from %s [%s]: %s", r.url, r.status, r.text) if r.status == HTTPStatus.UNAUTHORIZED: - raise InvalidAuth + raise InvalidAuth( + "Request failed due invalid or expired authentication cookie." + ) r.raise_for_status() try: data = await r.json() - return StatusServerResponse.from_dict(data) + return data except JSONDecodeError as e: _LOGGER.debug( - "Exception: Cannot parse status response:\n %s", + "Exception: Cannot parse response for %s:\n %s", + command, traceback.format_exc(), ) raise ParserError( - "Get status failed during parsing of request response." + "Get {command} failed during parsing of request response." ) from e except (TimeoutError, aiohttp.ClientError) as e: - _LOGGER.debug("Exception: Cannot get status:\n %s", traceback.format_exc()) + _LOGGER.debug( + "Exception: Cannot execute command %s:\n %s", + command, + traceback.format_exc(), + ) + raise CannotConnect( + "Executing command {command} failed due to request exception" + ) from e + + async def get_status(self) -> StatusServerResponse: + """Get general status information of pyLoad. + + Returns: + ------- + StatusServerResponse + Status information of pyLoad + + Raises: + ------ + CannotConnect: + if request fails + + """ + try: + r = await self.get(PyLoadCommand.STATUS) + return StatusServerResponse.from_dict(r) + except CannotConnect as e: raise CannotConnect("Get status failed due to request exception") from e + async def pause(self) -> None: + """Pause download queue. + + Raises: + ------ + CannotConnect: + if request fails + + """ + try: + await self.get(PyLoadCommand.PAUSE) + except CannotConnect as e: + raise CannotConnect( + "Pausing download queue failed due to request exception" + ) from e + + async def unpause(self) -> None: + """Unpause download queue. + + Raises: + ------ + CannotConnect: + if request fails + + """ + try: + await self.get(PyLoadCommand.UNPAUSE) + except CannotConnect as e: + raise CannotConnect( + "Unpausing download queue failed due to request exception" + ) from e + + async def toggle_pause(self) -> None: + """Toggle pause download queue. + + Raises: + ------ + CannotConnect: + if request fails + + """ + try: + await self.get(PyLoadCommand.TOGGLE_PAUSE) + except CannotConnect as e: + raise CannotConnect( + "Toggling pause download queue failed due to request exception" + ) from e + + async def stop_all_downloads(self) -> None: + """Abort all running downloads. + + Raises: + ------ + CannotConnect: + if request fails + + """ + try: + await self.get(PyLoadCommand.ABORT_ALL) + except CannotConnect as e: + raise CannotConnect( + "Aborting all running downlods failed due to request exception" + ) from e + + async def restart_failed(self) -> None: + """Restart all failed files. + + Raises: + ------ + CannotConnect: + if request fails + + """ + try: + await self.get(PyLoadCommand.RESTART_FAILED) + except CannotConnect as e: + raise CannotConnect( + "Restarting all failed files failed due to request exception" + ) from e + + async def toggle_reconnect(self) -> None: + """Toggle reconnect activation. + + Raises: + ------ + CannotConnect: + if request fails + + """ + await self.get(PyLoadCommand.TOGGLE_RECONNECT) + + async def delete_finished(self) -> None: + """Delete all finished files and completly finished packages. + + Raises: + ------ + CannotConnect: + if request fails + + """ + try: + await self.get(PyLoadCommand.DELETE_FINISHED) + except CannotConnect as e: + raise CannotConnect( + "Deleting all finished files failed due to request exception" + ) from e + + async def restart(self) -> None: + """Restart pyload core. + + Raises: + ------ + CannotConnect: + if request fails + + """ + try: + await self.get(PyLoadCommand.RESTART) + except CannotConnect as e: + raise CannotConnect( + "Restarting pyLoad core failed due to request exception" + ) from e + async def version(self) -> str: - """Get version of pyLoad.""" - url = f"{self.api_url}api/getServerVersion" + """Get version of pyLoad. + + Returns: + ------- + str: + pyLoad Version + + Raises: + ------ + CannotConnect: + if request fails + + """ try: - async with self._session.get(url) as r: - _LOGGER.debug("Response from %s [%s]: %s", url, r.status, r.text) - if r.status == HTTPStatus.UNAUTHORIZED: - raise InvalidAuth - r.raise_for_status() - try: - data = await r.json() - return str(data) - except JSONDecodeError as e: - _LOGGER.debug( - "Exception: Cannot parse status response:\n %s", - traceback.format_exc(), - ) - raise ParserError( - "Get version failed during parsing of request response." - ) from e - except (TimeoutError, aiohttp.ClientError) as e: - _LOGGER.debug("Exception: Cannot get version:\n %s", traceback.format_exc()) + r = await self.get(PyLoadCommand.VERSION) + return str(r) + except CannotConnect as e: raise CannotConnect("Get version failed due to request exception") from e + + async def free_space(self) -> int: + """Get available free space at download directory in bytes. + + Returns: + ------- + int: + free space at download directory in bytes + + Raises: + ------ + CannotConnect: + if request fails + + """ + try: + r = await self.get(PyLoadCommand.FREESPACE) + return int(r) + except CannotConnect as e: + raise CannotConnect("Get free space failed due to request exception") from e diff --git a/src/pyloadapi/types.py b/src/pyloadapi/types.py index 8e990d0..d3d8c7f 100644 --- a/src/pyloadapi/types.py +++ b/src/pyloadapi/types.py @@ -1,6 +1,7 @@ """Types for PyLoadAPI.""" from dataclasses import asdict, dataclass +from enum import StrEnum from typing import Any, List, Type, TypeVar T = TypeVar("T") @@ -50,3 +51,19 @@ class LoginResponse(Response): perms: int template: str _flashes: List[Any] + + +class PyLoadCommand(StrEnum): + """Set status commands.""" + + STATUS = "statusServer" + PAUSE = "pauseServer" + UNPAUSE = "unpauseServer" + TOGGLE_PAUSE = "togglePause" + ABORT_ALL = "stopAllDownloads" + RESTART_FAILED = "restartFailed" + TOGGLE_RECONNECT = "toggleReconnect" + DELETE_FINISHED = "deleteFinished" + RESTART = "restart" + VERSION = "getServerVersion" + FREESPACE = "freeSpace" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..050fd9f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for PyLoadAPI.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..eda31bd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +"""Fixtures for PyLoadAPI Tests.""" + +from typing import Any, AsyncGenerator, Generator + +import aiohttp +from aioresponses import aioresponses +from dotenv import load_dotenv +import pytest + +from pyloadapi.api import PyLoadAPI + +load_dotenv() + + +TEST_API_URL = "https://example.com:8000/" +TEST_USERNAME = "test-username" +TEST_PASSWORD = "test-password" +TEST_LOGIN_RESPONSE = { + "_permanent": True, + "authenticated": True, + "id": 2, + "name": "test-username", + "role": 0, + "perms": 0, + "template": "default", + "_flashes": [["message", "Logged in successfully"]], +} + + +@pytest.fixture(name="session") +async def aiohttp_client_session() -> AsyncGenerator[aiohttp.ClientSession, Any]: + """Create a client session.""" + async with aiohttp.ClientSession() as session: + yield session + + +@pytest.fixture(name="pyload") +async def mocked_pyloadapi_client(session: aiohttp.ClientSession) -> PyLoadAPI: + """Create Bring instance.""" + pyload = PyLoadAPI( + session, + TEST_API_URL, + TEST_USERNAME, + TEST_PASSWORD, + ) + return pyload + + +@pytest.fixture(name="mocked_aiohttp") +def aioclient_mock() -> Generator[aioresponses, Any, None]: + """Mock Aiohttp client requests.""" + with aioresponses() as m: + yield m diff --git a/tests/ruff.toml b/tests/ruff.toml new file mode 100644 index 0000000..8bef717 --- /dev/null +++ b/tests/ruff.toml @@ -0,0 +1,22 @@ +extend = "../pyproject.toml" + +[lint] +extend-select = [ + "PT001", # Use @pytest.fixture without parentheses + "PT002", # Configuration for fixture specified via positional args, use kwargs + "PT003", # The scope='function' is implied in @pytest.fixture() + "PT006", # Single parameter in parameterize is a string, multiple a tuple + "PT013", # Found incorrect pytest import, use simple import pytest instead + "PT015", # Assertion always fails, replace with pytest.fail() + "PT021", # use yield instead of request.addfinalizer + "PT022", # No teardown in fixture, replace useless yield with return +] + +extend-ignore = [ + "PLC", # pylint + "PLE", # pylint + "PLR", # pylint + "PLW", # pylint + "B904", # Use raise from to specify exception cause + "N815", # Variable {name} in class scope should not be mixedCase +] diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..d08604f --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,69 @@ +"""Tests for PyLoadAPI.""" + +import asyncio + +import aiohttp +from aioresponses import aioresponses +from dotenv import load_dotenv +import pytest + +from pyloadapi.api import PyLoadAPI +from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError + +from .conftest import TEST_API_URL, TEST_LOGIN_RESPONSE + +load_dotenv() + + +async def test_login(pyload: PyLoadAPI, mocked_aiohttp: aioresponses) -> None: + """Test login.""" + mocked_aiohttp.post( + f"{TEST_API_URL}api/login", status=200, payload=TEST_LOGIN_RESPONSE + ) + + result = await pyload.login() + assert result.to_dict() == TEST_LOGIN_RESPONSE + + +async def test_login_invalidauth( + pyload: PyLoadAPI, mocked_aiohttp: aioresponses +) -> None: + """Test login.""" + mocked_aiohttp.post(f"{TEST_API_URL}api/login", status=200) + + with pytest.raises(InvalidAuth): + await pyload.login() + + +@pytest.mark.parametrize( + ("exception"), + [ + asyncio.TimeoutError, + aiohttp.ClientError, + ], +) +async def test_login_request_exceptions( + pyload: PyLoadAPI, + mocked_aiohttp: aioresponses, + exception: Exception, +) -> None: + """Test login.""" + mocked_aiohttp.post(f"{TEST_API_URL}api/login", exception=exception) + + with pytest.raises(expected_exception=CannotConnect): + await pyload.login() + + +async def test_login_parse_exceptions( + pyload: PyLoadAPI, mocked_aiohttp: aioresponses +) -> None: + """Test login.""" + mocked_aiohttp.post( + f"{TEST_API_URL}api/login", + status=200, + body="not json", + content_type="application/json", + ) + + with pytest.raises(expected_exception=ParserError): + await pyload.login()