forest-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forest/__init__.py +16 -0
- forest/analytics.py +29 -0
- forest/checkout.py +992 -0
- forest/cli.py +2747 -0
- forest/config.py +286 -0
- forest/flags.py +20 -0
- forest/flow.py +157 -0
- forest/fsutil.py +36 -0
- forest/logger.py +197 -0
- forest/manifest.py +112 -0
- forest/metrics.py +67 -0
- forest/monitoring.py +138 -0
- forest/paths.py +192 -0
- forest/rclone.py +561 -0
- forest/resilience.py +122 -0
- forest/sync_state.py +182 -0
- forest_cli-0.1.0.dist-info/METADATA +233 -0
- forest_cli-0.1.0.dist-info/RECORD +22 -0
- forest_cli-0.1.0.dist-info/WHEEL +5 -0
- forest_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forest_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- forest_cli-0.1.0.dist-info/top_level.txt +1 -0
forest/rclone.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""Thin active transport wrapper around the external ``rclone`` binary.
|
|
2
|
+
|
|
3
|
+
This module provides the low-level transport primitives (connection-string
|
|
4
|
+
builder + ``copy``/``copyto``/``lsjson``/``check``) plus the duck-typed
|
|
5
|
+
:class:`RemoteFile` / :class:`TransferResult` data shapes, and the
|
|
6
|
+
:class:`RcloneTransport` adapter + :func:`get_transport` factory used by
|
|
7
|
+
``cli.py``.
|
|
8
|
+
|
|
9
|
+
forest.yaml is the single source of truth for remotes: every connstring is
|
|
10
|
+
built on the fly and passed as ONE argv element (no shell, no ``rclone.conf``).
|
|
11
|
+
Real-name files at rest on every backend; no md5/content-addressing anywhere.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from contextlib import suppress
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from urllib.parse import urlsplit
|
|
28
|
+
|
|
29
|
+
from forest import metrics
|
|
30
|
+
from forest.config import RemoteConfig
|
|
31
|
+
from forest.logger import get_logger
|
|
32
|
+
from forest.resilience import CircuitBreaker, CircuitOpenError, backoff_delay, is_transient, transfer_retries
|
|
33
|
+
|
|
34
|
+
_log = get_logger("forest.rclone")
|
|
35
|
+
|
|
36
|
+
RCLONE_INSTALL_MSG = "rclone is required. Install with: conda install -c conda-forge rclone"
|
|
37
|
+
|
|
38
|
+
_LOCAL_METADATA_EXCLUDES = ["--exclude", "._*", "--exclude", ".DS_Store"]
|
|
39
|
+
_APPLEDOUBLE_MAGIC = b"\x00\x05\x16\x07"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RcloneError(Exception):
|
|
43
|
+
"""Raised when an rclone subprocess exits non-zero.
|
|
44
|
+
|
|
45
|
+
Carries the captured ``stderr`` text and the process exit ``code`` so
|
|
46
|
+
callers can surface the failure (e.g. as ``TransferResult.errors``).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, stderr: str, code: int) -> None:
|
|
50
|
+
self.stderr = stderr
|
|
51
|
+
self.code = code
|
|
52
|
+
super().__init__(f"rclone failed (exit {code}): {stderr}".rstrip())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class TransferResult:
|
|
57
|
+
"""Outcome of a push or pull operation (duck-typed across cli.py)."""
|
|
58
|
+
|
|
59
|
+
files_transferred: int
|
|
60
|
+
bytes_transferred: int
|
|
61
|
+
errors: list[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def success(self) -> bool:
|
|
65
|
+
return len(self.errors) == 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class RemoteFile:
|
|
70
|
+
"""A file listing entry on a remote.
|
|
71
|
+
|
|
72
|
+
``key`` is PREFIX-RELATIVE and may contain ``/`` (nested dirs preserved).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
key: str
|
|
76
|
+
size: int
|
|
77
|
+
last_modified: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_available() -> bool:
|
|
81
|
+
"""Return True when the ``rclone`` binary is resolvable on ``$PATH``."""
|
|
82
|
+
return shutil.which("rclone") is not None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- #
|
|
86
|
+
# Connection-string builder
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_remote(remote_cfg: RemoteConfig) -> str:
|
|
91
|
+
"""Build an on-the-fly rclone connection-string ROOT from a RemoteConfig.
|
|
92
|
+
|
|
93
|
+
Dispatches on ``remote_cfg.remote_type`` (``s3`` / ``local`` / ``sftp``).
|
|
94
|
+
The returned string is a single argv element; never shell-quote it here.
|
|
95
|
+
"""
|
|
96
|
+
remote_type = remote_cfg.remote_type
|
|
97
|
+
if remote_type == "local":
|
|
98
|
+
return _build_local(remote_cfg)
|
|
99
|
+
if remote_type == "s3":
|
|
100
|
+
return _build_s3(remote_cfg)
|
|
101
|
+
if remote_type == "sftp":
|
|
102
|
+
return _build_sftp(remote_cfg)
|
|
103
|
+
raise ValueError(f"Unsupported remote type '{remote_type}' for URL: {remote_cfg.url}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_local(remote_cfg: RemoteConfig) -> str:
|
|
107
|
+
# Plain absolute path, used as-is. Drop trailing slashes so joining
|
|
108
|
+
# ``{root}/{key}`` never yields ``//``; spaces are preserved verbatim.
|
|
109
|
+
stripped = remote_cfg.url.rstrip("/")
|
|
110
|
+
return stripped or "/"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_s3(remote_cfg: RemoteConfig) -> str:
|
|
114
|
+
rest = remote_cfg.url[len("s3://") :].rstrip("/") # "bucket" or "bucket/prefix"
|
|
115
|
+
opts = ["provider=AWS", "env_auth=true"]
|
|
116
|
+
if remote_cfg.profile is not None:
|
|
117
|
+
opts.append(f"profile={remote_cfg.profile}")
|
|
118
|
+
if remote_cfg.region is not None:
|
|
119
|
+
opts.append(f"region={remote_cfg.region}")
|
|
120
|
+
if remote_cfg.endpoint is not None:
|
|
121
|
+
opts.append(f"endpoint={remote_cfg.endpoint}")
|
|
122
|
+
opts.append("no_check_bucket=true")
|
|
123
|
+
return ":s3," + ",".join(opts) + ":" + rest
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_sftp_url(url: str) -> tuple[str | None, str | None, int, str | None]:
|
|
127
|
+
"""Parse an sftp URL into ``(user, host, port, path)``.
|
|
128
|
+
|
|
129
|
+
Accepts ``sftp://host[:port]/path`` (credentials rejected) and scp-style
|
|
130
|
+
``user@host:[port:]/path`` forms.
|
|
131
|
+
"""
|
|
132
|
+
if url.startswith("sftp://"):
|
|
133
|
+
parsed = urlsplit(url)
|
|
134
|
+
if parsed.username or parsed.password:
|
|
135
|
+
raise ValueError("Cannot parse sftp url with embedded credentials; use key_file and known_hosts.")
|
|
136
|
+
return None, parsed.hostname, parsed.port or 22, parsed.path
|
|
137
|
+
|
|
138
|
+
match = re.match(r"^(?P<user>[^@]+)@(?P<rest>.+)$", url)
|
|
139
|
+
if not match:
|
|
140
|
+
raise ValueError(f"Cannot parse sftp url (expected user@host:/path or sftp://host/path): {url}")
|
|
141
|
+
user = match.group("user")
|
|
142
|
+
host_part, _, path_part = match.group("rest").partition(":")
|
|
143
|
+
if not path_part:
|
|
144
|
+
raise ValueError(f"Cannot parse sftp url (missing remote path): {url}")
|
|
145
|
+
port = 22
|
|
146
|
+
if not path_part.startswith("/") and ":" in path_part:
|
|
147
|
+
maybe_port, _, rest_path = path_part.partition(":")
|
|
148
|
+
if maybe_port.isdigit():
|
|
149
|
+
port = int(maybe_port)
|
|
150
|
+
path_part = rest_path
|
|
151
|
+
return user, host_part, port, path_part
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _build_sftp(remote_cfg: RemoteConfig) -> str:
|
|
155
|
+
missing = [
|
|
156
|
+
name
|
|
157
|
+
for name, value in (("key_file", remote_cfg.key_file), ("known_hosts", remote_cfg.known_hosts))
|
|
158
|
+
if not value
|
|
159
|
+
]
|
|
160
|
+
if missing:
|
|
161
|
+
missing_text = ", ".join(missing)
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"sftp remote requires key_file and known_hosts (set them in forest.yaml); "
|
|
164
|
+
f"missing: {missing_text}; "
|
|
165
|
+
"refusing to build a connstring without them (no agent/default-key fallback, "
|
|
166
|
+
"no host-key-check bypass)."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
user, host_part, port, path_part = _parse_sftp_url(remote_cfg.url)
|
|
170
|
+
|
|
171
|
+
if not host_part:
|
|
172
|
+
raise ValueError(f"Cannot parse sftp url (missing host): {remote_cfg.url}")
|
|
173
|
+
if not path_part or not path_part.startswith("/"):
|
|
174
|
+
raise ValueError(f"Cannot parse sftp url (missing remote path): {remote_cfg.url}")
|
|
175
|
+
|
|
176
|
+
opts = [
|
|
177
|
+
f"host={host_part}",
|
|
178
|
+
f"key_file={remote_cfg.key_file}",
|
|
179
|
+
f"known_hosts_file={remote_cfg.known_hosts}",
|
|
180
|
+
f"port={port}",
|
|
181
|
+
]
|
|
182
|
+
if user is not None:
|
|
183
|
+
opts.insert(1, f"user={user}")
|
|
184
|
+
return f":sftp,{','.join(opts)}:{path_part}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------------------- #
|
|
188
|
+
# Subprocess helpers
|
|
189
|
+
# --------------------------------------------------------------------------- #
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _progress_flags(quiet: bool) -> list[str]:
|
|
193
|
+
if quiet:
|
|
194
|
+
return ["-q"]
|
|
195
|
+
# --stats-log-level NOTICE forces rclone to emit the final transfer stats
|
|
196
|
+
# line to stderr even for fast/sub-second transfers (the default INFO level
|
|
197
|
+
# is below rclone's console threshold, so small uploads would otherwise show
|
|
198
|
+
# no stats at all). `-q` in quiet mode suppresses these entirely.
|
|
199
|
+
return ["--stats", "1s", "--stats-one-line", "--stats-log-level", "NOTICE"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _run(args: list[str], *, echo_stderr: bool) -> subprocess.CompletedProcess[str]:
|
|
203
|
+
"""Run ``rclone <args>``, raising :class:`RcloneError` on non-zero exit.
|
|
204
|
+
|
|
205
|
+
stderr is always captured (so errors carry text); when ``echo_stderr`` it
|
|
206
|
+
is also forwarded to the real stderr so rclone's stats/errors stay visible.
|
|
207
|
+
Transient failures (exit code 5 / network-blip stderr) are retried with
|
|
208
|
+
exponential backoff up to ``FOREST_TRANSFER_RETRIES`` extra attempts; only
|
|
209
|
+
the final attempt's stderr is echoed so retries do not duplicate output.
|
|
210
|
+
"""
|
|
211
|
+
op = args[0] if args else "rclone"
|
|
212
|
+
max_retries = transfer_retries()
|
|
213
|
+
attempt = 0
|
|
214
|
+
start = time.monotonic()
|
|
215
|
+
while True:
|
|
216
|
+
proc = subprocess.run(["rclone", *args], capture_output=True, text=True)
|
|
217
|
+
if proc.returncode == 0:
|
|
218
|
+
break
|
|
219
|
+
if attempt < max_retries and is_transient(proc.returncode, proc.stderr or ""):
|
|
220
|
+
attempt += 1
|
|
221
|
+
delay = backoff_delay(attempt)
|
|
222
|
+
_log.warning(
|
|
223
|
+
"transient rclone failure; retrying",
|
|
224
|
+
extra={"op": op, "exit_code": proc.returncode, "attempt": attempt, "delay_s": delay},
|
|
225
|
+
)
|
|
226
|
+
metrics.emit("rclone.retries", 1, tags={"op": op})
|
|
227
|
+
time.sleep(delay)
|
|
228
|
+
continue
|
|
229
|
+
if echo_stderr and proc.stderr:
|
|
230
|
+
sys.stderr.write(proc.stderr)
|
|
231
|
+
_log.error("rclone failed", extra={"op": op, "exit_code": proc.returncode, "attempts": attempt + 1})
|
|
232
|
+
metrics.emit("rclone.failures", 1, tags={"op": op})
|
|
233
|
+
raise RcloneError(proc.stderr or "", proc.returncode)
|
|
234
|
+
if echo_stderr and proc.stderr:
|
|
235
|
+
sys.stderr.write(proc.stderr)
|
|
236
|
+
duration = round(time.monotonic() - start, 3)
|
|
237
|
+
_log.debug("rclone ok", extra={"op": op, "attempts": attempt + 1, "duration_s": duration})
|
|
238
|
+
metrics.emit("rclone.duration_seconds", duration, unit="s", tags={"op": op})
|
|
239
|
+
return proc
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_local_dst(dst: str) -> bool:
|
|
243
|
+
# rclone connstrings for non-local backends start with ':' (e.g. ':s3,', ':sftp,').
|
|
244
|
+
return not dst.startswith(":")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _remove_local_metadata_sidecars(written_files: list[Path], *, root: Path | None = None) -> None:
|
|
248
|
+
"""Remove local macOS metadata sidecars scoped to written file paths.
|
|
249
|
+
|
|
250
|
+
AppleDouble ``._*`` files are deleted only when the 4-byte AppleDouble magic
|
|
251
|
+
matches, preserving real data files whose names happen to start with ``._``.
|
|
252
|
+
``.DS_Store`` has no data payload in forest outputs, so it is deleted
|
|
253
|
+
unconditionally, but only from directories touched by this transfer.
|
|
254
|
+
"""
|
|
255
|
+
candidates: set[Path] = set()
|
|
256
|
+
for path in written_files:
|
|
257
|
+
path = Path(path)
|
|
258
|
+
candidates.add(path.parent / f"._{path.name}")
|
|
259
|
+
candidates.add(path.parent / ".DS_Store")
|
|
260
|
+
if root is not None:
|
|
261
|
+
candidates.update(_ancestor_metadata_candidates(path, root))
|
|
262
|
+
|
|
263
|
+
for path in candidates:
|
|
264
|
+
if _is_metadata_sidecar(path):
|
|
265
|
+
with suppress(OSError):
|
|
266
|
+
path.unlink()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _ancestor_metadata_candidates(path: Path, root: Path) -> set[Path]:
|
|
270
|
+
"""Return metadata candidates for ancestor dirs from ``root`` to ``path``."""
|
|
271
|
+
candidates: set[Path] = set()
|
|
272
|
+
with suppress(ValueError):
|
|
273
|
+
rel = path.relative_to(root)
|
|
274
|
+
current = root
|
|
275
|
+
candidates.add(current / ".DS_Store")
|
|
276
|
+
for part in rel.parts[:-1]:
|
|
277
|
+
child = current / part
|
|
278
|
+
candidates.add(current / f"._{part}")
|
|
279
|
+
candidates.add(child / ".DS_Store")
|
|
280
|
+
current = child
|
|
281
|
+
return candidates
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _is_metadata_sidecar(path: Path) -> bool:
|
|
285
|
+
if not path.is_file():
|
|
286
|
+
return False
|
|
287
|
+
if path.name == ".DS_Store":
|
|
288
|
+
return True
|
|
289
|
+
if not path.name.startswith("._"):
|
|
290
|
+
return False
|
|
291
|
+
with suppress(OSError):
|
|
292
|
+
return path.read_bytes()[:4] == _APPLEDOUBLE_MAGIC
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# --------------------------------------------------------------------------- #
|
|
297
|
+
# Transfer primitives
|
|
298
|
+
# --------------------------------------------------------------------------- #
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def copy(files: list[Path], local_base: Path, dst: str, *, quiet: bool) -> None:
|
|
302
|
+
"""Copy ``files`` to ``dst`` preserving paths RELATIVE to ``local_base``.
|
|
303
|
+
|
|
304
|
+
Runs ``rclone copy --files-from-raw <tmp list> <local_base> <dst>``. The
|
|
305
|
+
list entries are each file's path relative to ``local_base``; rclone
|
|
306
|
+
preserves that relative layout under ``dst`` and CANNOT rename (use
|
|
307
|
+
:func:`copyto`). ``--files-from-raw`` (not ``--files-from``) is required so
|
|
308
|
+
real filenames with leading ``#``/``;`` or surrounding whitespace are taken
|
|
309
|
+
verbatim instead of being parsed as comments/trimmed and silently dropped.
|
|
310
|
+
"""
|
|
311
|
+
if not files:
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
local_base = Path(local_base)
|
|
315
|
+
rel_lines = [str(Path(f).relative_to(local_base)) for f in files]
|
|
316
|
+
|
|
317
|
+
fd, list_path = tempfile.mkstemp(suffix=".lst")
|
|
318
|
+
try:
|
|
319
|
+
with os.fdopen(fd, "w") as fh:
|
|
320
|
+
fh.write("\n".join(rel_lines) + "\n")
|
|
321
|
+
args = ["copy", "--files-from-raw", list_path, str(local_base), dst, *_progress_flags(quiet)]
|
|
322
|
+
_run(args, echo_stderr=not quiet)
|
|
323
|
+
if _is_local_dst(dst):
|
|
324
|
+
dst_root = Path(dst)
|
|
325
|
+
_remove_local_metadata_sidecars([dst_root / rel for rel in rel_lines], root=dst_root)
|
|
326
|
+
finally:
|
|
327
|
+
os.unlink(list_path)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def copyto(src_file: Path | str, dst: str, *, quiet: bool) -> None:
|
|
331
|
+
"""Copy a single file to an exact destination (rename semantics).
|
|
332
|
+
|
|
333
|
+
Runs ``rclone copyto <src> <dst>`` — used for remote_path remaps and
|
|
334
|
+
``sync_by=file`` renames where the destination key differs from the source.
|
|
335
|
+
"""
|
|
336
|
+
args = ["copyto", str(src_file), dst, *_progress_flags(quiet)]
|
|
337
|
+
_run(args, echo_stderr=not quiet)
|
|
338
|
+
if _is_local_dst(dst):
|
|
339
|
+
_remove_local_metadata_sidecars([Path(dst)])
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --------------------------------------------------------------------------- #
|
|
343
|
+
# Listing
|
|
344
|
+
# --------------------------------------------------------------------------- #
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def lsjson(dst: str, prefix: str = "") -> list[RemoteFile]:
|
|
348
|
+
"""List files under ``dst``/``prefix`` recursively as RemoteFile objects.
|
|
349
|
+
|
|
350
|
+
Runs ``rclone lsjson --recursive --files-only [--exclude '._*' ...] <target>``
|
|
351
|
+
and maps each object: ``Path -> key`` (prefix-relative, nested preserved),
|
|
352
|
+
``Size -> size``, ``ModTime -> last_modified``. The ModTime parse is tolerant
|
|
353
|
+
(RFC3339 with variable fractional seconds / tz, or odd/missing values): the
|
|
354
|
+
raw string is kept and an empty/absent value becomes None. Never crashes.
|
|
355
|
+
"""
|
|
356
|
+
target = f"{dst.rstrip('/')}/{prefix.strip('/')}" if prefix else dst
|
|
357
|
+
|
|
358
|
+
args = ["lsjson", "--recursive", "--files-only"]
|
|
359
|
+
if _is_local_dst(dst):
|
|
360
|
+
args += _LOCAL_METADATA_EXCLUDES
|
|
361
|
+
args.append(target)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
proc = _run(args, echo_stderr=False)
|
|
365
|
+
except RcloneError as exc:
|
|
366
|
+
# A missing remote dir/prefix is an empty listing, not a failure: the
|
|
367
|
+
# duck-typed ls contract returns [] so callers (pull --all, status
|
|
368
|
+
# --remote) skip stages that have nothing on the remote yet.
|
|
369
|
+
if _is_missing_prefix_error(exc):
|
|
370
|
+
return []
|
|
371
|
+
raise
|
|
372
|
+
stdout = proc.stdout.strip()
|
|
373
|
+
if not stdout:
|
|
374
|
+
return []
|
|
375
|
+
|
|
376
|
+
files: list[RemoteFile] = []
|
|
377
|
+
for obj in json.loads(stdout):
|
|
378
|
+
key = obj.get("Path")
|
|
379
|
+
if key is None:
|
|
380
|
+
continue
|
|
381
|
+
size_raw = obj.get("Size", 0)
|
|
382
|
+
try:
|
|
383
|
+
size = int(size_raw)
|
|
384
|
+
except (TypeError, ValueError):
|
|
385
|
+
size = 0
|
|
386
|
+
files.append(RemoteFile(key=key, size=size, last_modified=_coerce_modtime(obj.get("ModTime"))))
|
|
387
|
+
return files
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _coerce_modtime(value: object) -> str | None:
|
|
391
|
+
if isinstance(value, str):
|
|
392
|
+
stripped = value.strip()
|
|
393
|
+
return stripped or None
|
|
394
|
+
if value is None:
|
|
395
|
+
return None
|
|
396
|
+
return str(value)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _is_missing_prefix_error(exc: RcloneError) -> bool:
|
|
400
|
+
"""Return True for rclone's absent remote dir/prefix listing error."""
|
|
401
|
+
if exc.code == 3:
|
|
402
|
+
return True
|
|
403
|
+
stderr = (exc.stderr or "").lower()
|
|
404
|
+
return "directory not found" in stderr
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# --------------------------------------------------------------------------- #
|
|
408
|
+
# Verify
|
|
409
|
+
# --------------------------------------------------------------------------- #
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def check(local_base: Path, dst: str) -> bool:
|
|
413
|
+
"""Return True when ``local_base`` and ``dst`` match (rclone exit 0).
|
|
414
|
+
|
|
415
|
+
Runs ``rclone check [--exclude '._*' ...] <local_base> <dst>``. AppleDouble
|
|
416
|
+
sidecars are always excluded because ``local_base`` is always a local path
|
|
417
|
+
(exFAT/macOS would otherwise surface spurious ``._*`` differences).
|
|
418
|
+
"""
|
|
419
|
+
args = ["check", *_LOCAL_METADATA_EXCLUDES, str(local_base), dst]
|
|
420
|
+
proc = subprocess.run(["rclone", *args], capture_output=True, text=True)
|
|
421
|
+
return proc.returncode == 0
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# --------------------------------------------------------------------------- #
|
|
425
|
+
# RcloneTransport adapter + factory
|
|
426
|
+
# --------------------------------------------------------------------------- #
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _batchable_base(local_path: Path, remote_key: str) -> Path | None:
|
|
430
|
+
"""Return the directory ``base`` for which ``local_path == base / remote_key``.
|
|
431
|
+
|
|
432
|
+
A pair is batchable iff ``remote_key`` is exactly the tail of ``local_path``
|
|
433
|
+
relative to some real directory boundary; ``rclone copy --files-from-raw``
|
|
434
|
+
then places the file at ``dst_root/remote_key`` with no rename. Returns
|
|
435
|
+
None for remote_path remaps and sync_by=file renames (key diverges from the
|
|
436
|
+
local layout), which must go through :func:`copyto`.
|
|
437
|
+
"""
|
|
438
|
+
rk = remote_key.strip("/")
|
|
439
|
+
if not rk:
|
|
440
|
+
return None
|
|
441
|
+
lp = local_path.as_posix()
|
|
442
|
+
suffix = "/" + rk
|
|
443
|
+
if not lp.endswith(suffix):
|
|
444
|
+
return None
|
|
445
|
+
base = Path(lp[: -len(suffix)])
|
|
446
|
+
try:
|
|
447
|
+
if local_path.relative_to(base).as_posix() == rk:
|
|
448
|
+
return base
|
|
449
|
+
except ValueError:
|
|
450
|
+
return None
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class RcloneTransport:
|
|
455
|
+
"""Duck-typed transport backed by the rclone binary (local/s3/sftp).
|
|
456
|
+
|
|
457
|
+
Implements the interface ``cli.py`` consumes:
|
|
458
|
+
``push`` / ``pull`` / ``ls`` / ``is_available`` / ``backend_name``. Each
|
|
459
|
+
invocation builds its connstring root on the fly from ``remote_cfg`` via
|
|
460
|
+
:func:`build_remote`; the ``remote_base`` argument (always ``remote_cfg.url``)
|
|
461
|
+
is accepted for signature compatibility.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
def __init__(self, remote_cfg: RemoteConfig) -> None:
|
|
465
|
+
self.remote_cfg = remote_cfg
|
|
466
|
+
# Trips after consecutive failed operations so multi-unit push/pull
|
|
467
|
+
# loops fail fast against a dead remote instead of retrying per unit.
|
|
468
|
+
self._breaker = CircuitBreaker()
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def backend_name(self) -> str:
|
|
472
|
+
return f"rclone:{self.remote_cfg.remote_type}"
|
|
473
|
+
|
|
474
|
+
def is_available(self) -> bool:
|
|
475
|
+
return is_available()
|
|
476
|
+
|
|
477
|
+
def push(
|
|
478
|
+
self,
|
|
479
|
+
files: list[tuple[Path, str]],
|
|
480
|
+
remote_base: str,
|
|
481
|
+
*,
|
|
482
|
+
quiet: bool = False,
|
|
483
|
+
) -> TransferResult:
|
|
484
|
+
if not files:
|
|
485
|
+
return TransferResult(files_transferred=0, bytes_transferred=0)
|
|
486
|
+
|
|
487
|
+
dst_root = build_remote(self.remote_cfg)
|
|
488
|
+
groups: dict[Path, list[Path]] = {}
|
|
489
|
+
renames: list[tuple[Path, str]] = []
|
|
490
|
+
for local_path, remote_key in files:
|
|
491
|
+
local_path = Path(local_path)
|
|
492
|
+
base = _batchable_base(local_path, remote_key)
|
|
493
|
+
if base is None:
|
|
494
|
+
renames.append((local_path, remote_key))
|
|
495
|
+
else:
|
|
496
|
+
groups.setdefault(base, []).append(local_path)
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
self._breaker.guard("push")
|
|
500
|
+
for base, group in groups.items():
|
|
501
|
+
copy(group, base, dst_root, quiet=quiet)
|
|
502
|
+
for local_path, remote_key in renames:
|
|
503
|
+
copyto(str(local_path), f"{dst_root}/{remote_key.strip('/')}", quiet=quiet)
|
|
504
|
+
except CircuitOpenError as exc:
|
|
505
|
+
return TransferResult(0, 0, errors=[str(exc)])
|
|
506
|
+
except RcloneError as exc:
|
|
507
|
+
self._breaker.record_failure()
|
|
508
|
+
return TransferResult(0, 0, errors=[exc.stderr or str(exc)])
|
|
509
|
+
self._breaker.record_success()
|
|
510
|
+
|
|
511
|
+
total_bytes = sum(Path(lp).stat().st_size for lp, _ in files)
|
|
512
|
+
return TransferResult(files_transferred=len(files), bytes_transferred=total_bytes)
|
|
513
|
+
|
|
514
|
+
def pull(
|
|
515
|
+
self,
|
|
516
|
+
files: list[tuple[str, Path]],
|
|
517
|
+
remote_base: str,
|
|
518
|
+
*,
|
|
519
|
+
quiet: bool = False,
|
|
520
|
+
) -> TransferResult:
|
|
521
|
+
if not files:
|
|
522
|
+
return TransferResult(files_transferred=0, bytes_transferred=0)
|
|
523
|
+
|
|
524
|
+
dst_root = build_remote(self.remote_cfg)
|
|
525
|
+
try:
|
|
526
|
+
self._breaker.guard("pull")
|
|
527
|
+
for remote_key, local_path in files:
|
|
528
|
+
local_path = Path(local_path)
|
|
529
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
530
|
+
copyto(f"{dst_root}/{remote_key.strip('/')}", str(local_path), quiet=quiet)
|
|
531
|
+
except CircuitOpenError as exc:
|
|
532
|
+
return TransferResult(0, 0, errors=[str(exc)])
|
|
533
|
+
except RcloneError as exc:
|
|
534
|
+
self._breaker.record_failure()
|
|
535
|
+
return TransferResult(0, 0, errors=[exc.stderr or str(exc)])
|
|
536
|
+
self._breaker.record_success()
|
|
537
|
+
|
|
538
|
+
total_bytes = sum(Path(lp).stat().st_size for _, lp in files if Path(lp).exists())
|
|
539
|
+
return TransferResult(files_transferred=len(files), bytes_transferred=total_bytes)
|
|
540
|
+
|
|
541
|
+
def ls(self, remote_base: str, prefix: str = "") -> list[RemoteFile]:
|
|
542
|
+
self._breaker.guard("ls")
|
|
543
|
+
try:
|
|
544
|
+
listing = lsjson(build_remote(self.remote_cfg), prefix=prefix)
|
|
545
|
+
except RcloneError:
|
|
546
|
+
self._breaker.record_failure()
|
|
547
|
+
raise
|
|
548
|
+
self._breaker.record_success()
|
|
549
|
+
return listing
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def get_transport(remote_cfg: RemoteConfig) -> RcloneTransport:
|
|
553
|
+
"""Return an :class:`RcloneTransport` for a local/s3/sftp remote.
|
|
554
|
+
|
|
555
|
+
Dispatches on ``remote_cfg.remote_type``; raises for an unsupported type
|
|
556
|
+
(matching the message the old factory produced so callers stay identical).
|
|
557
|
+
"""
|
|
558
|
+
remote_type = remote_cfg.remote_type
|
|
559
|
+
if remote_type in ("local", "s3", "sftp"):
|
|
560
|
+
return RcloneTransport(remote_cfg)
|
|
561
|
+
raise ValueError(f"Unsupported remote type '{remote_type}' for URL: {remote_cfg.url}")
|
forest/resilience.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Resilience primitives guarding external rclone transfers.
|
|
2
|
+
|
|
3
|
+
Two layers, both tunable (and disableable) via environment variables:
|
|
4
|
+
|
|
5
|
+
- retry with exponential backoff for *transient* failures only
|
|
6
|
+
(``forest.rclone._run``): rclone exit code 5 ("temporary error") or stderr
|
|
7
|
+
matching timeout/connection patterns. Assertion-class failures (bad remote,
|
|
8
|
+
missing path, auth) never retry.
|
|
9
|
+
Knobs: ``FOREST_TRANSFER_RETRIES`` (default 2 extra attempts, 0 disables),
|
|
10
|
+
``FOREST_RETRY_BASE_DELAY`` (seconds, default 0.5, doubles per attempt).
|
|
11
|
+
|
|
12
|
+
- a per-transport circuit breaker (``forest.rclone.RcloneTransport``): after
|
|
13
|
+
N *consecutive* failed transfer operations the circuit opens and remaining
|
|
14
|
+
operations fail fast instead of hammering a dead remote (e.g. a bare
|
|
15
|
+
``forest pull`` over hundreds of units against an unreachable host).
|
|
16
|
+
Knobs: ``FOREST_BREAKER_THRESHOLD`` (default 5, 0 disables),
|
|
17
|
+
``FOREST_BREAKER_RESET_SECONDS`` (default 60; after this cool-down one probe
|
|
18
|
+
operation is allowed through, closing the circuit again on success).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import time
|
|
26
|
+
from typing import Callable
|
|
27
|
+
|
|
28
|
+
# rclone exit code 5: "Temporary error (one that more retries might fix)".
|
|
29
|
+
TRANSIENT_EXIT_CODES = frozenset({5})
|
|
30
|
+
|
|
31
|
+
_TRANSIENT_STDERR = re.compile(
|
|
32
|
+
r"(timeout|timed out|connection (reset|refused|closed|aborted)"
|
|
33
|
+
r"|temporar(y|ily)|too many requests|rate ?limit|unexpected eof|broken pipe)",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_transient(exit_code: int, stderr: str) -> bool:
|
|
39
|
+
"""Classify an rclone failure as retryable (network blip) or permanent."""
|
|
40
|
+
if exit_code in TRANSIENT_EXIT_CODES:
|
|
41
|
+
return True
|
|
42
|
+
return bool(_TRANSIENT_STDERR.search(stderr or ""))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _env_int(name: str, default: int) -> int:
|
|
46
|
+
raw = os.environ.get(name)
|
|
47
|
+
if raw is None:
|
|
48
|
+
return default
|
|
49
|
+
try:
|
|
50
|
+
return max(0, int(raw))
|
|
51
|
+
except ValueError:
|
|
52
|
+
return default
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _env_float(name: str, default: float) -> float:
|
|
56
|
+
raw = os.environ.get(name)
|
|
57
|
+
if raw is None:
|
|
58
|
+
return default
|
|
59
|
+
try:
|
|
60
|
+
return max(0.0, float(raw))
|
|
61
|
+
except ValueError:
|
|
62
|
+
return default
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def transfer_retries() -> int:
|
|
66
|
+
"""Extra attempts allowed after the first failure (0 disables retries)."""
|
|
67
|
+
return _env_int("FOREST_TRANSFER_RETRIES", 2)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def retry_base_delay() -> float:
|
|
71
|
+
"""Initial backoff delay in seconds; doubles on each subsequent retry."""
|
|
72
|
+
return _env_float("FOREST_RETRY_BASE_DELAY", 0.5)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def backoff_delay(attempt: int) -> float:
|
|
76
|
+
"""Delay before retry ``attempt`` (1-based): base * 2**(attempt-1)."""
|
|
77
|
+
return retry_base_delay() * (2.0 ** max(0, attempt - 1))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CircuitOpenError(Exception):
|
|
81
|
+
"""Raised when the circuit is open and an operation is refused fast."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CircuitBreaker:
|
|
85
|
+
"""Consecutive-failure circuit breaker with time-based half-open reset."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
threshold: int = 0,
|
|
91
|
+
reset_seconds: float = 0.0,
|
|
92
|
+
clock: Callable[[], float] = time.monotonic,
|
|
93
|
+
) -> None:
|
|
94
|
+
self._threshold = threshold or _env_int("FOREST_BREAKER_THRESHOLD", 5)
|
|
95
|
+
self._reset_seconds = reset_seconds or _env_float("FOREST_BREAKER_RESET_SECONDS", 60.0)
|
|
96
|
+
self._clock = clock
|
|
97
|
+
self._consecutive_failures = 0
|
|
98
|
+
self._opened_at: float = 0.0
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def is_open(self) -> bool:
|
|
102
|
+
if self._threshold <= 0 or self._consecutive_failures < self._threshold:
|
|
103
|
+
return False
|
|
104
|
+
return (self._clock() - self._opened_at) < self._reset_seconds
|
|
105
|
+
|
|
106
|
+
def guard(self, operation: str) -> None:
|
|
107
|
+
"""Refuse ``operation`` fast when the circuit is open."""
|
|
108
|
+
if self.is_open:
|
|
109
|
+
raise CircuitOpenError(
|
|
110
|
+
f"circuit breaker open: {self._consecutive_failures} consecutive transfer failures; "
|
|
111
|
+
f"refusing '{operation}' for {self._reset_seconds:.0f}s to avoid hammering the remote "
|
|
112
|
+
"(set FOREST_BREAKER_THRESHOLD=0 to disable)"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def record_success(self) -> None:
|
|
116
|
+
self._consecutive_failures = 0
|
|
117
|
+
self._opened_at = 0.0
|
|
118
|
+
|
|
119
|
+
def record_failure(self) -> None:
|
|
120
|
+
self._consecutive_failures += 1
|
|
121
|
+
if self._threshold > 0 and self._consecutive_failures >= self._threshold:
|
|
122
|
+
self._opened_at = self._clock()
|