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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rclone-bin-api
3
- Version: 1.0.4
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()) # CoreVersion(version='v1.72.1')
44
+ print(api.version()) # CoreVersion(version='v1.72.1')
45
45
 
46
46
  api.stop()
47
47
  ```
@@ -24,7 +24,7 @@ from rclone_api.api import RcloneApi
24
24
  api = RcloneApi()
25
25
  api.start()
26
26
 
27
- print(api.version()) # CoreVersion(version='v1.72.1')
27
+ print(api.version()) # CoreVersion(version='v1.72.1')
28
28
 
29
29
  api.stop()
30
30
  ```
@@ -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.4"
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
- "pyright",
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 pyright" },
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.pyright]
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.73.0")
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 os
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="localhost:5572",
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._cleanup)
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
- if self.__process:
49
- return
50
-
51
- if self.__log_file:
52
- self.__log_file.parent.mkdir(parents=True, exist_ok=True)
53
-
54
- self.__process = subprocess.Popen(
55
- [
56
- str(self.__rclone_bin),
57
- "rcd",
58
- *([f"--config={self.__config_file}"] if self.__config_file else []),
59
- f"--rc-addr={self.__bind_addr}",
60
- "--rc-no-auth", # TODO: add auth.
61
- *(["--rc-web-gui"] if self.__enable_webui else []),
62
- "--rc-web-gui-no-open-browser",
63
- # The server needs to accept at least transfers+checkers connections, otherwise sync might fail!
64
- # The connections could be limited, but it could cause deadlocks, so it's preferred to change transfers/checkers only
65
- f"--transfers={self.__transfers}",
66
- f"--checkers={self.__checkers}",
67
- *([f"--log-file={self.__log_file}"] if self.__log_file else []),
68
- f"--log-level={self.__log_level}",
69
- *([f"--bwlimit={self.__bwlimit}"] if self.__bwlimit else []),
70
- ],
71
- stderr=subprocess.PIPE,
72
- text=True,
73
- start_new_session=True, # for _cleanup
74
- )
75
- # during dev you might want to start on cli separately:
76
- # rclone rcd --rc-no-auth --rc-addr=localhost:5572 --rc-web-gui --transfers=4 --checkers=4 --bwlimit=5K
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
- deadline = time.time() + timeout
83
-
84
- while time.time() < deadline:
110
+ with self.__startstop_lock:
85
111
  if not self.__process:
86
- # maybe instance created already but code using the api did not start yet.
87
- continue
112
+ logger.error("cannot wait for rclone being operational while not started before")
113
+ return
88
114
 
89
- # If rclone died immediately, capture stderr and raise
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
- if ret is None and self.operational():
96
- return
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
- time.sleep(0.1)
124
+ if ret is None and self.operational():
125
+ return
99
126
 
100
- raise RuntimeError(f"rclone did not become operational after {timeout}s")
127
+ time.sleep(0.1)
101
128
 
102
- def stop(self):
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
- self.__process = None
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
- def _cleanup(self):
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
- raw: bytes = resp.read()
140
- response_json = json.loads(raw.decode("utf-8"))
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
- raw: bytes = exc.read()
144
- response_json = json.loads(raw.decode("utf-8"))
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.4
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()) # CoreVersion(version='v1.72.1')
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
- client = RcloneApi("localhost:5573", log_file=Path("./log/rclone.log"))
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("localhost:5573")
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("localhost:5573", bwlimit="5M")
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
@@ -1,9 +0,0 @@
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()
File without changes