Skip to content

Commit cf96816

Browse files
authored
Add methods to add packages and upload container (#48)
* Add add_package and upload_container methods * Bump version 1.3.2 → 1.4.0
1 parent 7e7d27e commit cf96816

File tree

7 files changed

+575
-11
lines changed

7 files changed

+575
-11
lines changed

src/pyloadapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""PyLoadAPI package."""
22

3-
__version__ = "1.3.2"
3+
__version__ = "1.4.0"
44

55
from .api import PyLoadAPI
66
from .exceptions import CannotConnect, InvalidAuth, ParserError

src/pyloadapi/api.py

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
from http import HTTPStatus
10+
import json
1011
from json import JSONDecodeError
1112
import logging
1213
import traceback
@@ -15,7 +16,7 @@
1516
import aiohttp
1617

1718
from .exceptions import CannotConnect, InvalidAuth, ParserError
18-
from .types import LoginResponse, PyLoadCommand, StatusServerResponse
19+
from .types import Destination, LoginResponse, PyLoadCommand, StatusServerResponse
1920

2021
_LOGGER = logging.getLogger(__name__)
2122

@@ -167,6 +168,91 @@ async def get(
167168
"Executing command {command} failed due to request exception"
168169
) from e
169170

171+
async def post(
172+
self,
173+
command: PyLoadCommand,
174+
data: dict[str, Any],
175+
) -> Any:
176+
"""Execute a pyLoad API command using a POST request.
177+
178+
Parameters
179+
----------
180+
command : PyLoadCommand
181+
The pyLoad command to execute.
182+
data : dict[str, Any]
183+
Data to include in the request body. The values in the dictionary
184+
will be JSON encoded.
185+
186+
Returns
187+
-------
188+
Any
189+
The response data from the API.
190+
191+
Raises
192+
------
193+
CannotConnect
194+
If the request to the API fails due to a connection issue.
195+
InvalidAuth
196+
If the request fails due to invalid or expired authentication.
197+
ParserError
198+
If there's an error parsing the API response.
199+
200+
Notes
201+
-----
202+
This method sends an asynchronous POST request to the pyLoad API endpoint
203+
specified by `command`, with the provided `data` dictionary. It handles
204+
authentication errors, HTTP status checks, and parses the JSON response.
205+
206+
207+
Example
208+
-------
209+
To add a new package to pyLoad, use:
210+
```python
211+
status = await pyload_api.post(PyLoadCommand.ADD_PACKAGE, data={...}
212+
```
213+
214+
"""
215+
url = f"{self.api_url}api/{command}"
216+
data = {
217+
k: str(v) if isinstance(v, bytes) else json.dumps(v)
218+
for k, v in data.items()
219+
}
220+
221+
try:
222+
async with self._session.post(url, data=data) as r:
223+
_LOGGER.debug(
224+
"Response from %s [%s]: %s", r.url, r.status, await r.text()
225+
)
226+
227+
if r.status == HTTPStatus.UNAUTHORIZED:
228+
raise InvalidAuth(
229+
"Request failed due invalid or expired authentication cookie."
230+
)
231+
r.raise_for_status()
232+
try:
233+
data = await r.json()
234+
except JSONDecodeError as e:
235+
_LOGGER.debug(
236+
"Exception: Cannot parse response for %s:\n %s",
237+
command,
238+
traceback.format_exc(),
239+
)
240+
raise ParserError(
241+
f"Get {command} failed during parsing of request response."
242+
) from e
243+
244+
return data
245+
246+
except (TimeoutError, aiohttp.ClientError) as e:
247+
_LOGGER.debug(
248+
"Exception: Cannot execute command %s:\n %s",
249+
command,
250+
traceback.format_exc(),
251+
)
252+
raise CannotConnect(
253+
f"Executing command {command} failed due to request exception"
254+
) from e
255+
170256
async def get_status(self) -> StatusServerResponse:
171257
"""Get general status information of pyLoad.
172258
@@ -529,3 +615,107 @@ async def free_space(self) -> int:
529615
return int(r)
530616
except CannotConnect as e:
531617
raise CannotConnect("Get free space failed due to request exception") from e
618+
619+
async def add_package(
620+
self,
621+
name: str,
622+
links: list[str],
623+
destination: Destination = Destination.COLLECTOR,
624+
) -> int:
625+
"""Add a new package to pyLoad from a list of links.
626+
627+
Parameters
628+
----------
629+
name : str
630+
The name of the package to be added.
631+
links : list[str]
632+
A list of download links to be included in the package.
633+
destination : Destination, optional
634+
The destination where the package should be stored, by default Destination.COLLECTOR.
635+
636+
Returns
637+
-------
638+
int
639+
The ID of the newly created package.
640+
641+
Raises
642+
------
643+
CannotConnect
644+
If the request to add the package fails due to a connection issue.
645+
InvalidAuth
646+
If the request fails due to invalid or expired authentication.
647+
ParserError
648+
If there's an issue parsing the response from the server.
649+
650+
Example
651+
-------
652+
To add a new package with a couple of links to the pyLoad collector:
653+
```python
654+
package_id = await pyload_api.add_package(
655+
"test_package",
656+
[
657+
"https://example.com/file1.zip",
658+
"https://example.com/file2.iso",
659+
]
660+
)
661+
```
662+
"""
663+
664+
try:
665+
r = await self.post(
666+
PyLoadCommand.ADD_PACKAGE,
667+
data={
668+
"name": name,
669+
"links": links,
670+
"dest": destination,
671+
},
672+
)
673+
return int(r)
674+
except CannotConnect as e:
675+
raise CannotConnect("Adding package failed due to request exception") from e
676+
677+
async def upload_container(
678+
self,
679+
filename: str,
680+
binary_data: bytes,
681+
) -> None:
682+
"""Upload a container file to pyLoad.
683+
684+
Parameters
685+
----------
686+
filename : str
687+
The name of the file to be uploaded.
688+
binary_data : bytes
689+
The binary content of the file.
690+
691+
Returns
692+
-------
693+
None
694+
695+
Raises
696+
------
697+
CannotConnect
698+
If the request to upload the container fails due to a connection issue.
699+
InvalidAuth
700+
If the request fails due to invalid or expired authentication.
701+
702+
Example
703+
-------
704+
To upload a container file to pyLoad:
705+
```python
706+
await pyload_api.upload_container(
707+
"example_container.dlc",
708+
b"binary data of the file"
709+
)
710+
```
711+
"""
712+
try:
713+
await self.post(
714+
PyLoadCommand.UPLOAD_CONTAINER,
715+
data={"filename": filename, "data": binary_data},
716+
)
717+
718+
except CannotConnect as e:
719+
raise CannotConnect(
720+
"Uploading container to pyLoad failed due to request exception"
721+
) from e

src/pyloadapi/cli.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
import click
1111

1212
from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI
13+
from pyloadapi.types import Destination
1314

14-
logging.basicConfig(level=logging.INFO)
1515
_LOGGER = logging.getLogger(__name__)
1616

1717
CONFIG_FILE_PATH = Path("~/.config/pyloadapi/.pyload_config.json").expanduser()
@@ -46,7 +46,7 @@ async def init_api(
4646
return api
4747

4848

49-
@click.group()
49+
@click.group(invoke_without_command=True)
5050
@click.option("--api-url", help="Base URL of pyLoad")
5151
@click.option("--username", help="Username for pyLoad")
5252
@click.option("--password", help="Password for pyLoad")
@@ -59,6 +59,9 @@ def cli(
5959
) -> None:
6060
"""CLI for interacting with pyLoad."""
6161

62+
if not any([api_url, username, password, ctx.invoked_subcommand]):
63+
click.echo(ctx.get_help())
64+
6265
config = load_config()
6366

6467
if api_url:
@@ -307,3 +310,95 @@ async def _toggle_reconnect() -> None:
307310
raise click.ClickException("Unable to parse response from pyLoad") from e
308311

309312
asyncio.run(_toggle_reconnect())
313+
314+
315+
@cli.command()
316+
@click.pass_context
317+
@click.argument(
318+
"container",
319+
type=click.Path(
320+
exists=True,
321+
readable=True,
322+
path_type=Path,
323+
),
324+
)
325+
def upload_container(ctx: click.Context, container: Path) -> None:
326+
"""Upload a container file to pyLoad."""
327+
328+
with open(container, "rb") as f:
329+
binary_data = f.read()
330+
331+
async def _upload_container() -> None:
332+
try:
333+
async with aiohttp.ClientSession() as session:
334+
api = await init_api(
335+
session,
336+
ctx.obj["api_url"],
337+
ctx.obj["username"],
338+
ctx.obj["password"],
339+
)
340+
341+
await api.upload_container(
342+
filename=click.format_filename(container, shorten=True),
343+
binary_data=binary_data,
344+
)
345+
346+
except CannotConnect as e:
347+
raise click.ClickException("Unable to connect to pyLoad") from e
348+
except InvalidAuth as e:
349+
raise click.ClickException(
350+
"Authentication failed, verify username and password"
351+
) from e
352+
except ParserError as e:
353+
raise click.ClickException("Unable to parse response from pyLoad") from e
354+
355+
asyncio.run(_upload_container())
356+
357+
358+
@cli.command()
359+
@click.pass_context
360+
@click.argument("package_name")
361+
@click.option(
362+
"--queue/--collector",
363+
default=True,
364+
help="Add package to queue or collector. Defaults to queue",
365+
)
366+
def add_package(ctx: click.Context, package_name: str, queue: bool) -> None:
367+
"""Add a package to pyLoad."""
368+
369+
links = []
370+
371+
while value := click.prompt(
372+
"Please enter a link", type=str, default="", show_default=False
373+
):
374+
links.append(value)
375+
376+
if not links:
377+
raise click.ClickException("No links entered")
378+
379+
async def _add_package() -> None:
380+
try:
381+
async with aiohttp.ClientSession() as session:
382+
api = await init_api(
383+
session,
384+
ctx.obj["api_url"],
385+
ctx.obj["username"],
386+
ctx.obj["password"],
387+
)
388+
389+
await api.add_package(
390+
name=package_name,
391+
links=links,
392+
destination=Destination.QUEUE if queue else Destination.COLLECTOR,
393+
)
394+
395+
except CannotConnect as e:
396+
raise click.ClickException("Unable to connect to pyLoad") from e
397+
except InvalidAuth as e:
398+
raise click.ClickException(
399+
"Authentication failed, verify username and password"
400+
) from e
401+
except ParserError as e:
402+
raise click.ClickException("Unable to parse response from pyLoad") from e
403+
404+
asyncio.run(_add_package())

src/pyloadapi/types.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
2424
"""
2525

26-
from enum import StrEnum
26+
from enum import IntEnum, StrEnum
2727
from typing import Any, NotRequired, TypedDict, TypeVar
2828

2929
T = TypeVar("T")
@@ -139,11 +139,12 @@ class PyLoadCommand(StrEnum):
139139
RESTART = "restart"
140140
VERSION = "getServerVersion"
141141
FREESPACE = "freeSpace"
142-
ADDPACKAGE = "addPackage"
142+
ADD_PACKAGE = "addPackage"
143+
UPLOAD_CONTAINER = "uploadContainer"
143144

144145

145-
class Destination(StrEnum):
146+
class Destination(IntEnum):
146147
"""Destination for new Packages."""
147148

148-
QUEUE = "queue"
149-
COLLECTOR = "collector"
149+
COLLECTOR = 0
150+
QUEUE = 1

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
"reconnect": False,
4949
}
5050

51+
BYTE_DATA = b"BYTE_DATA"
52+
5153

5254
@pytest.fixture(name="session")
5355
async def aiohttp_client_session() -> AsyncGenerator[aiohttp.ClientSession, Any]:

0 commit comments

Comments
 (0)