neptune-sdk 0.0.1.dev580__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- neptune_sdk-0.0.1.dev580/PKG-INFO +9 -0
- neptune_sdk-0.0.1.dev580/pyproject.toml +49 -0
- neptune_sdk-0.0.1.dev580/src/neptune_sdk/__init__.py +69 -0
- neptune_sdk-0.0.1.dev580/src/neptune_sdk/client.py +311 -0
- neptune_sdk-0.0.1.dev580/src/neptune_sdk/exceptions.py +16 -0
- neptune_sdk-0.0.1.dev580/src/neptune_sdk/models.py +250 -0
- neptune_sdk-0.0.1.dev580/tests/__init__.py +0 -0
- neptune_sdk-0.0.1.dev580/tests/test_client.py +308 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"pdm-backend",
|
|
4
|
+
]
|
|
5
|
+
build-backend = "pdm.backend"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "neptune-sdk"
|
|
9
|
+
dynamic = []
|
|
10
|
+
description = "Python SDK for Neptune BitTorrent client JSON-RPC API"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
license = "GPL-3.0-only"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
"pydantic>=2",
|
|
16
|
+
]
|
|
17
|
+
version = "0.0.1.dev580"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8",
|
|
22
|
+
"respx>=0.22",
|
|
23
|
+
"ruff>=0.11",
|
|
24
|
+
"ty>=0.0.40",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.pdm.version]
|
|
28
|
+
source = "scm"
|
|
29
|
+
tag_regex = "^sdk/python/v(.+)$"
|
|
30
|
+
version_format = "_version_helper:format_version"
|
|
31
|
+
|
|
32
|
+
[tool.pdm.build]
|
|
33
|
+
package-dir = "src"
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
target-version = "py310"
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = [
|
|
42
|
+
"E",
|
|
43
|
+
"F",
|
|
44
|
+
"I",
|
|
45
|
+
"UP",
|
|
46
|
+
"B",
|
|
47
|
+
"SIM",
|
|
48
|
+
"TCH",
|
|
49
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Neptune SDK — async Python client for the Neptune BitTorrent JSON-RPC API."""
|
|
2
|
+
|
|
3
|
+
from .client import NeptuneClient
|
|
4
|
+
from .exceptions import NeptuneConnectionError, NeptuneError, NeptuneRPCError
|
|
5
|
+
from .models import (
|
|
6
|
+
AddTorrentRequest,
|
|
7
|
+
AddTorrentResponse,
|
|
8
|
+
AddTrackerRequest,
|
|
9
|
+
DelCustomRequest,
|
|
10
|
+
InfoHashRequest,
|
|
11
|
+
ListTorrentRequest,
|
|
12
|
+
MainDataTorrent,
|
|
13
|
+
MoveTorrentRequest,
|
|
14
|
+
Peer,
|
|
15
|
+
RemoveTorrentRequest,
|
|
16
|
+
RemoveTrackerRequest,
|
|
17
|
+
ReplaceTrackersRequest,
|
|
18
|
+
SetCustomRequest,
|
|
19
|
+
SetFilePriorityRequest,
|
|
20
|
+
SetGlobalSpeedLimitRequest,
|
|
21
|
+
SetSpeedLimitRequest,
|
|
22
|
+
TagsRequest,
|
|
23
|
+
TorrentFile,
|
|
24
|
+
TorrentFilesResponse,
|
|
25
|
+
TorrentInfo,
|
|
26
|
+
TorrentListResponse,
|
|
27
|
+
TorrentPeersResponse,
|
|
28
|
+
TorrentTrackersResponse,
|
|
29
|
+
Tracker,
|
|
30
|
+
TransferSummary,
|
|
31
|
+
UpdateCustomRequest,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# client
|
|
36
|
+
"NeptuneClient",
|
|
37
|
+
# exceptions
|
|
38
|
+
"NeptuneError",
|
|
39
|
+
"NeptuneRPCError",
|
|
40
|
+
"NeptuneConnectionError",
|
|
41
|
+
# request models
|
|
42
|
+
"AddTorrentRequest",
|
|
43
|
+
"AddTrackerRequest",
|
|
44
|
+
"DelCustomRequest",
|
|
45
|
+
"InfoHashRequest",
|
|
46
|
+
"ListTorrentRequest",
|
|
47
|
+
"MoveTorrentRequest",
|
|
48
|
+
"RemoveTorrentRequest",
|
|
49
|
+
"RemoveTrackerRequest",
|
|
50
|
+
"ReplaceTrackersRequest",
|
|
51
|
+
"SetCustomRequest",
|
|
52
|
+
"SetFilePriorityRequest",
|
|
53
|
+
"SetGlobalSpeedLimitRequest",
|
|
54
|
+
"SetSpeedLimitRequest",
|
|
55
|
+
"TagsRequest",
|
|
56
|
+
"UpdateCustomRequest",
|
|
57
|
+
# response / domain models
|
|
58
|
+
"AddTorrentResponse",
|
|
59
|
+
"MainDataTorrent",
|
|
60
|
+
"Peer",
|
|
61
|
+
"TorrentFile",
|
|
62
|
+
"TorrentFilesResponse",
|
|
63
|
+
"TorrentInfo",
|
|
64
|
+
"TorrentListResponse",
|
|
65
|
+
"TorrentPeersResponse",
|
|
66
|
+
"TorrentTrackersResponse",
|
|
67
|
+
"Tracker",
|
|
68
|
+
"TransferSummary",
|
|
69
|
+
]
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Sync Python client for the Neptune BitTorrent JSON-RPC API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from functools import cache
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import TypeAdapter
|
|
14
|
+
|
|
15
|
+
from .exceptions import NeptuneConnectionError, NeptuneRPCError
|
|
16
|
+
from .models import (
|
|
17
|
+
AddTorrentRequest,
|
|
18
|
+
AddTorrentResponse,
|
|
19
|
+
AddTrackerRequest,
|
|
20
|
+
DelCustomRequest,
|
|
21
|
+
InfoHashRequest,
|
|
22
|
+
ListTorrentRequest,
|
|
23
|
+
MoveTorrentRequest,
|
|
24
|
+
RemoveTorrentRequest,
|
|
25
|
+
RemoveTrackerRequest,
|
|
26
|
+
ReplaceTrackersRequest,
|
|
27
|
+
SetCustomRequest,
|
|
28
|
+
SetFilePriorityRequest,
|
|
29
|
+
SetGlobalSpeedLimitRequest,
|
|
30
|
+
SetSpeedLimitRequest,
|
|
31
|
+
TagsRequest,
|
|
32
|
+
TorrentFilesResponse,
|
|
33
|
+
TorrentInfo,
|
|
34
|
+
TorrentListResponse,
|
|
35
|
+
TorrentPeersResponse,
|
|
36
|
+
TorrentTrackersResponse,
|
|
37
|
+
TransferSummary,
|
|
38
|
+
UpdateCustomRequest,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
T = TypeVar("T")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _json_default(obj: Any) -> Any:
|
|
45
|
+
if isinstance(obj, (bytes, bytearray)):
|
|
46
|
+
return base64.b64encode(obj).decode()
|
|
47
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@cache
|
|
51
|
+
def _adapter(cls: type[T]) -> TypeAdapter[T]:
|
|
52
|
+
return TypeAdapter(cls)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate(cls: type[T], data: Any) -> T:
|
|
56
|
+
return _adapter(cls).validate_python(data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NeptuneClient:
|
|
60
|
+
"""Sync JSON-RPC client for Neptune.
|
|
61
|
+
|
|
62
|
+
Example::
|
|
63
|
+
|
|
64
|
+
with NeptuneClient("http://127.0.0.1:8002", token="secret") as c:
|
|
65
|
+
torrents = c.torrent_list()
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
base_url: str,
|
|
71
|
+
*,
|
|
72
|
+
token: str,
|
|
73
|
+
timeout: float = 30.0,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._client = httpx.Client(
|
|
76
|
+
base_url=base_url.rstrip("/"),
|
|
77
|
+
headers={"Authorization": token},
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# ── lifecycle ──────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> NeptuneClient:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __exit__(self, *exc: Any) -> None:
|
|
87
|
+
self.close()
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
self._client.close()
|
|
91
|
+
|
|
92
|
+
# ── transport ──────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def _call(self, method: str, params: Any = None) -> Any:
|
|
95
|
+
"""Send a JSON-RPC request and return the result field."""
|
|
96
|
+
payload: dict[str, Any] = {
|
|
97
|
+
"jsonrpc": "2.0",
|
|
98
|
+
"method": method,
|
|
99
|
+
"id": random.randint(1, 2**31),
|
|
100
|
+
}
|
|
101
|
+
if params is not None:
|
|
102
|
+
payload["params"] = asdict(params)
|
|
103
|
+
|
|
104
|
+
body_bytes = json.dumps(payload, default=_json_default).encode()
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
resp = self._client.post(
|
|
108
|
+
"/json_rpc",
|
|
109
|
+
content=body_bytes,
|
|
110
|
+
headers={"Content-Type": "application/json"},
|
|
111
|
+
)
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
except httpx.HTTPError as exc:
|
|
114
|
+
raise NeptuneConnectionError(str(exc)) from exc
|
|
115
|
+
|
|
116
|
+
body = resp.json()
|
|
117
|
+
|
|
118
|
+
if "error" in body and body["error"] is not None:
|
|
119
|
+
err = body["error"]
|
|
120
|
+
raise NeptuneRPCError(
|
|
121
|
+
code=err.get("code", -1),
|
|
122
|
+
message=err.get("message", ""),
|
|
123
|
+
data=err.get("data"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return body.get("result")
|
|
127
|
+
|
|
128
|
+
# ── system ─────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def ping(self) -> None:
|
|
131
|
+
"""system.ping — health check."""
|
|
132
|
+
self._call("system.ping")
|
|
133
|
+
|
|
134
|
+
# ── transfer ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def transfer_summary(self) -> TransferSummary:
|
|
137
|
+
"""Global download/upload rates and totals."""
|
|
138
|
+
return _validate(TransferSummary, self._call("transfer_summary"))
|
|
139
|
+
|
|
140
|
+
# ── torrent — queries ──────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def torrent_list(self, keys: list[str] | None = None) -> TorrentListResponse:
|
|
143
|
+
"""List all torrents. Optionally filter custom keys returned."""
|
|
144
|
+
return _validate(
|
|
145
|
+
TorrentListResponse,
|
|
146
|
+
self._call("torrent.list", ListTorrentRequest(keys=keys or None)),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def torrent_get(self, info_hash: str) -> TorrentInfo:
|
|
150
|
+
"""Get basic info for a single torrent."""
|
|
151
|
+
return _validate(
|
|
152
|
+
TorrentInfo,
|
|
153
|
+
self._call("torrent.get", InfoHashRequest(info_hash=info_hash)),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def torrent_files(self, info_hash: str) -> TorrentFilesResponse:
|
|
157
|
+
"""List files in a torrent."""
|
|
158
|
+
return _validate(
|
|
159
|
+
TorrentFilesResponse,
|
|
160
|
+
self._call("torrent.files", InfoHashRequest(info_hash=info_hash)),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def torrent_peers(self, info_hash: str) -> TorrentPeersResponse:
|
|
164
|
+
"""List connected peers for a torrent."""
|
|
165
|
+
return _validate(
|
|
166
|
+
TorrentPeersResponse,
|
|
167
|
+
self._call("torrent.peers", InfoHashRequest(info_hash=info_hash)),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def torrent_trackers(self, info_hash: str) -> TorrentTrackersResponse:
|
|
171
|
+
"""List trackers for a torrent."""
|
|
172
|
+
return _validate(
|
|
173
|
+
TorrentTrackersResponse,
|
|
174
|
+
self._call("torrent.trackers", InfoHashRequest(info_hash=info_hash)),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def torrent_add_tracker(self, info_hash: str, url: str, *, tier: int = 0) -> None:
|
|
178
|
+
"""Add a tracker to a torrent."""
|
|
179
|
+
self._call(
|
|
180
|
+
"torrent.add_tracker",
|
|
181
|
+
AddTrackerRequest(info_hash=info_hash, url=url, tier=tier),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def torrent_remove_tracker(self, info_hash: str, url: str) -> None:
|
|
185
|
+
"""Remove a tracker from a torrent."""
|
|
186
|
+
self._call(
|
|
187
|
+
"torrent.remove_tracker",
|
|
188
|
+
RemoveTrackerRequest(info_hash=info_hash, url=url),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def torrent_replace_trackers(
|
|
192
|
+
self, info_hash: str, replacements: dict[str, str]
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Replace tracker URLs. Keys are old URLs, values are new URLs."""
|
|
195
|
+
self._call(
|
|
196
|
+
"torrent.replace_trackers",
|
|
197
|
+
ReplaceTrackersRequest(info_hash=info_hash, replacements=replacements),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# ── torrent — mutations ────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def torrent_add(self, req: AddTorrentRequest) -> AddTorrentResponse:
|
|
203
|
+
"""Add a torrent from raw .torrent bytes."""
|
|
204
|
+
return _validate(AddTorrentResponse, self._call("torrent.add", req))
|
|
205
|
+
|
|
206
|
+
def torrent_move(self, info_hash: str, target_base_path: str) -> None:
|
|
207
|
+
"""Move torrent data to a new directory."""
|
|
208
|
+
self._call(
|
|
209
|
+
"torrent.move",
|
|
210
|
+
MoveTorrentRequest(info_hash=info_hash, target_base_path=target_base_path),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def torrent_remove(
|
|
214
|
+
self, info_hash: str, *, delete_data: bool = False
|
|
215
|
+
) -> TorrentListResponse:
|
|
216
|
+
"""Remove a torrent and return updated list."""
|
|
217
|
+
return _validate(
|
|
218
|
+
TorrentListResponse,
|
|
219
|
+
self._call(
|
|
220
|
+
"torrent.remove",
|
|
221
|
+
RemoveTorrentRequest(info_hash=info_hash, delete_data=delete_data),
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def torrent_start(self, info_hash: str) -> None:
|
|
226
|
+
"""Start a torrent."""
|
|
227
|
+
self._call("torrent.start", InfoHashRequest(info_hash=info_hash))
|
|
228
|
+
|
|
229
|
+
def torrent_stop(self, info_hash: str) -> None:
|
|
230
|
+
"""Stop a torrent."""
|
|
231
|
+
self._call("torrent.stop", InfoHashRequest(info_hash=info_hash))
|
|
232
|
+
|
|
233
|
+
def torrent_recheck(self, info_hash: str) -> None:
|
|
234
|
+
"""Recheck torrent data integrity."""
|
|
235
|
+
self._call("torrent.recheck", InfoHashRequest(info_hash=info_hash))
|
|
236
|
+
|
|
237
|
+
# ── torrent — custom ───────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
def torrent_custom_set(self, info_hash: str, key: str, value: str) -> None:
|
|
240
|
+
"""Set a custom key-value pair on a torrent."""
|
|
241
|
+
self._call(
|
|
242
|
+
"torrent.custom.set",
|
|
243
|
+
SetCustomRequest(info_hash=info_hash, key=key, value=value),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def torrent_custom_update(self, info_hash: str, custom: dict[str, str]) -> None:
|
|
247
|
+
"""Update multiple custom key-value pairs on a torrent."""
|
|
248
|
+
self._call(
|
|
249
|
+
"torrent.custom.update",
|
|
250
|
+
UpdateCustomRequest(info_hash=info_hash, custom=custom),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def torrent_custom_del(self, info_hash: str, key: str) -> None:
|
|
254
|
+
"""Delete a custom key from a torrent."""
|
|
255
|
+
self._call(
|
|
256
|
+
"torrent.custom.del",
|
|
257
|
+
DelCustomRequest(info_hash=info_hash, key=key),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def torrent_add_tags(self, info_hash: str, tags: list[str]) -> None:
|
|
261
|
+
"""Add tags to a torrent."""
|
|
262
|
+
self._call("torrent.add_tags", TagsRequest(info_hash=info_hash, tags=tags))
|
|
263
|
+
|
|
264
|
+
def torrent_remove_tags(self, info_hash: str, tags: list[str]) -> None:
|
|
265
|
+
"""Remove tags from a torrent."""
|
|
266
|
+
self._call("torrent.remove_tags", TagsRequest(info_hash=info_hash, tags=tags))
|
|
267
|
+
|
|
268
|
+
# ── torrent — limits ───────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
def torrent_set_download_limit(self, info_hash: str, limit: int) -> None:
|
|
271
|
+
"""Set per-torrent download speed limit (bytes/s, <=0 = unlimited)."""
|
|
272
|
+
self._call(
|
|
273
|
+
"torrent.set_download_limit",
|
|
274
|
+
SetSpeedLimitRequest(info_hash=info_hash, limit=limit),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def torrent_set_upload_limit(self, info_hash: str, limit: int) -> None:
|
|
278
|
+
"""Set per-torrent upload speed limit (bytes/s, <=0 = unlimited)."""
|
|
279
|
+
self._call(
|
|
280
|
+
"torrent.set_upload_limit",
|
|
281
|
+
SetSpeedLimitRequest(info_hash=info_hash, limit=limit),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def client_set_download_limit(self, limit: int) -> None:
|
|
285
|
+
"""Set global download speed limit (bytes/s, <=0 = unlimited)."""
|
|
286
|
+
self._call(
|
|
287
|
+
"client.set_download_limit",
|
|
288
|
+
SetGlobalSpeedLimitRequest(limit=limit),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def client_set_upload_limit(self, limit: int) -> None:
|
|
292
|
+
"""Set global upload speed limit (bytes/s, <=0 = unlimited)."""
|
|
293
|
+
self._call(
|
|
294
|
+
"client.set_upload_limit",
|
|
295
|
+
SetGlobalSpeedLimitRequest(limit=limit),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# ── torrent — file priority ────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def torrent_set_file_priority(
|
|
301
|
+
self, info_hash: str, file_ids: list[int], priority: int
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Set file priority (0 = skip, 1 = download)."""
|
|
304
|
+
self._call(
|
|
305
|
+
"torrent.set_file_priority",
|
|
306
|
+
SetFilePriorityRequest(
|
|
307
|
+
info_hash=info_hash,
|
|
308
|
+
file_ids=file_ids,
|
|
309
|
+
priority=priority,
|
|
310
|
+
),
|
|
311
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class NeptuneError(Exception):
|
|
2
|
+
"""Base exception for Neptune SDK."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NeptuneRPCError(NeptuneError):
|
|
6
|
+
"""Raised when the JSON-RPC server returns an error response."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, code: int, message: str, data: object = None) -> None:
|
|
9
|
+
self.code = code
|
|
10
|
+
self.message = message
|
|
11
|
+
self.data = data
|
|
12
|
+
super().__init__(f"RPC error {code}: {message}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NeptuneConnectionError(NeptuneError):
|
|
16
|
+
"""Raised when the HTTP connection to Neptune fails."""
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
# ── Shared domain types ──────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
9
|
+
class MainDataTorrent:
|
|
10
|
+
"""A torrent entry returned by torrent.list / torrent.remove."""
|
|
11
|
+
|
|
12
|
+
hash: str
|
|
13
|
+
name: str
|
|
14
|
+
state: str
|
|
15
|
+
comment: str
|
|
16
|
+
directory_base: str
|
|
17
|
+
message: str
|
|
18
|
+
tracker_errors: dict[str, str]
|
|
19
|
+
tags: list[str]
|
|
20
|
+
custom: dict[str, str]
|
|
21
|
+
download_rate: int
|
|
22
|
+
download_total: int
|
|
23
|
+
upload_rate: int
|
|
24
|
+
upload_total: int
|
|
25
|
+
connection_count: int
|
|
26
|
+
completed: int
|
|
27
|
+
total_length: int
|
|
28
|
+
selected_size: int
|
|
29
|
+
add_at: int
|
|
30
|
+
private: bool
|
|
31
|
+
total_seeding: int
|
|
32
|
+
total_downloading: int
|
|
33
|
+
connected_seeding: int
|
|
34
|
+
connected_downloading: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
38
|
+
class TransferSummary:
|
|
39
|
+
"""Global transfer rates and totals."""
|
|
40
|
+
|
|
41
|
+
download_rate: int
|
|
42
|
+
download_total: int
|
|
43
|
+
upload_rate: int
|
|
44
|
+
upload_total: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
48
|
+
class TorrentFile:
|
|
49
|
+
"""A single file inside a torrent."""
|
|
50
|
+
|
|
51
|
+
path: list[str]
|
|
52
|
+
index: int
|
|
53
|
+
progress: float
|
|
54
|
+
size: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
58
|
+
class Peer:
|
|
59
|
+
"""A connected peer."""
|
|
60
|
+
|
|
61
|
+
address: str
|
|
62
|
+
client: str
|
|
63
|
+
progress: float
|
|
64
|
+
download_rate: int
|
|
65
|
+
upload_rate: int
|
|
66
|
+
is_incoming: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
70
|
+
class Tracker:
|
|
71
|
+
"""A tracker entry."""
|
|
72
|
+
|
|
73
|
+
url: str
|
|
74
|
+
tier: int
|
|
75
|
+
message: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
79
|
+
class TorrentInfo:
|
|
80
|
+
"""Basic torrent metadata from torrent.get."""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
tags: list[str]
|
|
84
|
+
custom: dict[str, str]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Request types ─────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
91
|
+
class AddTorrentRequest:
|
|
92
|
+
"""Parameters for torrent.add."""
|
|
93
|
+
|
|
94
|
+
torrent_file: bytes
|
|
95
|
+
download_dir: str | None = None
|
|
96
|
+
tags: list[str] | None = None
|
|
97
|
+
custom: dict[str, str] | None = None
|
|
98
|
+
selected_files: list[int] | None = None
|
|
99
|
+
is_base_dir: bool = False
|
|
100
|
+
skip_hash_check: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
104
|
+
class InfoHashRequest:
|
|
105
|
+
"""Common request that only needs an info_hash."""
|
|
106
|
+
|
|
107
|
+
info_hash: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
111
|
+
class MoveTorrentRequest:
|
|
112
|
+
"""Parameters for torrent.move."""
|
|
113
|
+
|
|
114
|
+
info_hash: str
|
|
115
|
+
target_base_path: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
119
|
+
class RemoveTorrentRequest:
|
|
120
|
+
"""Parameters for torrent.remove."""
|
|
121
|
+
|
|
122
|
+
info_hash: str
|
|
123
|
+
delete_data: bool = False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
127
|
+
class TagsRequest:
|
|
128
|
+
"""Parameters for torrent.add_tags / torrent.remove_tags."""
|
|
129
|
+
|
|
130
|
+
info_hash: str
|
|
131
|
+
tags: list[str]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
135
|
+
class SetFilePriorityRequest:
|
|
136
|
+
"""Parameters for torrent.set_file_priority."""
|
|
137
|
+
|
|
138
|
+
info_hash: str
|
|
139
|
+
file_ids: list[int]
|
|
140
|
+
priority: int = 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
144
|
+
class SetSpeedLimitRequest:
|
|
145
|
+
"""Parameters for torrent.set_download_limit / torrent.set_upload_limit."""
|
|
146
|
+
|
|
147
|
+
info_hash: str
|
|
148
|
+
limit: int = 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
152
|
+
class SetGlobalSpeedLimitRequest:
|
|
153
|
+
"""Parameters for client.set_download_limit / client.set_upload_limit."""
|
|
154
|
+
|
|
155
|
+
limit: int = 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
159
|
+
class ListTorrentRequest:
|
|
160
|
+
"""Parameters for torrent.list."""
|
|
161
|
+
|
|
162
|
+
keys: list[str] | None = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
166
|
+
class SetCustomRequest:
|
|
167
|
+
"""Parameters for torrent.custom.set."""
|
|
168
|
+
|
|
169
|
+
info_hash: str
|
|
170
|
+
key: str
|
|
171
|
+
value: str
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
175
|
+
class UpdateCustomRequest:
|
|
176
|
+
"""Parameters for torrent.custom.update."""
|
|
177
|
+
|
|
178
|
+
info_hash: str
|
|
179
|
+
custom: dict[str, str]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
183
|
+
class DelCustomRequest:
|
|
184
|
+
"""Parameters for torrent.custom.del."""
|
|
185
|
+
|
|
186
|
+
info_hash: str
|
|
187
|
+
key: str
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
191
|
+
class AddTrackerRequest:
|
|
192
|
+
"""Parameters for torrent.add_tracker."""
|
|
193
|
+
|
|
194
|
+
info_hash: str
|
|
195
|
+
url: str
|
|
196
|
+
tier: int = 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
200
|
+
class RemoveTrackerRequest:
|
|
201
|
+
"""Parameters for torrent.remove_tracker."""
|
|
202
|
+
|
|
203
|
+
info_hash: str
|
|
204
|
+
url: str
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
208
|
+
class ReplaceTrackersRequest:
|
|
209
|
+
"""Parameters for torrent.replace_trackers."""
|
|
210
|
+
|
|
211
|
+
info_hash: str
|
|
212
|
+
replacements: dict[str, str]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── Response types ────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
219
|
+
class TorrentListResponse:
|
|
220
|
+
"""Response for torrent.list and torrent.remove."""
|
|
221
|
+
|
|
222
|
+
torrents: list[MainDataTorrent]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
226
|
+
class AddTorrentResponse:
|
|
227
|
+
"""Response for torrent.add."""
|
|
228
|
+
|
|
229
|
+
info_hash: str
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
233
|
+
class TorrentFilesResponse:
|
|
234
|
+
"""Response for torrent.files."""
|
|
235
|
+
|
|
236
|
+
files: list[TorrentFile]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
240
|
+
class TorrentPeersResponse:
|
|
241
|
+
"""Response for torrent.peers."""
|
|
242
|
+
|
|
243
|
+
peers: list[Peer]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
247
|
+
class TorrentTrackersResponse:
|
|
248
|
+
"""Response for torrent.trackers."""
|
|
249
|
+
|
|
250
|
+
trackers: list[Tracker]
|
|
File without changes
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Tests for NeptuneClient using respx to mock HTTP transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
import respx
|
|
11
|
+
|
|
12
|
+
from neptune_sdk import (
|
|
13
|
+
AddTorrentRequest,
|
|
14
|
+
NeptuneClient,
|
|
15
|
+
NeptuneRPCError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
BASE = "http://127.0.0.1:8002"
|
|
19
|
+
TOKEN = "test-token"
|
|
20
|
+
|
|
21
|
+
# ── helpers ───────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ok(result, *, id=1):
|
|
25
|
+
return httpx.Response(200, json={"jsonrpc": "2.0", "result": result, "id": id})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _rpc_error(code=-32600, message="bad request", *, id=1):
|
|
29
|
+
return httpx.Response(
|
|
30
|
+
200,
|
|
31
|
+
json={"jsonrpc": "2.0", "error": {"code": code, "message": message}, "id": id},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
TORRENT_JSON = {
|
|
36
|
+
"hash": "aabb",
|
|
37
|
+
"name": "test",
|
|
38
|
+
"state": "Downloading",
|
|
39
|
+
"comment": "",
|
|
40
|
+
"directory_base": "/downloads/test",
|
|
41
|
+
"message": "",
|
|
42
|
+
"tracker_errors": {},
|
|
43
|
+
"tags": [],
|
|
44
|
+
"custom": {},
|
|
45
|
+
"download_rate": 0,
|
|
46
|
+
"download_total": 0,
|
|
47
|
+
"upload_rate": 0,
|
|
48
|
+
"upload_total": 0,
|
|
49
|
+
"connection_count": 0,
|
|
50
|
+
"completed": 0,
|
|
51
|
+
"total_length": 100,
|
|
52
|
+
"selected_size": 100,
|
|
53
|
+
"add_at": 0,
|
|
54
|
+
"total_seeding": 0,
|
|
55
|
+
"total_downloading": 1,
|
|
56
|
+
"connected_seeding": 0,
|
|
57
|
+
"connected_downloading": 2,
|
|
58
|
+
"private": False,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── tests ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture()
|
|
66
|
+
def mock_api():
|
|
67
|
+
with respx.mock(base_url=BASE) as rspx:
|
|
68
|
+
yield rspx
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture()
|
|
72
|
+
def client():
|
|
73
|
+
with NeptuneClient(BASE, token=TOKEN) as c:
|
|
74
|
+
yield c
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_ping(mock_api, client):
|
|
78
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
79
|
+
client.ping()
|
|
80
|
+
req = mock_api.calls.last.request
|
|
81
|
+
payload = json.loads(req.content)
|
|
82
|
+
assert payload["method"] == "system.ping"
|
|
83
|
+
assert req.headers["authorization"] == TOKEN
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_transfer_summary(mock_api, client):
|
|
87
|
+
mock_api.post("/json_rpc").mock(
|
|
88
|
+
return_value=_ok(
|
|
89
|
+
{
|
|
90
|
+
"download_rate": 100,
|
|
91
|
+
"download_total": 200,
|
|
92
|
+
"upload_rate": 50,
|
|
93
|
+
"upload_total": 80,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
result = client.transfer_summary()
|
|
98
|
+
assert result.download_rate == 100
|
|
99
|
+
assert result.upload_total == 80
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_torrent_list(mock_api, client):
|
|
103
|
+
mock_api.post("/json_rpc").mock(return_value=_ok({"torrents": [TORRENT_JSON]}))
|
|
104
|
+
result = client.torrent_list()
|
|
105
|
+
assert len(result.torrents) == 1
|
|
106
|
+
assert result.torrents[0].hash == "aabb"
|
|
107
|
+
assert result.torrents[0].name == "test"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_torrent_get(mock_api, client):
|
|
111
|
+
mock_api.post("/json_rpc").mock(
|
|
112
|
+
return_value=_ok(
|
|
113
|
+
{"name": "my_torrent", "tags": ["a", "b"], "custom": {"key1": "val1"}}
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
result = client.torrent_get("aabb")
|
|
117
|
+
assert result.name == "my_torrent"
|
|
118
|
+
assert result.tags == ["a", "b"]
|
|
119
|
+
assert result.custom == {"key1": "val1"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_torrent_files(mock_api, client):
|
|
123
|
+
mock_api.post("/json_rpc").mock(
|
|
124
|
+
return_value=_ok(
|
|
125
|
+
{
|
|
126
|
+
"files": [
|
|
127
|
+
{
|
|
128
|
+
"path": ["dir", "file.txt"],
|
|
129
|
+
"index": 0,
|
|
130
|
+
"progress": 0.5,
|
|
131
|
+
"size": 1024,
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
result = client.torrent_files("aabb")
|
|
138
|
+
assert len(result.files) == 1
|
|
139
|
+
assert result.files[0].path == ["dir", "file.txt"]
|
|
140
|
+
assert result.files[0].progress == 0.5
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_torrent_peers(mock_api, client):
|
|
144
|
+
mock_api.post("/json_rpc").mock(
|
|
145
|
+
return_value=_ok(
|
|
146
|
+
{
|
|
147
|
+
"peers": [
|
|
148
|
+
{
|
|
149
|
+
"address": "1.2.3.4:5678",
|
|
150
|
+
"client": "qBittorrent",
|
|
151
|
+
"progress": 0.9,
|
|
152
|
+
"download_rate": 1000,
|
|
153
|
+
"upload_rate": 500,
|
|
154
|
+
"is_incoming": False,
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
result = client.torrent_peers("aabb")
|
|
161
|
+
assert result.peers[0].address == "1.2.3.4:5678"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_torrent_trackers(mock_api, client):
|
|
165
|
+
mock_api.post("/json_rpc").mock(
|
|
166
|
+
return_value=_ok(
|
|
167
|
+
{
|
|
168
|
+
"trackers": [
|
|
169
|
+
{"url": "http://t.example.com/announce", "tier": 0, "message": ""}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
result = client.torrent_trackers("aabb")
|
|
175
|
+
assert result.trackers[0].url == "http://t.example.com/announce"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_torrent_add_encodes_base64(mock_api, client):
|
|
179
|
+
mock_api.post("/json_rpc").mock(return_value=_ok({"info_hash": "aa" * 20}))
|
|
180
|
+
|
|
181
|
+
torrent_bytes = b"d8:announce3:url..."
|
|
182
|
+
req = AddTorrentRequest(torrent_file=torrent_bytes, tags=["linux"])
|
|
183
|
+
result = client.torrent_add(req)
|
|
184
|
+
|
|
185
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
186
|
+
assert payload["params"]["torrent_file"] == base64.b64encode(torrent_bytes).decode()
|
|
187
|
+
assert payload["params"]["tags"] == ["linux"]
|
|
188
|
+
assert result.info_hash == "aa" * 20
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_torrent_remove(mock_api, client):
|
|
192
|
+
mock_api.post("/json_rpc").mock(return_value=_ok({"torrents": []}))
|
|
193
|
+
result = client.torrent_remove("aabb", delete_data=True)
|
|
194
|
+
assert result.torrents == []
|
|
195
|
+
|
|
196
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
197
|
+
assert payload["params"]["delete_data"] is True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_torrent_move(mock_api, client):
|
|
201
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
202
|
+
client.torrent_move("aabb", "/new/path")
|
|
203
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
204
|
+
assert payload["method"] == "torrent.move"
|
|
205
|
+
assert payload["params"]["info_hash"] == "aabb"
|
|
206
|
+
assert payload["params"]["target_base_path"] == "/new/path"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_torrent_start(mock_api, client):
|
|
210
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
211
|
+
client.torrent_start("aabb")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_torrent_stop(mock_api, client):
|
|
215
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
216
|
+
client.torrent_stop("aabb")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_torrent_add_tags(mock_api, client):
|
|
220
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
221
|
+
client.torrent_add_tags("aabb", ["tag1", "tag2"])
|
|
222
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
223
|
+
assert payload["params"]["tags"] == ["tag1", "tag2"]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_torrent_remove_tags(mock_api, client):
|
|
227
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
228
|
+
client.torrent_remove_tags("aabb", ["old"])
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_torrent_set_download_limit(mock_api, client):
|
|
232
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
233
|
+
client.torrent_set_download_limit("aabb", 1024 * 1024)
|
|
234
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
235
|
+
assert payload["params"]["limit"] == 1048576
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_torrent_set_upload_limit(mock_api, client):
|
|
239
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
240
|
+
client.torrent_set_upload_limit("aabb", 512000)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_client_set_download_limit(mock_api, client):
|
|
244
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
245
|
+
client.client_set_download_limit(0)
|
|
246
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
247
|
+
assert payload["method"] == "client.set_download_limit"
|
|
248
|
+
assert payload["params"]["limit"] == 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_client_set_upload_limit(mock_api, client):
|
|
252
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
253
|
+
client.client_set_upload_limit(-1)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_torrent_set_file_priority(mock_api, client):
|
|
257
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
258
|
+
client.torrent_set_file_priority("aabb", [0, 2], 1)
|
|
259
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
260
|
+
assert payload["params"]["file_ids"] == [0, 2]
|
|
261
|
+
assert payload["params"]["priority"] == 1
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_rpc_error_raises(mock_api, client):
|
|
265
|
+
mock_api.post("/json_rpc").mock(return_value=_rpc_error(-32601, "method not found"))
|
|
266
|
+
with pytest.raises(NeptuneRPCError) as exc_info:
|
|
267
|
+
client.ping()
|
|
268
|
+
assert exc_info.value.code == -32601
|
|
269
|
+
assert "method not found" in exc_info.value.message
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_empty_torrent_list(mock_api, client):
|
|
273
|
+
mock_api.post("/json_rpc").mock(return_value=_ok({"torrents": []}))
|
|
274
|
+
result = client.torrent_list()
|
|
275
|
+
assert result.torrents == []
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_torrent_custom_set(mock_api, client):
|
|
279
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
280
|
+
client.torrent_custom_set("aabb", "label", "my-label")
|
|
281
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
282
|
+
assert payload["method"] == "torrent.custom.set"
|
|
283
|
+
assert payload["params"]["key"] == "label"
|
|
284
|
+
assert payload["params"]["value"] == "my-label"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_torrent_custom_update(mock_api, client):
|
|
288
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
289
|
+
client.torrent_custom_update("aabb", {"k1": "v1", "k2": "v2"})
|
|
290
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
291
|
+
assert payload["method"] == "torrent.custom.update"
|
|
292
|
+
assert payload["params"]["custom"] == {"k1": "v1", "k2": "v2"}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_torrent_custom_del(mock_api, client):
|
|
296
|
+
mock_api.post("/json_rpc").mock(return_value=_ok(None))
|
|
297
|
+
client.torrent_custom_del("aabb", "label")
|
|
298
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
299
|
+
assert payload["method"] == "torrent.custom.del"
|
|
300
|
+
assert payload["params"]["key"] == "label"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_torrent_list_with_keys(mock_api, client):
|
|
304
|
+
mock_api.post("/json_rpc").mock(return_value=_ok({"torrents": [TORRENT_JSON]}))
|
|
305
|
+
result = client.torrent_list(keys=["label"])
|
|
306
|
+
assert len(result.torrents) == 1
|
|
307
|
+
payload = json.loads(mock_api.calls.last.request.content)
|
|
308
|
+
assert payload["params"]["keys"] == ["label"]
|