rclone-bin-api 1.0.0__py3-none-win_amd64.whl
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.
- examples/__init__.py +0 -0
- examples/about.py +9 -0
- rclone_api/__init__.py +11 -0
- rclone_api/api.py +238 -0
- rclone_api/bin/rclone.exe +0 -0
- rclone_api/dto.py +210 -0
- rclone_api/exceptions.py +22 -0
- rclone_bin_api-1.0.0.dist-info/METADATA +60 -0
- rclone_bin_api-1.0.0.dist-info/RECORD +13 -0
- rclone_bin_api-1.0.0.dist-info/WHEEL +5 -0
- rclone_bin_api-1.0.0.dist-info/top_level.txt +3 -0
- tests/test_exceptions.py +7 -0
- tests/test_rclone.py +311 -0
examples/__init__.py
ADDED
|
File without changes
|
examples/about.py
ADDED
rclone_api/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
bin_dir = Path(__file__).parent / "bin"
|
|
5
|
+
bin_name = "rclone.exe" if sys.platform == "win32" else "rclone"
|
|
6
|
+
rclone = bin_dir / bin_name
|
|
7
|
+
|
|
8
|
+
assert rclone.is_file(), "rclone binary missing!"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
BINARY_PATH = rclone.absolute()
|
rclone_api/api.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from . import BINARY_PATH
|
|
13
|
+
from .dto import AsyncJobResponse, ConfigListremotes, CoreStats, CoreVersion, JobList, JobStatus, LsJsonEntry, PubliclinkResponse
|
|
14
|
+
from .exceptions import RcloneConnectionException, RcloneProcessException
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RcloneApi:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
bind="localhost:5572",
|
|
21
|
+
log_file: Path | None = None,
|
|
22
|
+
log_level: Literal["DEBUG", "INFO", "NOTICE", "ERROR"] = "NOTICE",
|
|
23
|
+
transfers: int = 4,
|
|
24
|
+
checkers: int = 4,
|
|
25
|
+
enable_webui: bool = False,
|
|
26
|
+
bwlimit: str | None = None,
|
|
27
|
+
):
|
|
28
|
+
self.__bind_addr = bind
|
|
29
|
+
self.__log_file = log_file
|
|
30
|
+
self.__log_level = log_level
|
|
31
|
+
self.__transfers = transfers
|
|
32
|
+
self.__checkers = checkers
|
|
33
|
+
self.__enable_webui = enable_webui
|
|
34
|
+
self.__bwlimit = bwlimit
|
|
35
|
+
self.__connect_addr = f"http://{bind}"
|
|
36
|
+
self.__process = None
|
|
37
|
+
self.__rclone_bin = BINARY_PATH
|
|
38
|
+
|
|
39
|
+
atexit.register(self._cleanup)
|
|
40
|
+
|
|
41
|
+
# -------------------------
|
|
42
|
+
# Lifecycle
|
|
43
|
+
# -------------------------
|
|
44
|
+
def start(self, startup_timeout: float | None = 5):
|
|
45
|
+
if self.__process:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if self.__log_file:
|
|
49
|
+
self.__log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
self.__process = subprocess.Popen(
|
|
52
|
+
[
|
|
53
|
+
str(self.__rclone_bin),
|
|
54
|
+
"rcd",
|
|
55
|
+
f"--rc-addr={self.__bind_addr}",
|
|
56
|
+
"--rc-no-auth", # TODO: add auth.
|
|
57
|
+
*(["--rc-web-gui"] if self.__enable_webui else []),
|
|
58
|
+
"--rc-web-gui-no-open-browser",
|
|
59
|
+
# The server needs to accept at least transfers+checkers connections, otherwise sync might fail!
|
|
60
|
+
# The connections could be limited, but it could cause deadlocks, so it's preferred to change transfers/checkers only
|
|
61
|
+
f"--transfers={self.__transfers}",
|
|
62
|
+
f"--checkers={self.__checkers}",
|
|
63
|
+
*([f"--log-file={self.__log_file}"] if self.__log_file else []),
|
|
64
|
+
f"--log-level={self.__log_level}",
|
|
65
|
+
*([f"--bwlimit={self.__bwlimit}"] if self.__bwlimit else []),
|
|
66
|
+
],
|
|
67
|
+
stderr=subprocess.PIPE,
|
|
68
|
+
text=True,
|
|
69
|
+
start_new_session=True, # for _cleanup
|
|
70
|
+
)
|
|
71
|
+
# during dev you might want to start on cli separately:
|
|
72
|
+
# rclone rcd --rc-no-auth --rc-addr=localhost:5572 --rc-web-gui --transfers=4 --checkers=4 --bwlimit=5K
|
|
73
|
+
|
|
74
|
+
if startup_timeout:
|
|
75
|
+
self.wait_until_operational(startup_timeout)
|
|
76
|
+
|
|
77
|
+
def wait_until_operational(self, timeout: float = 5) -> None:
|
|
78
|
+
assert self.__process
|
|
79
|
+
assert self.__process.stderr # enable PIPE in subprocess
|
|
80
|
+
|
|
81
|
+
deadline = time.time() + timeout
|
|
82
|
+
|
|
83
|
+
while time.time() < deadline:
|
|
84
|
+
# If rclone died immediately, capture stderr and raise
|
|
85
|
+
ret = self.__process.poll()
|
|
86
|
+
if ret is not None:
|
|
87
|
+
stderr = self.__process.stderr.read()
|
|
88
|
+
raise RuntimeError(f"rclone failed to start (exit={ret}): {stderr.strip()}")
|
|
89
|
+
|
|
90
|
+
if ret is None and self.operational():
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
time.sleep(0.1)
|
|
94
|
+
|
|
95
|
+
raise RuntimeError(f"rclone did not become operational after {timeout}s")
|
|
96
|
+
|
|
97
|
+
def stop(self):
|
|
98
|
+
if self.__process:
|
|
99
|
+
self.__process.terminate()
|
|
100
|
+
self.__process.wait(timeout=5)
|
|
101
|
+
|
|
102
|
+
self.__process = None
|
|
103
|
+
|
|
104
|
+
def _cleanup(self):
|
|
105
|
+
# if forgot to call stop, we still kill a possible rclone instance at py program exit.
|
|
106
|
+
if self.__process and self.__process.poll() is None:
|
|
107
|
+
os.killpg(self.__process.pid, signal.SIGTERM)
|
|
108
|
+
|
|
109
|
+
# -------------------------
|
|
110
|
+
# Internal helper
|
|
111
|
+
# -------------------------
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _valid_fs_remote(fs: str, remote: str):
|
|
115
|
+
# Remote backend: fs ends with ":" → remote must NOT be absolute
|
|
116
|
+
|
|
117
|
+
assert not (fs.endswith(":") and Path(remote).is_absolute()), f"remote must be relative when fs is a remote: {fs=} {remote=}"
|
|
118
|
+
|
|
119
|
+
# Local backend: fs does NOT end with ":" → remote must be absolute
|
|
120
|
+
assert not (not fs.endswith(":") and not Path(fs).is_absolute()), f"fs must be absolute when remote is a local path: {fs=} {remote=}"
|
|
121
|
+
|
|
122
|
+
def _post(self, endpoint: str, data: dict[str, Any] | None = None):
|
|
123
|
+
req = urllib.request.Request(
|
|
124
|
+
url=f"{self.__connect_addr}/{endpoint}",
|
|
125
|
+
data=json.dumps(data or {}).encode("utf-8"),
|
|
126
|
+
headers={"Content-Type": "application/json"},
|
|
127
|
+
method="POST",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
132
|
+
raw: bytes = resp.read()
|
|
133
|
+
response_json = json.loads(raw.decode("utf-8"))
|
|
134
|
+
|
|
135
|
+
except urllib.error.HTTPError as exc: # non 200 HTTP codes
|
|
136
|
+
raw: bytes = exc.read()
|
|
137
|
+
response_json = json.loads(raw.decode("utf-8"))
|
|
138
|
+
raise RcloneProcessException.from_dict(response_json) from exc
|
|
139
|
+
except Exception as exc: # all other errors
|
|
140
|
+
raise RcloneConnectionException(f"Issue connecting to rclone RC server, error: {exc}") from exc
|
|
141
|
+
else:
|
|
142
|
+
return response_json
|
|
143
|
+
|
|
144
|
+
def _noopauth(self, input: dict):
|
|
145
|
+
return self._post("rc/noopauth", input)
|
|
146
|
+
|
|
147
|
+
def wait_for_jobs(self, job_ids: list[int]):
|
|
148
|
+
_job_ids = set(job_ids)
|
|
149
|
+
|
|
150
|
+
while self.__process: # only wait if there is a process running
|
|
151
|
+
running = set(self.job_list().runningIds)
|
|
152
|
+
if _job_ids.isdisjoint(running):
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
time.sleep(0.05)
|
|
156
|
+
|
|
157
|
+
# -------------------------
|
|
158
|
+
# Operations
|
|
159
|
+
# -------------------------
|
|
160
|
+
|
|
161
|
+
def deletefile(self, fs: str, remote: str) -> None:
|
|
162
|
+
self._valid_fs_remote(fs, remote)
|
|
163
|
+
self._post("operations/deletefile", {"fs": fs, "remote": remote})
|
|
164
|
+
|
|
165
|
+
def copyfile(self, src_fs: str, src_remote: str, dst_fs: str, dst_remote: str) -> None:
|
|
166
|
+
self._valid_fs_remote(src_fs, src_remote)
|
|
167
|
+
self._valid_fs_remote(dst_fs, dst_remote)
|
|
168
|
+
self._post("operations/copyfile", {"srcFs": src_fs, "srcRemote": src_remote, "dstFs": dst_fs, "dstRemote": dst_remote})
|
|
169
|
+
|
|
170
|
+
def copyfile_async(self, src_fs: str, src_remote: str, dst_fs: str, dst_remote: str) -> AsyncJobResponse:
|
|
171
|
+
self._valid_fs_remote(src_fs, src_remote)
|
|
172
|
+
self._valid_fs_remote(dst_fs, dst_remote)
|
|
173
|
+
result = self._post(
|
|
174
|
+
"operations/copyfile", {"_async": True, "srcFs": src_fs, "srcRemote": src_remote, "dstFs": dst_fs, "dstRemote": dst_remote}
|
|
175
|
+
)
|
|
176
|
+
return AsyncJobResponse.from_dict(result)
|
|
177
|
+
|
|
178
|
+
def copy(self, src_fs: str, dst_fs: str, create_empty_src_dirs: bool = False) -> None:
|
|
179
|
+
self._post("sync/copy", {"srcFs": src_fs, "dstFs": dst_fs, "createEmptySrcDirs": create_empty_src_dirs})
|
|
180
|
+
|
|
181
|
+
def copy_async(self, src_fs: str, dst_fs: str, create_empty_src_dirs: bool = False) -> AsyncJobResponse:
|
|
182
|
+
result = self._post("sync/copy", {"_async": True, "srcFs": src_fs, "dstFs": dst_fs, "createEmptySrcDirs": create_empty_src_dirs})
|
|
183
|
+
return AsyncJobResponse.from_dict(result)
|
|
184
|
+
|
|
185
|
+
def sync(self, src_fs: str, dst_fs: str, create_empty_src_dirs: bool = False) -> None:
|
|
186
|
+
self._post("sync/sync", {"srcFs": src_fs, "dstFs": dst_fs, "createEmptySrcDirs": create_empty_src_dirs})
|
|
187
|
+
|
|
188
|
+
def sync_async(self, src_fs: str, dst_fs: str, create_empty_src_dirs: bool = False) -> AsyncJobResponse:
|
|
189
|
+
result = self._post("sync/sync", {"_async": True, "srcFs": src_fs, "dstFs": dst_fs, "createEmptySrcDirs": create_empty_src_dirs})
|
|
190
|
+
return AsyncJobResponse.from_dict(result)
|
|
191
|
+
|
|
192
|
+
def publiclink(self, fs: str, remote: str, unlink: bool = False, expire: str | None = None) -> PubliclinkResponse:
|
|
193
|
+
self._valid_fs_remote(fs, remote)
|
|
194
|
+
result = self._post("operations/publiclink", {"fs": fs, "remote": remote, "unlink": unlink, **({"expire": expire} if expire else {})})
|
|
195
|
+
return PubliclinkResponse.from_dict(result)
|
|
196
|
+
|
|
197
|
+
def ls(self, fs: str, remote: str) -> list[LsJsonEntry]:
|
|
198
|
+
self._valid_fs_remote(fs, remote)
|
|
199
|
+
response: dict = self._post("operations/list", {"fs": fs, "remote": remote})
|
|
200
|
+
ls: list[dict] = response["list"]
|
|
201
|
+
return [LsJsonEntry.from_dict(x) for x in ls]
|
|
202
|
+
|
|
203
|
+
# -------------------------
|
|
204
|
+
# Utilities
|
|
205
|
+
# -------------------------
|
|
206
|
+
def job_status(self, jobid: int) -> JobStatus:
|
|
207
|
+
return JobStatus.from_dict(self._post("job/status", {"jobid": jobid}))
|
|
208
|
+
|
|
209
|
+
def job_list(self) -> JobList:
|
|
210
|
+
return JobList.from_dict(self._post("job/list"))
|
|
211
|
+
|
|
212
|
+
# def abort_job(self, jobid: int) -> None:
|
|
213
|
+
# self._post("job/stop", {"jobid": jobid})
|
|
214
|
+
|
|
215
|
+
# def abort_jobgroup(self, group: str) -> None:
|
|
216
|
+
# self._post("job/stopgroup", {"group": group})
|
|
217
|
+
|
|
218
|
+
def core_stats(self) -> CoreStats:
|
|
219
|
+
return CoreStats.from_dict(self._post("core/stats"))
|
|
220
|
+
|
|
221
|
+
def version(self) -> CoreVersion:
|
|
222
|
+
return CoreVersion.from_dict(self._post("core/version"))
|
|
223
|
+
|
|
224
|
+
def config_create(self, name: str, type: str, parameters: dict[str, Any]) -> None:
|
|
225
|
+
return self._post("config/create", {"name": name, "type": type, "parameters": parameters})
|
|
226
|
+
|
|
227
|
+
def config_delete(self, name: str) -> None:
|
|
228
|
+
return self._post("config/delete", {"name": name})
|
|
229
|
+
|
|
230
|
+
def config_listremotes(self) -> ConfigListremotes:
|
|
231
|
+
return ConfigListremotes.from_dict(self._post("config/listremotes"))
|
|
232
|
+
|
|
233
|
+
def operational(self) -> bool:
|
|
234
|
+
chk_input = {"op": True}
|
|
235
|
+
try:
|
|
236
|
+
return self._noopauth(chk_input) == chk_input
|
|
237
|
+
except Exception:
|
|
238
|
+
return False
|
|
Binary file
|
rclone_api/dto.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(slots=True)
|
|
6
|
+
class CoreVersion:
|
|
7
|
+
version: str
|
|
8
|
+
|
|
9
|
+
@staticmethod
|
|
10
|
+
def from_dict(d: dict[str, Any]):
|
|
11
|
+
return CoreVersion(version=d.get("version", "unknown"))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class JobList:
|
|
16
|
+
executeId: str | None
|
|
17
|
+
jobids: list[int]
|
|
18
|
+
runningIds: list[int]
|
|
19
|
+
finishedIds: list[int]
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def from_dict(d: dict[str, Any]):
|
|
23
|
+
return JobList(
|
|
24
|
+
executeId=str(d.get("executeId")),
|
|
25
|
+
jobids=d.get("jobids", []),
|
|
26
|
+
runningIds=d.get("runningIds", []),
|
|
27
|
+
finishedIds=d.get("finishedIds", []),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class ConfigListremotes:
|
|
33
|
+
remotes: list[str]
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def from_dict(d: dict[str, Any]):
|
|
37
|
+
return ConfigListremotes(remotes=d.get("remotes", []))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class AsyncJobResponse:
|
|
42
|
+
jobid: int
|
|
43
|
+
executeId: str | None
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def from_dict(d: dict[str, Any]):
|
|
47
|
+
return AsyncJobResponse(
|
|
48
|
+
jobid=d["jobid"],
|
|
49
|
+
executeId=d.get("executeId"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class JobStatus:
|
|
55
|
+
finished: bool
|
|
56
|
+
duration: float
|
|
57
|
+
endTime: str | None
|
|
58
|
+
error: str | None
|
|
59
|
+
id: int
|
|
60
|
+
executeId: int | None
|
|
61
|
+
startTime: str | None
|
|
62
|
+
success: bool
|
|
63
|
+
output: Any
|
|
64
|
+
progress: Any
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def from_dict(d: dict[str, Any]):
|
|
68
|
+
return JobStatus(
|
|
69
|
+
finished=d.get("finished", False),
|
|
70
|
+
duration=d.get("duration", 0.0),
|
|
71
|
+
endTime=d.get("endTime"),
|
|
72
|
+
error=d.get("error"),
|
|
73
|
+
id=d.get("id", 0),
|
|
74
|
+
executeId=d.get("executeId"),
|
|
75
|
+
startTime=d.get("startTime"),
|
|
76
|
+
success=d.get("success", False),
|
|
77
|
+
output=d.get("output"),
|
|
78
|
+
progress=d.get("progress"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(slots=True)
|
|
83
|
+
class TransferEntry:
|
|
84
|
+
bytes: int
|
|
85
|
+
eta: float | None
|
|
86
|
+
name: str
|
|
87
|
+
percentage: float
|
|
88
|
+
speed: float
|
|
89
|
+
speedAvg: float
|
|
90
|
+
size: int
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def from_dict(d: dict[str, Any]):
|
|
94
|
+
return TransferEntry(
|
|
95
|
+
bytes=d.get("bytes", 0),
|
|
96
|
+
eta=d.get("eta"), # may be None
|
|
97
|
+
name=d.get("name", ""),
|
|
98
|
+
percentage=d.get("percentage", 0.0),
|
|
99
|
+
speed=d.get("speed", 0.0),
|
|
100
|
+
speedAvg=d.get("speedAvg", 0.0),
|
|
101
|
+
size=d.get("size", 0),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(slots=True)
|
|
106
|
+
class CoreStats:
|
|
107
|
+
bytes: int
|
|
108
|
+
checks: int
|
|
109
|
+
deletes: int
|
|
110
|
+
elapsedTime: float
|
|
111
|
+
errors: int
|
|
112
|
+
eta: float | None
|
|
113
|
+
fatalError: bool
|
|
114
|
+
lastError: str | None
|
|
115
|
+
renames: int
|
|
116
|
+
listed: int
|
|
117
|
+
retryError: bool
|
|
118
|
+
|
|
119
|
+
serverSideCopies: int
|
|
120
|
+
serverSideCopyBytes: int
|
|
121
|
+
serverSideMoves: int
|
|
122
|
+
serverSideMoveBytes: int
|
|
123
|
+
|
|
124
|
+
speed: float
|
|
125
|
+
|
|
126
|
+
totalBytes: int
|
|
127
|
+
totalChecks: int
|
|
128
|
+
totalTransfers: int
|
|
129
|
+
transferTime: float
|
|
130
|
+
transfers: int
|
|
131
|
+
|
|
132
|
+
transferring: list[TransferEntry]
|
|
133
|
+
checking: list[str]
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def from_dict(d: dict[str, Any]):
|
|
137
|
+
return CoreStats(
|
|
138
|
+
bytes=d.get("bytes", 0),
|
|
139
|
+
checks=d.get("checks", 0),
|
|
140
|
+
deletes=d.get("deletes", 0),
|
|
141
|
+
elapsedTime=d.get("elapsedTime", 0.0),
|
|
142
|
+
errors=d.get("errors", 0),
|
|
143
|
+
eta=d.get("eta"),
|
|
144
|
+
fatalError=d.get("fatalError", False),
|
|
145
|
+
lastError=d.get("lastError"),
|
|
146
|
+
renames=d.get("renames", 0),
|
|
147
|
+
listed=d.get("listed", 0),
|
|
148
|
+
retryError=d.get("retryError", False),
|
|
149
|
+
serverSideCopies=d.get("serverSideCopies", 0),
|
|
150
|
+
serverSideCopyBytes=d.get("serverSideCopyBytes", 0),
|
|
151
|
+
serverSideMoves=d.get("serverSideMoves", 0),
|
|
152
|
+
serverSideMoveBytes=d.get("serverSideMoveBytes", 0),
|
|
153
|
+
speed=d.get("speed", 0.0),
|
|
154
|
+
totalBytes=d.get("totalBytes", 0),
|
|
155
|
+
totalChecks=d.get("totalChecks", 0),
|
|
156
|
+
totalTransfers=d.get("totalTransfers", 0),
|
|
157
|
+
transferTime=d.get("transferTime", 0.0),
|
|
158
|
+
transfers=d.get("transfers", 0),
|
|
159
|
+
transferring=[TransferEntry.from_dict(x) for x in d.get("transferring", [])],
|
|
160
|
+
checking=d.get("checking", []),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(slots=True)
|
|
165
|
+
class LsJsonEntry:
|
|
166
|
+
Name: str
|
|
167
|
+
Size: int
|
|
168
|
+
Path: str
|
|
169
|
+
IsDir: bool
|
|
170
|
+
|
|
171
|
+
ModTime: str | None
|
|
172
|
+
MimeType: str | None
|
|
173
|
+
Hashes: dict[str, str] | None = None
|
|
174
|
+
ID: str | None = None
|
|
175
|
+
OrigID: str | None = None
|
|
176
|
+
IsBucket: bool | None = None
|
|
177
|
+
Encrypted: str | None = None
|
|
178
|
+
EncryptedPath: str | None = None
|
|
179
|
+
Tier: str | None = None
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def from_dict(d: dict[str, Any]):
|
|
183
|
+
return LsJsonEntry(
|
|
184
|
+
# mandatory
|
|
185
|
+
Name=str(d["Name"]),
|
|
186
|
+
Size=int(d["Size"]),
|
|
187
|
+
Path=str(d["Path"]),
|
|
188
|
+
IsDir=bool(d["IsDir"]),
|
|
189
|
+
# optional and/or backend dependent
|
|
190
|
+
ModTime=d.get("ModTime"),
|
|
191
|
+
MimeType=d.get("MimeType"),
|
|
192
|
+
Hashes=d.get("Hashes"),
|
|
193
|
+
ID=d.get("ID"),
|
|
194
|
+
OrigID=d.get("OrigID"),
|
|
195
|
+
IsBucket=d.get("IsBucket"),
|
|
196
|
+
Encrypted=d.get("Encrypted"),
|
|
197
|
+
EncryptedPath=d.get("EncryptedPath"),
|
|
198
|
+
Tier=d.get("Tier"),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(slots=True)
|
|
203
|
+
class PubliclinkResponse:
|
|
204
|
+
link: str
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def from_dict(d: dict[str, Any]):
|
|
208
|
+
return PubliclinkResponse(
|
|
209
|
+
link=str(d["link"]),
|
|
210
|
+
)
|
rclone_api/exceptions.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class RcloneConnectionException(Exception): ...
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RcloneProcessException(Exception):
|
|
5
|
+
def __init__(self, error: str, input: dict | None, status: int | None, path: str | None):
|
|
6
|
+
super().__init__(error)
|
|
7
|
+
self.error = error
|
|
8
|
+
self.input = input
|
|
9
|
+
self.status = status
|
|
10
|
+
self.path = path
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def from_dict(d: dict):
|
|
14
|
+
return RcloneProcessException(
|
|
15
|
+
error=d.get("error", "Unknown error"),
|
|
16
|
+
input=d.get("input", None),
|
|
17
|
+
status=d.get("status", None),
|
|
18
|
+
path=d.get("path", None),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def __str__(self):
|
|
22
|
+
return f"RcloneProcessException(status={self.status}, path='{self.path}', error='{self.error}', input={self.input})"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rclone-bin-api
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Rclone API for Python including the Rclone binaries.
|
|
5
|
+
Author-email: Michael G <me@mgineer85.de>
|
|
6
|
+
Maintainer-email: Michael G <me@mgineer85.de>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Repository, https://github.com/mgineer85/rclone-bin-api
|
|
9
|
+
Keywords: rclone
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Rclone Api for Python including Rclone binaries
|
|
19
|
+
|
|
20
|
+
This package is developed for use in the photobooth-app.
|
|
21
|
+
It serves to distribute the rclone binary via wheels so a recent version is available
|
|
22
|
+
on all platforms that are supported by the photobooth-app.
|
|
23
|
+
|
|
24
|
+
[](https://pypi.org/project/rclone-bin-api/)
|
|
25
|
+
[](https://github.com/mgineer85/rclone-bin-api/workflows/ruff.yml)
|
|
26
|
+
[](https://github.com/mgineer85/rclone-bin-api/actions/workflows/pytests.yml)
|
|
27
|
+
[](https://codecov.io/gh/mgineer85/rclone-bin-api)
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
The PyPi packages include the Rclone binaries for Linux/Windows/Mac (64bit/ARM64). To use the API, create an instance of the `api=RcloneApi()`.
|
|
32
|
+
The provided binding methods make use of [Rclones remote control capabilites](https://rclone.org/rc/), the service needs to be started prior starting file operations `api.start()`.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Get the version of the Rclone included in the distribution:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from rclone_api.api import RcloneApi
|
|
40
|
+
|
|
41
|
+
api = RcloneApi()
|
|
42
|
+
api.start()
|
|
43
|
+
|
|
44
|
+
print(api.version()) # CoreVersion(version='v1.72.1')
|
|
45
|
+
|
|
46
|
+
api.stop()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Synchonize a folder to a remote
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from rclone_api.api import RcloneApi
|
|
53
|
+
|
|
54
|
+
api = RcloneApi()
|
|
55
|
+
api.start()
|
|
56
|
+
|
|
57
|
+
api.sync("localdir/", "cloudremote:remotedir/")
|
|
58
|
+
|
|
59
|
+
api.stop()
|
|
60
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
examples/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
examples/about.py,sha256=8HE9-k-MMsn7B5T6r3QYDD-ksCSaJHT9JyF81XfdtQw,141
|
|
3
|
+
rclone_api/__init__.py,sha256=9y4k5CZDsaHL1Wx1oIiHPSWwN-f39NjSRBpDVYvgV1A,255
|
|
4
|
+
rclone_api/api.py,sha256=Xx4sOPjlEuG8yY2oTqfumQ0tPs-UbWCYeB_4Ob-BNSw,9768
|
|
5
|
+
rclone_api/dto.py,sha256=auAaG-oPL4U3rqq-gInraAj_H96NVIYkxkkEYSHEPK8,5398
|
|
6
|
+
rclone_api/exceptions.py,sha256=iM8HJqsA0DCbHX-E2VM1M8dMSmSNmjIk7bT9wv6XsqM,741
|
|
7
|
+
rclone_api/bin/rclone.exe,sha256=RJWROLLJspXX1VjiKfC3SVpa9EarD-RyuNyYKwcOWns,73190912
|
|
8
|
+
tests/test_exceptions.py,sha256=SFD5JbL9Uf6fvjWg1DfmurJGt33Mn_mI38Ym5XSdkqo,312
|
|
9
|
+
tests/test_rclone.py,sha256=I-MfsOEqyXL9FJoIFjM3c6hYE7j2BSqo8uYdLmxqJ9s,8961
|
|
10
|
+
rclone_bin_api-1.0.0.dist-info/METADATA,sha256=VWIBDCwjoPxcmxYyf8RfNmiIDgl64m8hIJzfK5F11MQ,2198
|
|
11
|
+
rclone_bin_api-1.0.0.dist-info/WHEEL,sha256=ZjXRCNaQ9YSypEK2TE0LRB0sy2OVXSszb4Sx1XjM99k,97
|
|
12
|
+
rclone_bin_api-1.0.0.dist-info/top_level.txt,sha256=2Mx55HkFxfNF4Bg_IvGfrl4dxgmeZlfEMkmU4fTmnU8,26
|
|
13
|
+
rclone_bin_api-1.0.0.dist-info/RECORD,,
|
tests/test_exceptions.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from rclone_api.exceptions import RcloneProcessException
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_rclone_process_exception_str():
|
|
5
|
+
exc = RcloneProcessException(error="boom", input={"op": True}, status=5, path="/tmp/rclone")
|
|
6
|
+
|
|
7
|
+
assert str(exc) == ("RcloneProcessException(status=5, path='/tmp/rclone', error='boom', input={'op': True})")
|
tests/test_rclone.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from rclone_api.api import RcloneApi
|
|
11
|
+
from rclone_api.exceptions import RcloneProcessException
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(name=None)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RcloneFixture:
|
|
18
|
+
client: RcloneApi
|
|
19
|
+
remote_name: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture()
|
|
23
|
+
def _rclone_fixture() -> Generator[RcloneFixture, None, None]:
|
|
24
|
+
client = RcloneApi("localhost:5573", log_file=Path("./log/rclone.log"))
|
|
25
|
+
|
|
26
|
+
client.start()
|
|
27
|
+
|
|
28
|
+
# create local remote for testing
|
|
29
|
+
remote_name = uuid4().hex
|
|
30
|
+
client.config_create(remote_name, "local", {})
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
yield RcloneFixture(client, remote_name)
|
|
34
|
+
finally:
|
|
35
|
+
client.config_delete(remote_name)
|
|
36
|
+
client.stop()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_operational():
|
|
40
|
+
ins = RcloneApi("localhost:5573")
|
|
41
|
+
assert ins.operational() is False
|
|
42
|
+
|
|
43
|
+
ins.start()
|
|
44
|
+
|
|
45
|
+
assert ins.operational() is True
|
|
46
|
+
|
|
47
|
+
ins.start() # ensure second start doesn't break everything...
|
|
48
|
+
|
|
49
|
+
ins.stop()
|
|
50
|
+
|
|
51
|
+
assert ins.operational() is False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_transfers_status_request(tmp_path: Path):
|
|
55
|
+
ins = RcloneApi("localhost:5573", bwlimit="5M")
|
|
56
|
+
ins.start()
|
|
57
|
+
|
|
58
|
+
dummy_local = tmp_path / "in" / "file1.txt"
|
|
59
|
+
dummy_local.parent.mkdir(parents=True)
|
|
60
|
+
dummy_local.write_bytes(b"0" * 1024 * 1024)
|
|
61
|
+
|
|
62
|
+
dummy_local_remote = Path(tmp_path / "out" / "file1.txt").absolute()
|
|
63
|
+
|
|
64
|
+
assert len(ins.core_stats().transferring) == 0
|
|
65
|
+
|
|
66
|
+
# queue the copy
|
|
67
|
+
job = ins.copyfile_async(str(dummy_local.parent), dummy_local.name, str(dummy_local_remote.parent), dummy_local_remote.name)
|
|
68
|
+
|
|
69
|
+
while len(ins.core_stats().transferring) == 0:
|
|
70
|
+
# wait until actually transferring
|
|
71
|
+
time.sleep(0.1)
|
|
72
|
+
|
|
73
|
+
# ensure the filename is part of the transfer queue
|
|
74
|
+
assert dummy_local.name in ins.core_stats().transferring[0].name
|
|
75
|
+
|
|
76
|
+
ins.wait_for_jobs([job.jobid])
|
|
77
|
+
|
|
78
|
+
final_status = ins.job_status(jobid=job.jobid)
|
|
79
|
+
|
|
80
|
+
assert final_status.success
|
|
81
|
+
|
|
82
|
+
ins.stop()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_version(_rclone_fixture: RcloneFixture):
|
|
86
|
+
version = _rclone_fixture.client.version()
|
|
87
|
+
|
|
88
|
+
assert _rclone_fixture.client.version()
|
|
89
|
+
|
|
90
|
+
logger.info(version)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_core_stats(_rclone_fixture: RcloneFixture):
|
|
94
|
+
assert _rclone_fixture.client.core_stats()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_create_list_delete_remotes(_rclone_fixture: RcloneFixture):
|
|
98
|
+
name = uuid4().hex
|
|
99
|
+
|
|
100
|
+
_rclone_fixture.client.config_create(name, "local", {})
|
|
101
|
+
assert name in _rclone_fixture.client.config_listremotes().remotes
|
|
102
|
+
|
|
103
|
+
_rclone_fixture.client.config_delete(name)
|
|
104
|
+
assert name not in _rclone_fixture.client.config_listremotes().remotes
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_deletefile(_rclone_fixture: RcloneFixture, tmp_path: Path):
|
|
108
|
+
client = _rclone_fixture.client
|
|
109
|
+
remote = _rclone_fixture.remote_name
|
|
110
|
+
|
|
111
|
+
dummy_local = tmp_path / "file1.txt"
|
|
112
|
+
dummy_local.touch()
|
|
113
|
+
|
|
114
|
+
dummy_remote = Path(tmp_path / "file1.txt").relative_to(Path.cwd())
|
|
115
|
+
|
|
116
|
+
# Perform
|
|
117
|
+
client.deletefile(f"{remote}:", dummy_remote.as_posix())
|
|
118
|
+
|
|
119
|
+
# Assertions
|
|
120
|
+
listing = client.ls(f"{remote}:", dummy_remote.parent.as_posix())
|
|
121
|
+
|
|
122
|
+
assert not any(entry.Name == "file1.txt" for entry in listing)
|
|
123
|
+
|
|
124
|
+
assert not dummy_local.exists()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_copyfile(_rclone_fixture: RcloneFixture, tmp_path: Path):
|
|
128
|
+
client = _rclone_fixture.client
|
|
129
|
+
remote = _rclone_fixture.remote_name
|
|
130
|
+
|
|
131
|
+
dummy_local = tmp_path / "in" / "file1.txt"
|
|
132
|
+
dummy_local.parent.mkdir(parents=True)
|
|
133
|
+
dummy_local.touch()
|
|
134
|
+
|
|
135
|
+
dummy_remote = Path(tmp_path / "out" / "file1.txt").relative_to(Path.cwd())
|
|
136
|
+
|
|
137
|
+
# Perform
|
|
138
|
+
client.copyfile(str(dummy_local.parent), dummy_local.name, f"{remote}:", dummy_remote.as_posix())
|
|
139
|
+
|
|
140
|
+
# Assertions
|
|
141
|
+
listing = client.ls(f"{remote}:", dummy_remote.parent.as_posix())
|
|
142
|
+
|
|
143
|
+
assert any(entry.Name == "file1.txt" for entry in listing)
|
|
144
|
+
|
|
145
|
+
assert dummy_remote.is_file()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_copyfile_async(_rclone_fixture: RcloneFixture, tmp_path: Path):
|
|
149
|
+
client = _rclone_fixture.client
|
|
150
|
+
remote = _rclone_fixture.remote_name
|
|
151
|
+
|
|
152
|
+
dummy_local = tmp_path / "in" / "file1.txt"
|
|
153
|
+
dummy_local.parent.mkdir(parents=True)
|
|
154
|
+
dummy_local.touch()
|
|
155
|
+
|
|
156
|
+
dummy_remote = Path(tmp_path / "out" / "file1.txt").relative_to(Path.cwd())
|
|
157
|
+
|
|
158
|
+
# Perform the copy
|
|
159
|
+
job = client.copyfile_async(str(dummy_local.parent), dummy_local.name, f"{remote}:", dummy_remote.as_posix())
|
|
160
|
+
client.wait_for_jobs([job.jobid])
|
|
161
|
+
|
|
162
|
+
final_status = client.job_status(jobid=job.jobid)
|
|
163
|
+
final_joblist = client.job_list()
|
|
164
|
+
|
|
165
|
+
# --- Assertions ---
|
|
166
|
+
assert final_status.success
|
|
167
|
+
|
|
168
|
+
assert job.jobid in final_joblist.jobids
|
|
169
|
+
assert job.jobid in final_joblist.finishedIds
|
|
170
|
+
|
|
171
|
+
listing = client.ls(f"{remote}:", dummy_remote.parent.as_posix())
|
|
172
|
+
assert any(entry.Name == "file1.txt" for entry in listing)
|
|
173
|
+
|
|
174
|
+
assert dummy_remote.is_file()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_copy(_rclone_fixture: RcloneFixture, tmp_path: Path):
|
|
178
|
+
client = _rclone_fixture.client
|
|
179
|
+
remote = _rclone_fixture.remote_name
|
|
180
|
+
|
|
181
|
+
dummy_local = tmp_path / "in" / "file1.txt"
|
|
182
|
+
dummy_local.parent.mkdir(parents=True)
|
|
183
|
+
dummy_local.touch()
|
|
184
|
+
|
|
185
|
+
dummy_remote = Path(tmp_path / "out").relative_to(Path.cwd())
|
|
186
|
+
|
|
187
|
+
# Perform
|
|
188
|
+
client.copy(str(dummy_local.parent), f"{remote}:{dummy_remote.as_posix()}")
|
|
189
|
+
|
|
190
|
+
# Assertions
|
|
191
|
+
listing = client.ls(f"{remote}:", dummy_remote.as_posix())
|
|
192
|
+
|
|
193
|
+
assert any(entry.Name == "file1.txt" for entry in listing)
|
|
194
|
+
|
|
195
|
+
assert Path(dummy_remote, "file1.txt").is_file()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_copy_async(_rclone_fixture: RcloneFixture, tmp_path: Path):
|
|
199
|
+
client = _rclone_fixture.client
|
|
200
|
+
remote = _rclone_fixture.remote_name
|
|
201
|
+
|
|
202
|
+
dummy_local = tmp_path / "in" / "file1.txt"
|
|
203
|
+
dummy_local.parent.mkdir(parents=True)
|
|
204
|
+
dummy_local.touch()
|
|
205
|
+
|
|
206
|
+
dummy_remote = Path(tmp_path / "out").relative_to(Path.cwd())
|
|
207
|
+
|
|
208
|
+
# Perform
|
|
209
|
+
job = client.copy_async(str(dummy_local.parent), f"{remote}:{dummy_remote.as_posix()}")
|
|
210
|
+
client.wait_for_jobs([job.jobid])
|
|
211
|
+
|
|
212
|
+
final_status = client.job_status(jobid=job.jobid)
|
|
213
|
+
final_joblist = client.job_list()
|
|
214
|
+
|
|
215
|
+
# --- Assertions ---
|
|
216
|
+
assert final_status.success
|
|
217
|
+
|
|
218
|
+
assert job.jobid in final_joblist.jobids
|
|
219
|
+
assert job.jobid in final_joblist.finishedIds
|
|
220
|
+
|
|
221
|
+
listing = client.ls(f"{remote}:", dummy_remote.as_posix())
|
|
222
|
+
assert any(entry.Name == "file1.txt" for entry in listing)
|
|
223
|
+
|
|
224
|
+
assert Path(dummy_remote, "file1.txt").is_file()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_copy_localonly(_rclone_fixture: RcloneFixture, tmp_path: Path):
|
|
228
|
+
client = _rclone_fixture.client
|
|
229
|
+
# remote = _rclone_fixture.remote_name
|
|
230
|
+
|
|
231
|
+
dummy_local = tmp_path / "in" / "file1.txt"
|
|
232
|
+
dummy_local.parent.mkdir(parents=True)
|
|
233
|
+
dummy_local.touch()
|
|
234
|
+
|
|
235
|
+
dummy_local_remote = Path(tmp_path / "out").absolute()
|
|
236
|
+
|
|
237
|
+
# Perform
|
|
238
|
+
client.copy(str(dummy_local.parent), str(dummy_local_remote))
|
|
239
|
+
|
|
240
|
+
# Assertions
|
|
241
|
+
listing1 = client.ls(str(dummy_local_remote), "/")
|
|
242
|
+
listing2 = client.ls(str(dummy_local_remote), "")
|
|
243
|
+
|
|
244
|
+
assert any(entry.Name == "file1.txt" for entry in listing1)
|
|
245
|
+
assert any(entry.Name == "file1.txt" for entry in listing2)
|
|
246
|
+
|
|
247
|
+
assert Path(dummy_local_remote, "file1.txt").is_file()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_sync(_rclone_fixture: RcloneFixture, tmp_path: Path):
|
|
251
|
+
client = _rclone_fixture.client
|
|
252
|
+
remote = _rclone_fixture.remote_name
|
|
253
|
+
|
|
254
|
+
dummy_local = tmp_path / "in" / "file1.txt"
|
|
255
|
+
dummy_local.parent.mkdir(parents=True)
|
|
256
|
+
dummy_local.touch()
|
|
257
|
+
|
|
258
|
+
dummy_remote = Path(tmp_path / "out").relative_to(Path.cwd())
|
|
259
|
+
|
|
260
|
+
# Perform
|
|
261
|
+
client.sync(str(dummy_local.parent), f"{remote}:{dummy_remote.as_posix()}")
|
|
262
|
+
|
|
263
|
+
# Assertions
|
|
264
|
+
listing = client.ls(f"{remote}:", dummy_remote.as_posix())
|
|
265
|
+
|
|
266
|
+
assert any(entry.Name == "file1.txt" for entry in listing)
|
|
267
|
+
|
|
268
|
+
assert Path(dummy_remote, "file1.txt").is_file()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_sync_async(_rclone_fixture: RcloneFixture, tmp_path: Path):
|
|
272
|
+
client = _rclone_fixture.client
|
|
273
|
+
remote = _rclone_fixture.remote_name
|
|
274
|
+
|
|
275
|
+
dummy_local = tmp_path / "in" / "file1.txt"
|
|
276
|
+
dummy_local.parent.mkdir(parents=True)
|
|
277
|
+
dummy_local.touch()
|
|
278
|
+
|
|
279
|
+
dummy_remote = Path(tmp_path / "out").relative_to(Path.cwd())
|
|
280
|
+
|
|
281
|
+
# Perform
|
|
282
|
+
job = client.sync_async(str(dummy_local.parent), f"{remote}:{dummy_remote.as_posix()}")
|
|
283
|
+
client.wait_for_jobs([job.jobid])
|
|
284
|
+
|
|
285
|
+
final_status = client.job_status(jobid=job.jobid)
|
|
286
|
+
final_joblist = client.job_list()
|
|
287
|
+
|
|
288
|
+
# --- Assertions ---
|
|
289
|
+
assert final_status.success
|
|
290
|
+
|
|
291
|
+
assert job.jobid in final_joblist.jobids
|
|
292
|
+
assert job.jobid in final_joblist.finishedIds
|
|
293
|
+
|
|
294
|
+
listing = client.ls(f"{remote}:", dummy_remote.as_posix())
|
|
295
|
+
assert any(entry.Name == "file1.txt" for entry in listing)
|
|
296
|
+
|
|
297
|
+
assert Path(dummy_remote, "file1.txt").is_file()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_publiclink(_rclone_fixture: RcloneFixture, tmp_path: Path):
|
|
301
|
+
client = _rclone_fixture.client
|
|
302
|
+
remote = _rclone_fixture.remote_name
|
|
303
|
+
|
|
304
|
+
dummy_local = tmp_path / "file1.txt"
|
|
305
|
+
dummy_local.touch()
|
|
306
|
+
|
|
307
|
+
dummy_remote = Path(tmp_path / "file1.txt").relative_to(Path.cwd())
|
|
308
|
+
|
|
309
|
+
# Perform
|
|
310
|
+
with pytest.raises(RcloneProcessException):
|
|
311
|
+
client.publiclink(f"{remote}:", dummy_remote.as_posix())
|