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.
- rclone_bin_api-1.0.0/PKG-INFO +60 -0
- rclone_bin_api-1.0.0/README.md +43 -0
- rclone_bin_api-1.0.0/pyproject.toml +111 -0
- rclone_bin_api-1.0.0/setup.cfg +4 -0
- rclone_bin_api-1.0.0/setup.py +155 -0
- rclone_bin_api-1.0.0/src/examples/__init__.py +0 -0
- rclone_bin_api-1.0.0/src/examples/about.py +9 -0
- rclone_bin_api-1.0.0/src/rclone_api/__init__.py +11 -0
- rclone_bin_api-1.0.0/src/rclone_api/api.py +238 -0
- rclone_bin_api-1.0.0/src/rclone_api/dto.py +210 -0
- rclone_bin_api-1.0.0/src/rclone_api/exceptions.py +22 -0
- rclone_bin_api-1.0.0/src/rclone_bin_api.egg-info/PKG-INFO +60 -0
- rclone_bin_api-1.0.0/src/rclone_bin_api.egg-info/SOURCES.txt +15 -0
- rclone_bin_api-1.0.0/src/rclone_bin_api.egg-info/dependency_links.txt +1 -0
- rclone_bin_api-1.0.0/src/rclone_bin_api.egg-info/top_level.txt +3 -0
- rclone_bin_api-1.0.0/src/tests/test_exceptions.py +7 -0
- rclone_bin_api-1.0.0/src/tests/test_rclone.py +311 -0
|
@@ -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,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
|
+
[](https://pypi.org/project/rclone-bin-api/)
|
|
8
|
+
[](https://github.com/mgineer85/rclone-bin-api/workflows/ruff.yml)
|
|
9
|
+
[](https://github.com/mgineer85/rclone-bin-api/actions/workflows/pytests.yml)
|
|
10
|
+
[](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,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,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
|
+
[](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,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 @@
|
|
|
1
|
+
|
|
@@ -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())
|