rclone-bin-api 1.0.0__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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/rclone-bin-api)](https://pypi.org/project/rclone-bin-api/)
25
+ [![ruff](https://github.com/mgineer85/rclone-bin-api/actions/workflows/ruff.yml/badge.svg)](https://github.com/mgineer85/rclone-bin-api/workflows/ruff.yml)
26
+ [![pytest](https://github.com/mgineer85/rclone-bin-api/actions/workflows/pytests.yml/badge.svg)](https://github.com/mgineer85/rclone-bin-api/actions/workflows/pytests.yml)
27
+ [![codecov](https://codecov.io/gh/mgineer85/rclone-bin-api/graph/badge.svg?token=87aLWw2gIT)](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,43 @@
1
+ # Rclone Api for Python including Rclone binaries
2
+
3
+ This package is developed for use in the photobooth-app.
4
+ It serves to distribute the rclone binary via wheels so a recent version is available
5
+ on all platforms that are supported by the photobooth-app.
6
+
7
+ [![PyPI](https://img.shields.io/pypi/v/rclone-bin-api)](https://pypi.org/project/rclone-bin-api/)
8
+ [![ruff](https://github.com/mgineer85/rclone-bin-api/actions/workflows/ruff.yml/badge.svg)](https://github.com/mgineer85/rclone-bin-api/workflows/ruff.yml)
9
+ [![pytest](https://github.com/mgineer85/rclone-bin-api/actions/workflows/pytests.yml/badge.svg)](https://github.com/mgineer85/rclone-bin-api/actions/workflows/pytests.yml)
10
+ [![codecov](https://codecov.io/gh/mgineer85/rclone-bin-api/graph/badge.svg?token=87aLWw2gIT)](https://codecov.io/gh/mgineer85/rclone-bin-api)
11
+
12
+ ## How it works
13
+
14
+ The PyPi packages include the Rclone binaries for Linux/Windows/Mac (64bit/ARM64). To use the API, create an instance of the `api=RcloneApi()`.
15
+ 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()`.
16
+
17
+ ## Usage
18
+
19
+ Get the version of the Rclone included in the distribution:
20
+
21
+ ```python
22
+ from rclone_api.api import RcloneApi
23
+
24
+ api = RcloneApi()
25
+ api.start()
26
+
27
+ print(api.version()) # CoreVersion(version='v1.72.1')
28
+
29
+ api.stop()
30
+ ```
31
+
32
+ Synchonize a folder to a remote
33
+
34
+ ```python
35
+ from rclone_api.api import RcloneApi
36
+
37
+ api = RcloneApi()
38
+ api.start()
39
+
40
+ api.sync("localdir/", "cloudremote:remotedir/")
41
+
42
+ api.stop()
43
+ ```
@@ -0,0 +1,111 @@
1
+
2
+ [project]
3
+ name = "rclone-bin-api"
4
+ description = "Rclone API for Python including the Rclone binaries."
5
+ version = "1.0.0"
6
+ authors = [{ name = "Michael G", email = "me@mgineer85.de" }]
7
+ maintainers = [{ name = "Michael G", email = "me@mgineer85.de" }]
8
+ requires-python = ">=3.11"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
17
+ ]
18
+ keywords = ["rclone"]
19
+
20
+
21
+ dependencies = []
22
+
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/mgineer85/rclone-bin-api"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "ruff",
30
+ "pytest",
31
+ "pytest-cov",
32
+ "coverage[toml]",
33
+ "pyright",
34
+ "poethepoet",
35
+ "setuptools",
36
+ "wheel",
37
+ ]
38
+
39
+
40
+ # uv does not yet support [tool.pdm.scripts], so we skip this for now... https://github.com/astral-sh/uv/issues/5903
41
+ [tool.poe.tasks]
42
+ lint = [
43
+ { cmd = "uv run pyright" },
44
+ { cmd = "uv run ruff check" },
45
+ { cmd = "uv run ruff format --check" },
46
+ ]
47
+ format = [{ cmd = "uv run ruff check --fix" }, { cmd = "uv run ruff format" }]
48
+ test = [
49
+ { cmd = "pytest --basetemp=./tests_tmp/ -v ./src/tests --cov-report=term --cov-report=xml:coverage.xml --cov --durations=10" },
50
+ ]
51
+ build = [
52
+ { cmd = "uv build --wheel", env = { "RCLONE_BUILD_PLATFORM" = "win_amd64" } },
53
+ { cmd = "uv build --wheel", env = { "RCLONE_BUILD_PLATFORM" = "win_arm64" } },
54
+ { cmd = "uv build --wheel", env = { "RCLONE_BUILD_PLATFORM" = "manylinux_2_28_x86_64" } },
55
+ { cmd = "uv build --wheel", env = { "RCLONE_BUILD_PLATFORM" = "manylinux_2_28_aarch64" } },
56
+ { cmd = "uv build --wheel", env = { "RCLONE_BUILD_PLATFORM" = "macosx_10_9_x86_64" } },
57
+ { cmd = "uv build --wheel", env = { "RCLONE_BUILD_PLATFORM" = "macosx_11_0_arm64" } },
58
+ { cmd = "uv build --sdist" },
59
+ ]
60
+
61
+
62
+ [tool.pyright]
63
+ venvPath = "."
64
+ venv = ".venv"
65
+ ignore = ["./experimental/", "./vendor/"]
66
+ typeCheckingMode = "standard"
67
+
68
+
69
+ [tool.ruff]
70
+ line-length = 150
71
+
72
+ [tool.ruff.lint]
73
+ select = [
74
+ "E", # pycodestyle
75
+ "W", # pycodestyle
76
+ "F", # pyflakes
77
+ "B", # bugbear
78
+ "UP", # pyupgrade
79
+ "I", # isort
80
+ #"D", # pydocstyle # add later
81
+ ]
82
+ ignore = [
83
+ "B008", #used for DI injection
84
+ ]
85
+
86
+ [tool.ruff.lint.pydocstyle]
87
+ convention = "google"
88
+
89
+
90
+ [tool.pytest.ini_options]
91
+ testpaths = ["tests"]
92
+ log_cli = true
93
+ log_cli_level = "DEBUG"
94
+ log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
95
+ log_cli_date_format = "%Y-%m-%d %H:%M:%S"
96
+
97
+ [tool.coverage.run]
98
+ # disable couldnt-parse: https://github.com/nedbat/coveragepy/issues/1392
99
+ disable_warnings = ["couldnt-parse"]
100
+ omit = ["test_*.py", "./tests/*"]
101
+ parallel = true
102
+ concurrency = ["thread", "multiprocessing"]
103
+
104
+
105
+ [build-system]
106
+ requires = ["setuptools", "wheel", "requests"]
107
+ build-backend = "setuptools.build_meta"
108
+
109
+
110
+ [tool.setuptools]
111
+ package-dir = { "" = "src/" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,155 @@
1
+ import hashlib
2
+ import io
3
+ import os
4
+ import pathlib
5
+ import shutil
6
+ import zipfile
7
+
8
+ import requests
9
+ from packaging.tags import sys_tags
10
+ from setuptools import setup
11
+ from setuptools.command.build_py import build_py
12
+ from wheel.bdist_wheel import bdist_wheel
13
+
14
+ rclone_version = os.environ.get("RCLONE_VERSION", "1.72.1")
15
+ rclone_build_platform = os.environ.get("RCLONE_BUILD_PLATFORM", None)
16
+
17
+ PLATFORMS = {
18
+ # (pypa platform tag) -> (rclone variant)
19
+ "win_amd64": ("windows", "amd64"),
20
+ "win_arm64": ("windows", "arm64"),
21
+ "manylinux_2_28_x86_64": ("linux", "amd64"),
22
+ "manylinux_2_28_aarch64": ("linux", "arm64"),
23
+ "macosx_10_9_x86_64": ("osx", "amd64"),
24
+ "macosx_11_0_arm64": ("osx", "arm64"),
25
+ }
26
+
27
+
28
+ def get_rclone_variant(platform_tag: str):
29
+ try:
30
+ return PLATFORMS[platform_tag]
31
+ except KeyError:
32
+ raise RuntimeError(f"Unsupported platform tag '{platform_tag}'. Supported: {sorted(PLATFORMS)}") from None
33
+
34
+
35
+ def detect_supported_platform_tag():
36
+ """Return the first sys_tags() platform that we explicitly support."""
37
+ for tag in sys_tags():
38
+ if tag.platform in PLATFORMS:
39
+ return tag.platform
40
+
41
+ raise RuntimeError(f"No supported platform tag found for this interpreter. Supported: {sorted(PLATFORMS)}")
42
+
43
+
44
+ def rclone_download(system: str, arch: str, rclone_version: str, dest: pathlib.Path) -> pathlib.Path:
45
+ shutil.rmtree(dest, ignore_errors=True) # ensure clean bin dir, because build dir might be reused.
46
+ dest.mkdir(parents=True, exist_ok=True)
47
+
48
+ base_url = f"https://downloads.rclone.org/v{rclone_version}"
49
+ filename = f"rclone-v{rclone_version}-{system}-{arch}.zip"
50
+ url = f"{base_url}/{filename}"
51
+ sums_url = f"{base_url}/SHA256SUMS"
52
+
53
+ print(f"Downloading rclone from {url}")
54
+
55
+ req_session = requests.Session()
56
+ resp = req_session.get(url)
57
+ resp.raise_for_status()
58
+ assert resp.content
59
+ zip_bytes = resp.content
60
+
61
+ try:
62
+ hash_valid = None
63
+ resp = req_session.get(sums_url)
64
+ resp.raise_for_status()
65
+ assert resp.text
66
+ sums_text = resp.text
67
+
68
+ for line in sums_text.splitlines():
69
+ parts = line.strip().split()
70
+ if len(parts) == 2 and parts[1] == filename:
71
+ hash_valid = parts[0]
72
+ break
73
+
74
+ if not hash_valid:
75
+ raise RuntimeError(f"{filename} not found in SHA256SUMS")
76
+
77
+ hash = hashlib.sha256(zip_bytes).hexdigest()
78
+ if hash != hash_valid.lower():
79
+ raise RuntimeError(f"rclone checksum mismatch: expected {hash_valid}, got {hash}")
80
+
81
+ except Exception as e:
82
+ raise RuntimeError(f"Failed to verify rclone checksum: {e}") from e
83
+ else:
84
+ print("download verified successfully")
85
+
86
+ bin_name = "rclone.exe" if system == "windows" else "rclone"
87
+ bin_path = dest / bin_name
88
+
89
+ bin_path.unlink(missing_ok=True)
90
+
91
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
92
+ for member in zf.filelist:
93
+ if pathlib.Path(member.filename).name == bin_name:
94
+ data = zf.read(member)
95
+ bin_path.write_bytes(data)
96
+ break
97
+
98
+ assert bin_path.is_file(), f"Binary '{bin_name}' not found inside rclone archive {filename}"
99
+
100
+ print(f"unpacked rclone to {bin_path}")
101
+
102
+ if system != "windows":
103
+ bin_path.chmod(0o755)
104
+
105
+ print("unpacking done")
106
+
107
+ return bin_path
108
+
109
+
110
+ class BuildWithRclone(build_py):
111
+ def initialize_options(self):
112
+ super().initialize_options()
113
+ self.platform_tag = rclone_build_platform or detect_supported_platform_tag()
114
+
115
+ def run(self):
116
+ # 1. Run normal build first (creates build_lib)
117
+ super().run()
118
+
119
+ rclone_sys, rclone_arch = get_rclone_variant(self.platform_tag)
120
+
121
+ # 2. Determine where to place the binary inside the wheel
122
+ if self.editable_mode:
123
+ print("editable installation!")
124
+ pkg_dir = pathlib.Path(self.get_package_dir("rclone_api")) / "bin"
125
+
126
+ bin_name = "rclone.exe" if rclone_sys == "windows" else "rclone"
127
+ if pkg_dir.joinpath(bin_name).exists():
128
+ print("not downloading rclone as it already exists.")
129
+ return
130
+ else:
131
+ pkg_dir = pathlib.Path(self.build_lib) / "rclone_api/bin"
132
+
133
+ print(pkg_dir)
134
+
135
+ # 3. Download rclone
136
+
137
+ print(f"Downloading binary for {rclone_sys}_{rclone_arch}")
138
+ rclone_download(rclone_sys, rclone_arch, rclone_version, pkg_dir)
139
+
140
+
141
+ class PlatformWheel(bdist_wheel):
142
+ def initialize_options(self):
143
+ super().initialize_options()
144
+ self.platform_tag = rclone_build_platform or detect_supported_platform_tag()
145
+
146
+ def get_tag(self):
147
+ return ("py3", "none", self.platform_tag)
148
+
149
+
150
+ setup(
151
+ cmdclass={
152
+ "build_py": BuildWithRclone,
153
+ "bdist_wheel": PlatformWheel,
154
+ },
155
+ )
File without changes
@@ -0,0 +1,9 @@
1
+ from rclone_api.api import RcloneApi
2
+
3
+ if __name__ == "__main__":
4
+ rc = RcloneApi()
5
+ rc.start()
6
+
7
+ print(rc.version())
8
+
9
+ rc.stop()
@@ -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()
@@ -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
@@ -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
+ )
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/rclone-bin-api)](https://pypi.org/project/rclone-bin-api/)
25
+ [![ruff](https://github.com/mgineer85/rclone-bin-api/actions/workflows/ruff.yml/badge.svg)](https://github.com/mgineer85/rclone-bin-api/workflows/ruff.yml)
26
+ [![pytest](https://github.com/mgineer85/rclone-bin-api/actions/workflows/pytests.yml/badge.svg)](https://github.com/mgineer85/rclone-bin-api/actions/workflows/pytests.yml)
27
+ [![codecov](https://codecov.io/gh/mgineer85/rclone-bin-api/graph/badge.svg?token=87aLWw2gIT)](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,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ src/examples/__init__.py
5
+ src/examples/about.py
6
+ src/rclone_api/__init__.py
7
+ src/rclone_api/api.py
8
+ src/rclone_api/dto.py
9
+ src/rclone_api/exceptions.py
10
+ src/rclone_bin_api.egg-info/PKG-INFO
11
+ src/rclone_bin_api.egg-info/SOURCES.txt
12
+ src/rclone_bin_api.egg-info/dependency_links.txt
13
+ src/rclone_bin_api.egg-info/top_level.txt
14
+ src/tests/test_exceptions.py
15
+ src/tests/test_rclone.py
@@ -0,0 +1,3 @@
1
+ examples
2
+ rclone_api
3
+ tests
@@ -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})")
@@ -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())