rclone-bin-api 1.0.4__tar.gz → 1.0.6__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.4 → rclone_bin_api-1.0.6}/PKG-INFO +2 -2
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/README.md +1 -1
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/pyproject.toml +6 -4
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/setup.py +1 -1
- rclone_bin_api-1.0.6/src/examples/about.py +18 -0
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/rclone_api/api.py +85 -60
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/rclone_bin_api.egg-info/PKG-INFO +2 -2
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/tests/test_rclone.py +30 -4
- rclone_bin_api-1.0.4/src/examples/about.py +0 -9
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/setup.cfg +0 -0
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/examples/__init__.py +0 -0
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/rclone_api/__init__.py +0 -0
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/rclone_api/dto.py +0 -0
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/rclone_api/exceptions.py +0 -0
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/rclone_bin_api.egg-info/SOURCES.txt +0 -0
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/rclone_bin_api.egg-info/dependency_links.txt +0 -0
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/rclone_bin_api.egg-info/top_level.txt +0 -0
- {rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/tests/test_exceptions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rclone-bin-api
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.6
|
|
4
4
|
Summary: Rclone API for Python including the Rclone binaries.
|
|
5
5
|
Author-email: Michael G <me@mgineer85.de>
|
|
6
6
|
Maintainer-email: Michael G <me@mgineer85.de>
|
|
@@ -41,7 +41,7 @@ from rclone_api.api import RcloneApi
|
|
|
41
41
|
api = RcloneApi()
|
|
42
42
|
api.start()
|
|
43
43
|
|
|
44
|
-
print(api.version())
|
|
44
|
+
print(api.version()) # CoreVersion(version='v1.72.1')
|
|
45
45
|
|
|
46
46
|
api.stop()
|
|
47
47
|
```
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
[project]
|
|
3
3
|
name = "rclone-bin-api"
|
|
4
4
|
description = "Rclone API for Python including the Rclone binaries."
|
|
5
|
-
version = "1.0.
|
|
5
|
+
version = "1.0.6"
|
|
6
6
|
authors = [{ name = "Michael G", email = "me@mgineer85.de" }]
|
|
7
7
|
maintainers = [{ name = "Michael G", email = "me@mgineer85.de" }]
|
|
8
8
|
requires-python = ">=3.11"
|
|
@@ -30,17 +30,19 @@ dev = [
|
|
|
30
30
|
"pytest",
|
|
31
31
|
"pytest-cov",
|
|
32
32
|
"coverage[toml]",
|
|
33
|
-
"
|
|
33
|
+
"basedpyright",
|
|
34
34
|
"poethepoet",
|
|
35
35
|
"setuptools",
|
|
36
36
|
"wheel",
|
|
37
|
+
"requests",
|
|
38
|
+
"psutil",
|
|
37
39
|
]
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
# uv does not yet support [tool.pdm.scripts], so we skip this for now... https://github.com/astral-sh/uv/issues/5903
|
|
41
43
|
[tool.poe.tasks]
|
|
42
44
|
lint = [
|
|
43
|
-
{ cmd = "uv run
|
|
45
|
+
{ cmd = "uv run basedpyright" },
|
|
44
46
|
{ cmd = "uv run ruff check" },
|
|
45
47
|
{ cmd = "uv run ruff format --check" },
|
|
46
48
|
]
|
|
@@ -59,7 +61,7 @@ build = [
|
|
|
59
61
|
]
|
|
60
62
|
|
|
61
63
|
|
|
62
|
-
[tool.
|
|
64
|
+
[tool.basedpyright]
|
|
63
65
|
venvPath = "."
|
|
64
66
|
venv = ".venv"
|
|
65
67
|
ignore = ["./experimental/", "./vendor/"]
|
|
@@ -11,7 +11,7 @@ from setuptools import setup
|
|
|
11
11
|
from setuptools.command.build_py import build_py
|
|
12
12
|
from wheel.bdist_wheel import bdist_wheel
|
|
13
13
|
|
|
14
|
-
rclone_version = os.environ.get("RCLONE_VERSION", "1.
|
|
14
|
+
rclone_version = os.environ.get("RCLONE_VERSION", "1.75.0")
|
|
15
15
|
rclone_build_platform = os.environ.get("RCLONE_BUILD_PLATFORM", None)
|
|
16
16
|
|
|
17
17
|
PLATFORMS = {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from rclone_api.api import RcloneApi
|
|
5
|
+
|
|
6
|
+
if __name__ == "__main__":
|
|
7
|
+
logging.basicConfig(
|
|
8
|
+
stream=sys.stdout,
|
|
9
|
+
level=logging.DEBUG,
|
|
10
|
+
format="[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
rc = RcloneApi()
|
|
14
|
+
rc.start()
|
|
15
|
+
|
|
16
|
+
print(rc.version())
|
|
17
|
+
|
|
18
|
+
rc.stop()
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import atexit
|
|
2
2
|
import json
|
|
3
|
-
import
|
|
4
|
-
import signal
|
|
3
|
+
import logging
|
|
5
4
|
import subprocess
|
|
5
|
+
import threading
|
|
6
6
|
import time
|
|
7
7
|
import urllib.error
|
|
8
8
|
import urllib.request
|
|
@@ -13,11 +13,14 @@ from . import BINARY_PATH
|
|
|
13
13
|
from .dto import AsyncJobResponse, ConfigListremotes, CoreStats, CoreVersion, JobList, JobStatus, LsJsonEntry, PubliclinkResponse
|
|
14
14
|
from .exceptions import RcloneConnectionException, RcloneProcessException, RclonePublicLinkNotSupportedException
|
|
15
15
|
|
|
16
|
+
logger = logging.getLogger(name="rclone_api")
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
class RcloneApi:
|
|
18
20
|
def __init__(
|
|
19
21
|
self,
|
|
20
|
-
bind="
|
|
22
|
+
bind="127.0.0.1:5572", # bind to IPv4 127.0.0.1 until https://github.com/rclone/rclone-web/issues/71 is resolved
|
|
23
|
+
bind_gui="127.0.0.1:5573", # bind to IPv4 127.0.0.1 until https://github.com/rclone/rclone-web/issues/71 is resolved
|
|
21
24
|
log_file: Path | None = None,
|
|
22
25
|
log_level: Literal["DEBUG", "INFO", "NOTICE", "ERROR"] = "NOTICE",
|
|
23
26
|
transfers: int = 4,
|
|
@@ -27,6 +30,7 @@ class RcloneApi:
|
|
|
27
30
|
config_file: Path | None = None,
|
|
28
31
|
):
|
|
29
32
|
self.__bind_addr = bind
|
|
33
|
+
self.__bind_addr_gui = bind_gui
|
|
30
34
|
self.__log_file = log_file
|
|
31
35
|
self.__log_level = log_level
|
|
32
36
|
self.__transfers = transfers
|
|
@@ -38,78 +42,99 @@ class RcloneApi:
|
|
|
38
42
|
self.__connect_addr = f"http://{bind}"
|
|
39
43
|
self.__process = None
|
|
40
44
|
self.__rclone_bin = BINARY_PATH
|
|
45
|
+
self.__startstop_lock = threading.Lock()
|
|
41
46
|
|
|
42
|
-
atexit.register(self.
|
|
47
|
+
atexit.register(self.stop)
|
|
43
48
|
|
|
44
49
|
# -------------------------
|
|
45
50
|
# Lifecycle
|
|
46
51
|
# -------------------------
|
|
47
52
|
def start(self, startup_timeout: float | None = 5):
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
53
|
+
with self.__startstop_lock:
|
|
54
|
+
if self.__process:
|
|
55
|
+
logger.info("RcloneApi already started a rclone instance - skipping.")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if self.__log_file:
|
|
59
|
+
logger.debug(f"rclone logging to file {self.__log_file}, setting log level {self.__log_level}")
|
|
60
|
+
self.__log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
# since rclone 1.74.0 a new built-in webgui is avail.
|
|
63
|
+
# start using gui to enable the gui, otherwise only rcd which just exposes the api
|
|
64
|
+
if self.__enable_webui:
|
|
65
|
+
logger.info("starting rclone with gui enabled")
|
|
66
|
+
rc_cmd = "gui"
|
|
67
|
+
rc_cmd_args = [
|
|
68
|
+
"--rc-no-auth",
|
|
69
|
+
"--no-open-browser",
|
|
70
|
+
f"--api-addr={self.__bind_addr}",
|
|
71
|
+
f"--addr={self.__bind_addr_gui}",
|
|
72
|
+
]
|
|
73
|
+
else:
|
|
74
|
+
logger.info("starting rclone with remote command daemon (api only, no gui)")
|
|
75
|
+
rc_cmd = "rcd"
|
|
76
|
+
rc_cmd_args = [
|
|
77
|
+
"--rc-no-auth",
|
|
78
|
+
f"--rc-addr={self.__bind_addr}",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
self.__process = subprocess.Popen(
|
|
82
|
+
[
|
|
83
|
+
str(self.__rclone_bin),
|
|
84
|
+
rc_cmd,
|
|
85
|
+
*rc_cmd_args,
|
|
86
|
+
*([f"--config={self.__config_file}"] if self.__config_file else []),
|
|
87
|
+
*([f"--log-file={self.__log_file}"] if self.__log_file else []),
|
|
88
|
+
f"--log-level={self.__log_level}",
|
|
89
|
+
# The server needs to accept at least transfers+checkers connections, otherwise sync might fail!
|
|
90
|
+
# The connections could be limited, but it could cause deadlocks, so it's preferred to change transfers/checkers only
|
|
91
|
+
f"--transfers={self.__transfers}",
|
|
92
|
+
f"--checkers={self.__checkers}",
|
|
93
|
+
*([f"--bwlimit={self.__bwlimit}"] if self.__bwlimit else []),
|
|
94
|
+
],
|
|
95
|
+
stderr=subprocess.PIPE,
|
|
96
|
+
text=True,
|
|
97
|
+
start_new_session=True, # for _cleanup
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
logger.info(f"rclone started, cmd={' '.join(self.__process.args)}") # type: ignore
|
|
101
|
+
|
|
102
|
+
# during dev you might want to start on cli separately:
|
|
103
|
+
# rclone rcd --rc-no-auth --rc-addr=localhost:5572 --rc-web-gui --transfers=4 --checkers=4 --bwlimit=5K
|
|
77
104
|
|
|
78
105
|
if startup_timeout:
|
|
79
106
|
self.wait_until_operational(startup_timeout)
|
|
107
|
+
logger.info("rclone is operational now")
|
|
80
108
|
|
|
81
109
|
def wait_until_operational(self, timeout: float = 5) -> None:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
while time.time() < deadline:
|
|
110
|
+
with self.__startstop_lock:
|
|
85
111
|
if not self.__process:
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
logger.error("cannot wait for rclone being operational while not started before")
|
|
113
|
+
return
|
|
88
114
|
|
|
89
|
-
|
|
90
|
-
ret = self.__process.poll()
|
|
91
|
-
if ret is not None:
|
|
92
|
-
stderr = self.__process.stderr.read() # type: ignore
|
|
93
|
-
raise RuntimeError(f"rclone failed to start (exit={ret}): {stderr.strip()}")
|
|
115
|
+
deadline = time.time() + timeout
|
|
94
116
|
|
|
95
|
-
|
|
96
|
-
|
|
117
|
+
while time.time() < deadline:
|
|
118
|
+
# If rclone died immediately, capture stderr and raise
|
|
119
|
+
ret = self.__process.poll()
|
|
120
|
+
if ret is not None:
|
|
121
|
+
stderr = self.__process.stderr.read() # type: ignore
|
|
122
|
+
raise RuntimeError(f"rclone failed to start (exit={ret}): {stderr.strip()}")
|
|
97
123
|
|
|
98
|
-
|
|
124
|
+
if ret is None and self.operational():
|
|
125
|
+
return
|
|
99
126
|
|
|
100
|
-
|
|
127
|
+
time.sleep(0.1)
|
|
101
128
|
|
|
102
|
-
|
|
103
|
-
if self.__process:
|
|
104
|
-
self.__process.terminate()
|
|
105
|
-
self.__process.wait(timeout=5)
|
|
129
|
+
raise RuntimeError(f"rclone did not become operational after {timeout}s")
|
|
106
130
|
|
|
107
|
-
|
|
131
|
+
def stop(self):
|
|
132
|
+
with self.__startstop_lock:
|
|
133
|
+
if self.__process:
|
|
134
|
+
self.__process.terminate()
|
|
135
|
+
self.__process.wait(timeout=5)
|
|
108
136
|
|
|
109
|
-
|
|
110
|
-
# if forgot to call stop, we still kill a possible rclone instance at py program exit.
|
|
111
|
-
if self.__process and self.__process.poll() is None:
|
|
112
|
-
os.killpg(self.__process.pid, signal.SIGTERM)
|
|
137
|
+
self.__process = None
|
|
113
138
|
|
|
114
139
|
# -------------------------
|
|
115
140
|
# Internal helper
|
|
@@ -136,12 +161,12 @@ class RcloneApi:
|
|
|
136
161
|
|
|
137
162
|
try:
|
|
138
163
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
139
|
-
|
|
140
|
-
response_json = json.loads(
|
|
164
|
+
raw_resp: bytes = resp.read()
|
|
165
|
+
response_json = json.loads(raw_resp.decode("utf-8"))
|
|
141
166
|
|
|
142
167
|
except urllib.error.HTTPError as exc: # non 200 HTTP codes
|
|
143
|
-
|
|
144
|
-
response_json = json.loads(
|
|
168
|
+
raw_exc: bytes = exc.read()
|
|
169
|
+
response_json = json.loads(raw_exc.decode("utf-8"))
|
|
145
170
|
raise RcloneProcessException.from_dict(response_json) from exc
|
|
146
171
|
except TimeoutError as exc:
|
|
147
172
|
raise RcloneConnectionException(f"Operation timed out after {TIMEOUT}s. To copy large files consider using _async methods.") from exc
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rclone-bin-api
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.6
|
|
4
4
|
Summary: Rclone API for Python including the Rclone binaries.
|
|
5
5
|
Author-email: Michael G <me@mgineer85.de>
|
|
6
6
|
Maintainer-email: Michael G <me@mgineer85.de>
|
|
@@ -41,7 +41,7 @@ from rclone_api.api import RcloneApi
|
|
|
41
41
|
api = RcloneApi()
|
|
42
42
|
api.start()
|
|
43
43
|
|
|
44
|
-
print(api.version())
|
|
44
|
+
print(api.version()) # CoreVersion(version='v1.72.1')
|
|
45
45
|
|
|
46
46
|
api.stop()
|
|
47
47
|
```
|
|
@@ -5,8 +5,11 @@ from dataclasses import dataclass
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from uuid import uuid4
|
|
7
7
|
|
|
8
|
+
import psutil
|
|
8
9
|
import pytest
|
|
10
|
+
import requests
|
|
9
11
|
|
|
12
|
+
from rclone_api import BINARY_PATH
|
|
10
13
|
from rclone_api.api import RcloneApi
|
|
11
14
|
from rclone_api.exceptions import RcloneProcessException
|
|
12
15
|
|
|
@@ -21,7 +24,10 @@ class RcloneFixture:
|
|
|
21
24
|
|
|
22
25
|
@pytest.fixture()
|
|
23
26
|
def _rclone_fixture() -> Generator[RcloneFixture, None, None]:
|
|
24
|
-
|
|
27
|
+
# ensure no instance running prior testrun (also no other instances in separate terminals)
|
|
28
|
+
assert BINARY_PATH.name not in (p.name() for p in psutil.process_iter())
|
|
29
|
+
|
|
30
|
+
client = RcloneApi("127.0.0.1:5582", log_file=Path("./log/rclone.log"))
|
|
25
31
|
|
|
26
32
|
client.start()
|
|
27
33
|
|
|
@@ -37,22 +43,39 @@ def _rclone_fixture() -> Generator[RcloneFixture, None, None]:
|
|
|
37
43
|
|
|
38
44
|
|
|
39
45
|
def test_operational():
|
|
40
|
-
ins = RcloneApi("
|
|
46
|
+
ins = RcloneApi("127.0.0.1:5582", log_file=Path("./log/rclone.log"))
|
|
41
47
|
assert ins.operational() is False
|
|
42
48
|
|
|
43
49
|
ins.start()
|
|
44
|
-
|
|
50
|
+
assert BINARY_PATH.name in (p.name() for p in psutil.process_iter())
|
|
45
51
|
assert ins.operational() is True
|
|
46
52
|
|
|
47
53
|
ins.start() # ensure second start doesn't break everything...
|
|
54
|
+
assert BINARY_PATH.name in (p.name() for p in psutil.process_iter())
|
|
48
55
|
|
|
49
56
|
ins.stop()
|
|
57
|
+
assert BINARY_PATH.name not in (p.name() for p in psutil.process_iter())
|
|
58
|
+
assert ins.operational() is False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_operational_gui():
|
|
62
|
+
ins = RcloneApi("127.0.0.1:5582", "127.0.0.1:5583", enable_webui=True, log_file=Path("./log/rclone.log"))
|
|
63
|
+
assert ins.operational() is False
|
|
50
64
|
|
|
65
|
+
ins.start()
|
|
66
|
+
assert BINARY_PATH.name in (p.name() for p in psutil.process_iter())
|
|
67
|
+
assert ins.operational() is True
|
|
68
|
+
|
|
69
|
+
res = requests.get(url="http://localhost:5583")
|
|
70
|
+
assert res.ok
|
|
71
|
+
|
|
72
|
+
ins.stop()
|
|
73
|
+
assert BINARY_PATH.name not in (p.name() for p in psutil.process_iter())
|
|
51
74
|
assert ins.operational() is False
|
|
52
75
|
|
|
53
76
|
|
|
54
77
|
def test_transfers_status_request(tmp_path: Path):
|
|
55
|
-
ins = RcloneApi("
|
|
78
|
+
ins = RcloneApi("127.0.0.1:5582", bwlimit="0.5M", log_file=Path("./log/rclone.log"))
|
|
56
79
|
ins.start()
|
|
57
80
|
|
|
58
81
|
dummy_local = tmp_path / "in" / "file1.txt"
|
|
@@ -66,8 +89,11 @@ def test_transfers_status_request(tmp_path: Path):
|
|
|
66
89
|
# queue the copy
|
|
67
90
|
job = ins.copyfile_async(str(dummy_local.parent), dummy_local.name, str(dummy_local_remote.parent), dummy_local_remote.name)
|
|
68
91
|
|
|
92
|
+
abort_counter = 100
|
|
69
93
|
while len(ins.core_stats().transferring) == 0:
|
|
70
94
|
# wait until actually transferring
|
|
95
|
+
abort_counter -= 1
|
|
96
|
+
assert abort_counter > 0, "abort because test got stuck! this could happen if another rclone instance was started on the test port"
|
|
71
97
|
time.sleep(0.1)
|
|
72
98
|
|
|
73
99
|
# ensure the filename is part of the transfer queue
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rclone_bin_api-1.0.4 → rclone_bin_api-1.0.6}/src/rclone_bin_api.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|