rclone-api 1.0.49__py2.py3-none-any.whl → 1.0.51__py2.py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
rclone_api/rclone.py CHANGED
@@ -1,677 +1,680 @@
1
- """
2
- Unit test file.
3
- """
4
-
5
- import os
6
- import subprocess
7
- import time
8
- import warnings
9
- from concurrent.futures import Future, ThreadPoolExecutor
10
- from enum import Enum
11
- from fnmatch import fnmatch
12
- from pathlib import Path
13
- from tempfile import TemporaryDirectory
14
- from typing import Generator
15
-
16
- from rclone_api import Dir
17
- from rclone_api.completed_process import CompletedProcess
18
- from rclone_api.config import Config
19
- from rclone_api.convert import convert_to_filestr_list, convert_to_str
20
- from rclone_api.deprecated import deprecated
21
- from rclone_api.diff import DiffItem, diff_stream_from_running_process
22
- from rclone_api.dir_listing import DirListing
23
- from rclone_api.exec import RcloneExec
24
- from rclone_api.file import File
25
- from rclone_api.group_files import group_files
26
- from rclone_api.process import Process
27
- from rclone_api.remote import Remote
28
- from rclone_api.rpath import RPath
29
- from rclone_api.util import (
30
- get_rclone_exe,
31
- get_verbose,
32
- to_path,
33
- wait_for_mount,
34
- )
35
- from rclone_api.walk import walk
36
-
37
- EXECUTOR = ThreadPoolExecutor(16)
38
-
39
-
40
- def rclone_verbose(verbose: bool | None) -> bool:
41
- if verbose is not None:
42
- os.environ["RCLONE_API_VERBOSE"] = "1" if verbose else "0"
43
- return bool(int(os.getenv("RCLONE_API_VERBOSE", "0")))
44
-
45
-
46
- class ModTimeStrategy(Enum):
47
- USE_SERVER_MODTIME = "use-server-modtime"
48
- NO_MODTIME = "no-modtime"
49
-
50
-
51
- class Rclone:
52
- def __init__(
53
- self, rclone_conf: Path | Config, rclone_exe: Path | None = None
54
- ) -> None:
55
- if isinstance(rclone_conf, Path):
56
- if not rclone_conf.exists():
57
- raise ValueError(f"Rclone config file not found: {rclone_conf}")
58
- self._exec = RcloneExec(rclone_conf, get_rclone_exe(rclone_exe))
59
-
60
- def _run(
61
- self, cmd: list[str], check: bool = True, capture: bool | None = None
62
- ) -> subprocess.CompletedProcess:
63
- return self._exec.execute(cmd, check=check, capture=capture)
64
-
65
- def _launch_process(self, cmd: list[str], capture: bool | None = None) -> Process:
66
- return self._exec.launch_process(cmd, capture=capture)
67
-
68
- def webgui(self, other_args: list[str] | None = None) -> Process:
69
- """Launch the Rclone web GUI."""
70
- cmd = ["rcd", "--rc-web-gui"]
71
- if other_args:
72
- cmd += other_args
73
- return self._launch_process(cmd, capture=False)
74
-
75
- def launch_server(
76
- self,
77
- addr: str | None = None,
78
- user: str | None = None,
79
- password: str | None = None,
80
- other_args: list[str] | None = None,
81
- ) -> Process:
82
- """Launch the Rclone server so it can receive commands"""
83
- cmd = ["rcd"]
84
- if addr is not None:
85
- cmd += ["--rc-addr", addr]
86
- if user is not None:
87
- cmd += ["--rc-user", user]
88
- if password is not None:
89
- cmd += ["--rc-pass", password]
90
- if other_args:
91
- cmd += other_args
92
- out = self._launch_process(cmd, capture=False)
93
- time.sleep(1) # Give it some time to launch
94
- return out
95
-
96
- def remote_control(
97
- self,
98
- addr: str,
99
- user: str | None = None,
100
- password: str | None = None,
101
- capture: bool | None = None,
102
- other_args: list[str] | None = None,
103
- ) -> CompletedProcess:
104
- cmd = ["rc"]
105
- if addr:
106
- cmd += ["--rc-addr", addr]
107
- if user is not None:
108
- cmd += ["--rc-user", user]
109
- if password is not None:
110
- cmd += ["--rc-pass", password]
111
- if other_args:
112
- cmd += other_args
113
- cp = self._run(cmd, capture=capture)
114
- return CompletedProcess.from_subprocess(cp)
115
-
116
- def obscure(self, password: str) -> str:
117
- """Obscure a password for use in rclone config files."""
118
- cmd_list: list[str] = ["obscure", password]
119
- cp = self._run(cmd_list)
120
- return cp.stdout.strip()
121
-
122
- def ls(
123
- self,
124
- path: Dir | Remote | str,
125
- max_depth: int | None = None,
126
- glob: str | None = None,
127
- ) -> DirListing:
128
- """List files in the given path.
129
-
130
- Args:
131
- path: Remote path or Remote object to list
132
- max_depth: Maximum recursion depth (0 means no recursion)
133
-
134
- Returns:
135
- List of File objects found at the path
136
- """
137
-
138
- if isinstance(path, str):
139
- path = Dir(
140
- to_path(path, self)
141
- ) # assume it's a directory if ls is being called.
142
-
143
- cmd = ["lsjson"]
144
- if max_depth is not None:
145
- if max_depth < 0:
146
- cmd.append("--recursive")
147
- if max_depth > 0:
148
- cmd.append("--max-depth")
149
- cmd.append(str(max_depth))
150
- cmd.append(str(path))
151
- remote = path.remote if isinstance(path, Dir) else path
152
- assert isinstance(remote, Remote)
153
-
154
- cp = self._run(cmd, check=True)
155
- text = cp.stdout
156
- parent_path: str | None = None
157
- if isinstance(path, Dir):
158
- parent_path = path.path.path
159
- paths: list[RPath] = RPath.from_json_str(text, remote, parent_path=parent_path)
160
- # print(parent_path)
161
- for o in paths:
162
- o.set_rclone(self)
163
-
164
- # do we have a glob pattern?
165
- if glob is not None:
166
- paths = [p for p in paths if fnmatch(p.path, glob)]
167
- return DirListing(paths)
168
-
169
- def listremotes(self) -> list[Remote]:
170
- cmd = ["listremotes"]
171
- cp = self._run(cmd)
172
- text: str = cp.stdout
173
- tmp = text.splitlines()
174
- tmp = [t.strip() for t in tmp]
175
- # strip out ":" from the end
176
- tmp = [t.replace(":", "") for t in tmp]
177
- out = [Remote(name=t, rclone=self) for t in tmp]
178
- return out
179
-
180
- def diff(self, src: str, dst: str) -> Generator[DiffItem, None, None]:
181
- """Be extra careful with the src and dst values. If you are off by one
182
- parent directory, you will get a huge amount of false diffs."""
183
- cmd = [
184
- "check",
185
- src,
186
- dst,
187
- "--checkers",
188
- "1000",
189
- "--log-level",
190
- "INFO",
191
- "--combined",
192
- "-",
193
- ]
194
- proc = self._launch_process(cmd, capture=True)
195
- item: DiffItem
196
- for item in diff_stream_from_running_process(proc, src_slug=src, dst_slug=dst):
197
- if item is None:
198
- break
199
- yield item
200
-
201
- def walk(
202
- self, path: Dir | Remote | str, max_depth: int = -1, breadth_first: bool = True
203
- ) -> Generator[DirListing, None, None]:
204
- """Walk through the given path recursively.
205
-
206
- Args:
207
- path: Remote path or Remote object to walk through
208
- max_depth: Maximum depth to traverse (-1 for unlimited)
209
-
210
- Yields:
211
- DirListing: Directory listing for each directory encountered
212
- """
213
- dir_obj: Dir
214
- if isinstance(path, Dir):
215
- # Create a Remote object for the path
216
- remote = path.remote
217
- rpath = RPath(
218
- remote=remote,
219
- path=path.path.path,
220
- name=path.path.name,
221
- size=0,
222
- mime_type="inode/directory",
223
- mod_time="",
224
- is_dir=True,
225
- )
226
- rpath.set_rclone(self)
227
- dir_obj = Dir(rpath)
228
- elif isinstance(path, str):
229
- dir_obj = Dir(to_path(path, self))
230
- elif isinstance(path, Remote):
231
- dir_obj = Dir(path)
232
- else:
233
- dir_obj = Dir(path) # shut up pyright
234
- assert f"Invalid type for path: {type(path)}"
235
-
236
- yield from walk(dir_obj, max_depth=max_depth, breadth_first=breadth_first)
237
-
238
- def cleanup(
239
- self, path: str, other_args: list[str] | None = None
240
- ) -> CompletedProcess:
241
- """Cleanup any resources used by the Rclone instance."""
242
- # rclone cleanup remote:path [flags]
243
- cmd = ["cleanup", path]
244
- if other_args:
245
- cmd += other_args
246
- out = self._run(cmd)
247
- return CompletedProcess.from_subprocess(out)
248
-
249
- def copyfile(self, src: File | str, dst: File | str) -> None:
250
- """Copy a single file from source to destination.
251
-
252
- Args:
253
- src: Source file path (including remote if applicable)
254
- dst: Destination file path (including remote if applicable)
255
-
256
- Raises:
257
- subprocess.CalledProcessError: If the copy operation fails
258
- """
259
- src = src if isinstance(src, str) else str(src.path)
260
- dst = dst if isinstance(dst, str) else str(dst.path)
261
- cmd_list: list[str] = ["copyto", src, dst]
262
- self._run(cmd_list)
263
-
264
- def copy_to(
265
- self,
266
- src: File | str,
267
- dst: File | str,
268
- check=True,
269
- other_args: list[str] | None = None,
270
- ) -> None:
271
- """Copy multiple files from source to destination.
272
-
273
- Warning - slow.
274
-
275
- Args:
276
- payload: Dictionary of source and destination file paths
277
- """
278
- src = str(src)
279
- dst = str(dst)
280
- cmd_list: list[str] = ["copyto", src, dst]
281
- if other_args is not None:
282
- cmd_list += other_args
283
- self._run(cmd_list, check=check)
284
-
285
- def copyfiles(
286
- self,
287
- files: str | File | list[str] | list[File],
288
- check=True,
289
- other_args: list[str] | None = None,
290
- ) -> list[CompletedProcess]:
291
- """Copy multiple files from source to destination.
292
-
293
- Warning - slow.
294
-
295
- Args:
296
- payload: Dictionary of source and destination file paths
297
- """
298
- payload: list[str] = convert_to_filestr_list(files)
299
- if len(payload) == 0:
300
- return []
301
-
302
- datalists: dict[str, list[str]] = group_files(payload)
303
- # out: subprocess.CompletedProcess | None = None
304
- out: list[CompletedProcess] = []
305
-
306
- futures: list[Future] = []
307
-
308
- for remote, files in datalists.items():
309
-
310
- def _task(files=files) -> subprocess.CompletedProcess:
311
- with TemporaryDirectory() as tmpdir:
312
- include_files_txt = Path(tmpdir) / "include_files.txt"
313
- include_files_txt.write_text("\n".join(files), encoding="utf-8")
314
-
315
- # print(include_files_txt)
316
- cmd_list: list[str] = [
317
- "copy",
318
- remote,
319
- "--files-from",
320
- str(include_files_txt),
321
- "--checkers",
322
- "1000",
323
- "--transfers",
324
- "1000",
325
- ]
326
- if other_args is not None:
327
- cmd_list += other_args
328
- out = self._run(cmd_list)
329
- return out
330
-
331
- fut: Future = EXECUTOR.submit(_task)
332
- futures.append(fut)
333
- for fut in futures:
334
- cp: subprocess.CompletedProcess = fut.result()
335
- assert cp is not None
336
- out.append(CompletedProcess.from_subprocess(cp))
337
- if cp.returncode != 0:
338
- if check:
339
- raise ValueError(f"Error deleting files: {cp.stderr}")
340
- else:
341
- warnings.warn(f"Error deleting files: {cp.stderr}")
342
- return out
343
-
344
- def copy(self, src: Dir | str, dst: Dir | str) -> CompletedProcess:
345
- """Copy files from source to destination.
346
-
347
- Args:
348
- src: Source directory
349
- dst: Destination directory
350
- """
351
- # src_dir = src.path.path
352
- # dst_dir = dst.path.path
353
- src_dir = convert_to_str(src)
354
- dst_dir = convert_to_str(dst)
355
- cmd_list: list[str] = ["copy", src_dir, dst_dir]
356
- cp = self._run(cmd_list)
357
- return CompletedProcess.from_subprocess(cp)
358
-
359
- def purge(self, path: Dir | str) -> CompletedProcess:
360
- """Purge a directory"""
361
- # path should always be a string
362
- path = path if isinstance(path, str) else str(path.path)
363
- cmd_list: list[str] = ["purge", str(path)]
364
- cp = self._run(cmd_list)
365
- return CompletedProcess.from_subprocess(cp)
366
-
367
- def delete_files(
368
- self,
369
- files: str | File | list[str] | list[File],
370
- check=True,
371
- rmdirs=False,
372
- verbose: bool | None = None,
373
- other_args: list[str] | None = None,
374
- ) -> CompletedProcess:
375
- """Delete a directory"""
376
- payload: list[str] = convert_to_filestr_list(files)
377
- if len(payload) == 0:
378
- cp = subprocess.CompletedProcess(
379
- args=["rclone", "delete", "--files-from", "[]"],
380
- returncode=0,
381
- stdout="",
382
- stderr="",
383
- )
384
- return CompletedProcess.from_subprocess(cp)
385
-
386
- datalists: dict[str, list[str]] = group_files(payload)
387
- completed_processes: list[subprocess.CompletedProcess] = []
388
- verbose = get_verbose(verbose)
389
-
390
- futures: list[Future] = []
391
-
392
- for remote, files in datalists.items():
393
-
394
- def _task(files=files, check=check) -> subprocess.CompletedProcess:
395
- with TemporaryDirectory() as tmpdir:
396
- include_files_txt = Path(tmpdir) / "include_files.txt"
397
- include_files_txt.write_text("\n".join(files), encoding="utf-8")
398
-
399
- # print(include_files_txt)
400
- cmd_list: list[str] = [
401
- "delete",
402
- remote,
403
- "--files-from",
404
- str(include_files_txt),
405
- "--checkers",
406
- "1000",
407
- "--transfers",
408
- "1000",
409
- ]
410
- if verbose:
411
- cmd_list.append("-vvvv")
412
- if rmdirs:
413
- cmd_list.append("--rmdirs")
414
- if other_args:
415
- cmd_list += other_args
416
- out = self._run(cmd_list, check=check)
417
- if out.returncode != 0:
418
- if check:
419
- completed_processes.append(out)
420
- raise ValueError(f"Error deleting files: {out}")
421
- else:
422
- warnings.warn(f"Error deleting files: {out}")
423
- return out
424
-
425
- fut: Future = EXECUTOR.submit(_task)
426
- futures.append(fut)
427
-
428
- for fut in futures:
429
- out = fut.result()
430
- assert out is not None
431
- completed_processes.append(out)
432
-
433
- return CompletedProcess(completed_processes)
434
-
435
- @deprecated("delete_files")
436
- def deletefiles(
437
- self, files: str | File | list[str] | list[File]
438
- ) -> CompletedProcess:
439
- out = self.delete_files(files)
440
- return out
441
-
442
- def exists(self, path: Dir | Remote | str | File) -> bool:
443
- """Check if a file or directory exists."""
444
- arg: str = convert_to_str(path)
445
- assert isinstance(arg, str)
446
- try:
447
- dir_listing = self.ls(arg)
448
- # print(dir_listing)
449
- return len(dir_listing.dirs) > 0 or len(dir_listing.files) > 0
450
- except subprocess.CalledProcessError:
451
- return False
452
-
453
- def is_synced(self, src: str | Dir, dst: str | Dir) -> bool:
454
- """Check if two directories are in sync."""
455
- src = convert_to_str(src)
456
- dst = convert_to_str(dst)
457
- cmd_list: list[str] = ["check", str(src), str(dst)]
458
- try:
459
- self._run(cmd_list)
460
- return True
461
- except subprocess.CalledProcessError:
462
- return False
463
-
464
- def copy_dir(
465
- self, src: str | Dir, dst: str | Dir, args: list[str] | None = None
466
- ) -> CompletedProcess:
467
- """Copy a directory from source to destination."""
468
- # convert src to str, also dst
469
- src = convert_to_str(src)
470
- dst = convert_to_str(dst)
471
- cmd_list: list[str] = ["copy", src, dst]
472
- if args is not None:
473
- cmd_list += args
474
- cp = self._run(cmd_list)
475
- return CompletedProcess.from_subprocess(cp)
476
-
477
- def copy_remote(
478
- self, src: Remote, dst: Remote, args: list[str] | None = None
479
- ) -> CompletedProcess:
480
- """Copy a remote to another remote."""
481
- cmd_list: list[str] = ["copy", str(src), str(dst)]
482
- if args is not None:
483
- cmd_list += args
484
- # return self._run(cmd_list)
485
- cp = self._run(cmd_list)
486
- return CompletedProcess.from_subprocess(cp)
487
-
488
- def mount(
489
- self,
490
- src: Remote | Dir | str,
491
- outdir: Path,
492
- allow_writes=False,
493
- use_links=True,
494
- vfs_cache_mode="full",
495
- other_cmds: list[str] | None = None,
496
- ) -> Process:
497
- """Mount a remote or directory to a local path.
498
-
499
- Args:
500
- src: Remote or directory to mount
501
- outdir: Local path to mount to
502
-
503
- Returns:
504
- CompletedProcess from the mount command execution
505
-
506
- Raises:
507
- subprocess.CalledProcessError: If the mount operation fails
508
- """
509
- if outdir.exists():
510
- is_empty = not list(outdir.iterdir())
511
- if not is_empty:
512
- raise ValueError(
513
- f"Mount directory already exists and is not empty: {outdir}"
514
- )
515
- outdir.rmdir()
516
- try:
517
- outdir.parent.mkdir(parents=True, exist_ok=True)
518
- except PermissionError:
519
- warnings.warn(
520
- f"Permission error creating parent directory: {outdir.parent}"
521
- )
522
- src_str = convert_to_str(src)
523
- cmd_list: list[str] = ["mount", src_str, str(outdir)]
524
- if not allow_writes:
525
- cmd_list.append("--read-only")
526
- if use_links:
527
- cmd_list.append("--links")
528
- if vfs_cache_mode:
529
- cmd_list.append("--vfs-cache-mode")
530
- cmd_list.append(vfs_cache_mode)
531
- if other_cmds:
532
- cmd_list += other_cmds
533
- proc = self._launch_process(cmd_list)
534
- wait_for_mount(outdir, proc)
535
- return proc
536
-
537
- def mount_webdav(
538
- self,
539
- url: str,
540
- outdir: Path,
541
- vfs_cache_mode="full",
542
- vfs_disk_space_total_size: str | None = "10G",
543
- other_cmds: list[str] | None = None,
544
- ) -> Process:
545
- """Mount a remote or directory to a local path.
546
-
547
- Args:
548
- src: Remote or directory to mount
549
- outdir: Local path to mount to
550
-
551
- Returns:
552
- CompletedProcess from the mount command execution
553
-
554
- Raises:
555
- subprocess.CalledProcessError: If the mount operation fails
556
- """
557
- if outdir.exists():
558
- is_empty = not list(outdir.iterdir())
559
- if not is_empty:
560
- raise ValueError(
561
- f"Mount directory already exists and is not empty: {outdir}"
562
- )
563
- outdir.rmdir()
564
-
565
- src_str = url
566
- cmd_list: list[str] = ["mount", src_str, str(outdir)]
567
- cmd_list.append("--vfs-cache-mode")
568
- cmd_list.append(vfs_cache_mode)
569
- if other_cmds:
570
- cmd_list += other_cmds
571
- if vfs_disk_space_total_size is not None:
572
- cmd_list.append("--vfs-cache-max-size")
573
- cmd_list.append(vfs_disk_space_total_size)
574
- proc = self._launch_process(cmd_list)
575
- wait_for_mount(outdir, proc)
576
- return proc
577
-
578
- def mount_s3(
579
- self,
580
- url: str,
581
- outdir: Path,
582
- allow_writes=False,
583
- vfs_cache_mode="full",
584
- # dir-cache-time
585
- dir_cache_time: str | None = "1h",
586
- attribute_timeout: str | None = "1h",
587
- # --vfs-cache-max-size
588
- # vfs-cache-max-size
589
- vfs_disk_space_total_size: str | None = "100M",
590
- transfers: int | None = 128,
591
- modtime_strategy: (
592
- ModTimeStrategy | None
593
- ) = ModTimeStrategy.USE_SERVER_MODTIME, # speeds up S3 operations
594
- vfs_read_chunk_streams: int | None = 16,
595
- vfs_read_chunk_size: str | None = "4M",
596
- vfs_fast_fingerprint: bool = True,
597
- # vfs-refresh
598
- vfs_refresh: bool = True,
599
- other_cmds: list[str] | None = None,
600
- ) -> Process:
601
- """Mount a remote or directory to a local path.
602
-
603
- Args:
604
- src: Remote or directory to mount
605
- outdir: Local path to mount to
606
- """
607
- other_cmds = other_cmds or []
608
- if modtime_strategy is not None:
609
- other_cmds.append(f"--{modtime_strategy.value}")
610
- if (vfs_cache_mode == "full" or vfs_cache_mode == "writes") and (
611
- transfers is not None and "--transfers" not in other_cmds
612
- ):
613
- other_cmds.append("--transfers")
614
- other_cmds.append(str(transfers))
615
- if dir_cache_time is not None and "--dir-cache-time" not in other_cmds:
616
- other_cmds.append("--dir-cache-time")
617
- other_cmds.append(dir_cache_time)
618
- if (
619
- vfs_disk_space_total_size is not None
620
- and "--vfs-cache-max-size" not in other_cmds
621
- ):
622
- other_cmds.append("--vfs-cache-max-size")
623
- other_cmds.append(vfs_disk_space_total_size)
624
- if vfs_refresh and "--vfs-refresh" not in other_cmds:
625
- other_cmds.append("--vfs-refresh")
626
- if attribute_timeout is not None and "--attr-timeout" not in other_cmds:
627
- other_cmds.append("--attr-timeout")
628
- other_cmds.append(attribute_timeout)
629
- if vfs_read_chunk_streams:
630
- other_cmds.append("--vfs-read-chunk-streams")
631
- other_cmds.append(str(vfs_read_chunk_streams))
632
- if vfs_read_chunk_size:
633
- other_cmds.append("--vfs-read-chunk-size")
634
- other_cmds.append(vfs_read_chunk_size)
635
- if vfs_fast_fingerprint:
636
- other_cmds.append("--vfs-fast-fingerprint")
637
-
638
- other_cmds = other_cmds if other_cmds else None
639
- return self.mount(
640
- url,
641
- outdir,
642
- allow_writes=allow_writes,
643
- vfs_cache_mode=vfs_cache_mode,
644
- other_cmds=other_cmds,
645
- )
646
-
647
- def serve_webdav(
648
- self,
649
- src: Remote | Dir | str,
650
- user: str,
651
- password: str,
652
- addr: str = "localhost:2049",
653
- allow_other: bool = False,
654
- ) -> Process:
655
- """Serve a remote or directory via NFS.
656
-
657
- Args:
658
- src: Remote or directory to serve
659
- addr: Network address and port to serve on (default: localhost:2049)
660
- allow_other: Allow other users to access the share
661
-
662
- Returns:
663
- Process: The running NFS server process
664
-
665
- Raises:
666
- ValueError: If the NFS server fails to start
667
- """
668
- src_str = convert_to_str(src)
669
- cmd_list: list[str] = ["serve", "webdav", "--addr", addr, src_str]
670
- cmd_list.extend(["--user", user, "--pass", password])
671
- if allow_other:
672
- cmd_list.append("--allow-other")
673
- proc = self._launch_process(cmd_list)
674
- time.sleep(2) # give it a moment to start
675
- if proc.poll() is not None:
676
- raise ValueError("NFS serve process failed to start")
677
- return proc
1
+ """
2
+ Unit test file.
3
+ """
4
+
5
+ import os
6
+ import subprocess
7
+ import time
8
+ import warnings
9
+ from concurrent.futures import Future, ThreadPoolExecutor
10
+ from enum import Enum
11
+ from fnmatch import fnmatch
12
+ from pathlib import Path
13
+ from tempfile import TemporaryDirectory
14
+ from typing import Generator
15
+
16
+ from rclone_api import Dir
17
+ from rclone_api.completed_process import CompletedProcess
18
+ from rclone_api.config import Config
19
+ from rclone_api.convert import convert_to_filestr_list, convert_to_str
20
+ from rclone_api.deprecated import deprecated
21
+ from rclone_api.diff import DiffItem, diff_stream_from_running_process
22
+ from rclone_api.dir_listing import DirListing
23
+ from rclone_api.exec import RcloneExec
24
+ from rclone_api.file import File
25
+ from rclone_api.group_files import group_files
26
+ from rclone_api.process import Process
27
+ from rclone_api.remote import Remote
28
+ from rclone_api.rpath import RPath
29
+ from rclone_api.util import (
30
+ get_rclone_exe,
31
+ get_verbose,
32
+ to_path,
33
+ wait_for_mount,
34
+ )
35
+ from rclone_api.walk import walk
36
+
37
+ EXECUTOR = ThreadPoolExecutor(16)
38
+
39
+
40
+ def rclone_verbose(verbose: bool | None) -> bool:
41
+ if verbose is not None:
42
+ os.environ["RCLONE_API_VERBOSE"] = "1" if verbose else "0"
43
+ return bool(int(os.getenv("RCLONE_API_VERBOSE", "0")))
44
+
45
+
46
+ class ModTimeStrategy(Enum):
47
+ USE_SERVER_MODTIME = "use-server-modtime"
48
+ NO_MODTIME = "no-modtime"
49
+
50
+
51
+ class Rclone:
52
+ def __init__(
53
+ self, rclone_conf: Path | Config, rclone_exe: Path | None = None
54
+ ) -> None:
55
+ if isinstance(rclone_conf, Path):
56
+ if not rclone_conf.exists():
57
+ raise ValueError(f"Rclone config file not found: {rclone_conf}")
58
+ self._exec = RcloneExec(rclone_conf, get_rclone_exe(rclone_exe))
59
+
60
+ def _run(
61
+ self, cmd: list[str], check: bool = True, capture: bool | None = None
62
+ ) -> subprocess.CompletedProcess:
63
+ return self._exec.execute(cmd, check=check, capture=capture)
64
+
65
+ def _launch_process(self, cmd: list[str], capture: bool | None = None) -> Process:
66
+ return self._exec.launch_process(cmd, capture=capture)
67
+
68
+ def webgui(self, other_args: list[str] | None = None) -> Process:
69
+ """Launch the Rclone web GUI."""
70
+ cmd = ["rcd", "--rc-web-gui"]
71
+ if other_args:
72
+ cmd += other_args
73
+ return self._launch_process(cmd, capture=False)
74
+
75
+ def launch_server(
76
+ self,
77
+ addr: str | None = None,
78
+ user: str | None = None,
79
+ password: str | None = None,
80
+ other_args: list[str] | None = None,
81
+ ) -> Process:
82
+ """Launch the Rclone server so it can receive commands"""
83
+ cmd = ["rcd"]
84
+ if addr is not None:
85
+ cmd += ["--rc-addr", addr]
86
+ if user is not None:
87
+ cmd += ["--rc-user", user]
88
+ if password is not None:
89
+ cmd += ["--rc-pass", password]
90
+ if other_args:
91
+ cmd += other_args
92
+ out = self._launch_process(cmd, capture=False)
93
+ time.sleep(1) # Give it some time to launch
94
+ return out
95
+
96
+ def remote_control(
97
+ self,
98
+ addr: str,
99
+ user: str | None = None,
100
+ password: str | None = None,
101
+ capture: bool | None = None,
102
+ other_args: list[str] | None = None,
103
+ ) -> CompletedProcess:
104
+ cmd = ["rc"]
105
+ if addr:
106
+ cmd += ["--rc-addr", addr]
107
+ if user is not None:
108
+ cmd += ["--rc-user", user]
109
+ if password is not None:
110
+ cmd += ["--rc-pass", password]
111
+ if other_args:
112
+ cmd += other_args
113
+ cp = self._run(cmd, capture=capture)
114
+ return CompletedProcess.from_subprocess(cp)
115
+
116
+ def obscure(self, password: str) -> str:
117
+ """Obscure a password for use in rclone config files."""
118
+ cmd_list: list[str] = ["obscure", password]
119
+ cp = self._run(cmd_list)
120
+ return cp.stdout.strip()
121
+
122
+ def ls(
123
+ self,
124
+ path: Dir | Remote | str,
125
+ max_depth: int | None = None,
126
+ glob: str | None = None,
127
+ ) -> DirListing:
128
+ """List files in the given path.
129
+
130
+ Args:
131
+ path: Remote path or Remote object to list
132
+ max_depth: Maximum recursion depth (0 means no recursion)
133
+
134
+ Returns:
135
+ List of File objects found at the path
136
+ """
137
+
138
+ if isinstance(path, str):
139
+ path = Dir(
140
+ to_path(path, self)
141
+ ) # assume it's a directory if ls is being called.
142
+
143
+ cmd = ["lsjson"]
144
+ if max_depth is not None:
145
+ if max_depth < 0:
146
+ cmd.append("--recursive")
147
+ if max_depth > 0:
148
+ cmd.append("--max-depth")
149
+ cmd.append(str(max_depth))
150
+ cmd.append(str(path))
151
+ remote = path.remote if isinstance(path, Dir) else path
152
+ assert isinstance(remote, Remote)
153
+
154
+ cp = self._run(cmd, check=True)
155
+ text = cp.stdout
156
+ parent_path: str | None = None
157
+ if isinstance(path, Dir):
158
+ parent_path = path.path.path
159
+ paths: list[RPath] = RPath.from_json_str(text, remote, parent_path=parent_path)
160
+ # print(parent_path)
161
+ for o in paths:
162
+ o.set_rclone(self)
163
+
164
+ # do we have a glob pattern?
165
+ if glob is not None:
166
+ paths = [p for p in paths if fnmatch(p.path, glob)]
167
+ return DirListing(paths)
168
+
169
+ def listremotes(self) -> list[Remote]:
170
+ cmd = ["listremotes"]
171
+ cp = self._run(cmd)
172
+ text: str = cp.stdout
173
+ tmp = text.splitlines()
174
+ tmp = [t.strip() for t in tmp]
175
+ # strip out ":" from the end
176
+ tmp = [t.replace(":", "") for t in tmp]
177
+ out = [Remote(name=t, rclone=self) for t in tmp]
178
+ return out
179
+
180
+ def diff(self, src: str, dst: str) -> Generator[DiffItem, None, None]:
181
+ """Be extra careful with the src and dst values. If you are off by one
182
+ parent directory, you will get a huge amount of false diffs."""
183
+ cmd = [
184
+ "check",
185
+ src,
186
+ dst,
187
+ "--checkers",
188
+ "1000",
189
+ "--log-level",
190
+ "INFO",
191
+ "--combined",
192
+ "-",
193
+ ]
194
+ proc = self._launch_process(cmd, capture=True)
195
+ item: DiffItem
196
+ for item in diff_stream_from_running_process(proc, src_slug=src, dst_slug=dst):
197
+ if item is None:
198
+ break
199
+ yield item
200
+
201
+ def walk(
202
+ self, path: Dir | Remote | str, max_depth: int = -1, breadth_first: bool = True
203
+ ) -> Generator[DirListing, None, None]:
204
+ """Walk through the given path recursively.
205
+
206
+ Args:
207
+ path: Remote path or Remote object to walk through
208
+ max_depth: Maximum depth to traverse (-1 for unlimited)
209
+
210
+ Yields:
211
+ DirListing: Directory listing for each directory encountered
212
+ """
213
+ dir_obj: Dir
214
+ if isinstance(path, Dir):
215
+ # Create a Remote object for the path
216
+ remote = path.remote
217
+ rpath = RPath(
218
+ remote=remote,
219
+ path=path.path.path,
220
+ name=path.path.name,
221
+ size=0,
222
+ mime_type="inode/directory",
223
+ mod_time="",
224
+ is_dir=True,
225
+ )
226
+ rpath.set_rclone(self)
227
+ dir_obj = Dir(rpath)
228
+ elif isinstance(path, str):
229
+ dir_obj = Dir(to_path(path, self))
230
+ elif isinstance(path, Remote):
231
+ dir_obj = Dir(path)
232
+ else:
233
+ dir_obj = Dir(path) # shut up pyright
234
+ assert f"Invalid type for path: {type(path)}"
235
+
236
+ yield from walk(dir_obj, max_depth=max_depth, breadth_first=breadth_first)
237
+
238
+ def cleanup(
239
+ self, path: str, other_args: list[str] | None = None
240
+ ) -> CompletedProcess:
241
+ """Cleanup any resources used by the Rclone instance."""
242
+ # rclone cleanup remote:path [flags]
243
+ cmd = ["cleanup", path]
244
+ if other_args:
245
+ cmd += other_args
246
+ out = self._run(cmd)
247
+ return CompletedProcess.from_subprocess(out)
248
+
249
+ def copy_to(
250
+ self,
251
+ src: File | str,
252
+ dst: File | str,
253
+ check=True,
254
+ other_args: list[str] | None = None,
255
+ ) -> None:
256
+ """Copy multiple files from source to destination.
257
+
258
+ Warning - slow.
259
+
260
+ Args:
261
+ payload: Dictionary of source and destination file paths
262
+ """
263
+ src = src if isinstance(src, str) else str(src.path)
264
+ dst = dst if isinstance(dst, str) else str(dst.path)
265
+ cmd_list: list[str] = ["copyto", src, dst]
266
+ if other_args is not None:
267
+ cmd_list += other_args
268
+ self._run(cmd_list, check=check)
269
+
270
+ def copy_files(
271
+ self,
272
+ src: str,
273
+ dst: str,
274
+ files: list[str],
275
+ check=True,
276
+ verbose: bool | None = None,
277
+ other_args: list[str] | None = None,
278
+ ) -> list[CompletedProcess]:
279
+ """Copy multiple files from source to destination.
280
+
281
+ Args:
282
+ payload: Dictionary of source and destination file paths
283
+ """
284
+ verbose = get_verbose(verbose)
285
+ payload: list[str] = convert_to_filestr_list(files)
286
+ if len(payload) == 0:
287
+ return []
288
+
289
+ datalists: dict[str, list[str]] = group_files(payload, fully_qualified=False)
290
+ # out: subprocess.CompletedProcess | None = None
291
+ out: list[CompletedProcess] = []
292
+
293
+ futures: list[Future] = []
294
+
295
+ for common_prefix, files in datalists.items():
296
+
297
+ def _task(files=files) -> subprocess.CompletedProcess:
298
+ if verbose:
299
+ nfiles = len(files)
300
+ files_str = "\n".join(files)
301
+ print(f"Copying {nfiles} files: \n{files_str}")
302
+ with TemporaryDirectory() as tmpdir:
303
+ include_files_txt = Path(tmpdir) / "include_files.txt"
304
+ include_files_txt.write_text("\n".join(files), encoding="utf-8")
305
+ if common_prefix:
306
+ src_path = f"{src}/{common_prefix}"
307
+ dst_path = f"{dst}/{common_prefix}"
308
+ else:
309
+ src_path = src
310
+ dst_path = dst
311
+ # print(include_files_txt)
312
+ cmd_list: list[str] = [
313
+ "copy",
314
+ src_path,
315
+ dst_path,
316
+ "--files-from",
317
+ str(include_files_txt),
318
+ "--checkers",
319
+ "1000",
320
+ "--transfers",
321
+ "1000",
322
+ ]
323
+ if verbose:
324
+ cmd_list.append("-vvvv")
325
+ if other_args is not None:
326
+ cmd_list += other_args
327
+ out = self._run(cmd_list)
328
+ return out
329
+
330
+ fut: Future = EXECUTOR.submit(_task)
331
+ futures.append(fut)
332
+ for fut in futures:
333
+ cp: subprocess.CompletedProcess = fut.result()
334
+ assert cp is not None
335
+ out.append(CompletedProcess.from_subprocess(cp))
336
+ if cp.returncode != 0:
337
+ if check:
338
+ raise ValueError(f"Error deleting files: {cp.stderr}")
339
+ else:
340
+ warnings.warn(f"Error deleting files: {cp.stderr}")
341
+ return out
342
+
343
+ def copy(self, src: Dir | str, dst: Dir | str) -> CompletedProcess:
344
+ """Copy files from source to destination.
345
+
346
+ Args:
347
+ src: Source directory
348
+ dst: Destination directory
349
+ """
350
+ # src_dir = src.path.path
351
+ # dst_dir = dst.path.path
352
+ src_dir = convert_to_str(src)
353
+ dst_dir = convert_to_str(dst)
354
+ cmd_list: list[str] = ["copy", src_dir, dst_dir]
355
+ cp = self._run(cmd_list)
356
+ return CompletedProcess.from_subprocess(cp)
357
+
358
+ def purge(self, path: Dir | str) -> CompletedProcess:
359
+ """Purge a directory"""
360
+ # path should always be a string
361
+ path = path if isinstance(path, str) else str(path.path)
362
+ cmd_list: list[str] = ["purge", str(path)]
363
+ cp = self._run(cmd_list)
364
+ return CompletedProcess.from_subprocess(cp)
365
+
366
+ def delete_files(
367
+ self,
368
+ files: str | File | list[str] | list[File],
369
+ check=True,
370
+ rmdirs=False,
371
+ verbose: bool | None = None,
372
+ other_args: list[str] | None = None,
373
+ ) -> CompletedProcess:
374
+ """Delete a directory"""
375
+ verbose = get_verbose(verbose)
376
+ payload: list[str] = convert_to_filestr_list(files)
377
+ if len(payload) == 0:
378
+ if verbose:
379
+ print("No files to delete")
380
+ cp = subprocess.CompletedProcess(
381
+ args=["rclone", "delete", "--files-from", "[]"],
382
+ returncode=0,
383
+ stdout="",
384
+ stderr="",
385
+ )
386
+ return CompletedProcess.from_subprocess(cp)
387
+
388
+ datalists: dict[str, list[str]] = group_files(payload)
389
+ completed_processes: list[subprocess.CompletedProcess] = []
390
+
391
+ futures: list[Future] = []
392
+
393
+ for remote, files in datalists.items():
394
+
395
+ def _task(
396
+ files=files, check=check, remote=remote
397
+ ) -> subprocess.CompletedProcess:
398
+ with TemporaryDirectory() as tmpdir:
399
+ include_files_txt = Path(tmpdir) / "include_files.txt"
400
+ include_files_txt.write_text("\n".join(files), encoding="utf-8")
401
+
402
+ # print(include_files_txt)
403
+ cmd_list: list[str] = [
404
+ "delete",
405
+ remote,
406
+ "--files-from",
407
+ str(include_files_txt),
408
+ "--checkers",
409
+ "1000",
410
+ "--transfers",
411
+ "1000",
412
+ ]
413
+ if verbose:
414
+ cmd_list.append("-vvvv")
415
+ if rmdirs:
416
+ cmd_list.append("--rmdirs")
417
+ if other_args:
418
+ cmd_list += other_args
419
+ out = self._run(cmd_list, check=check)
420
+ if out.returncode != 0:
421
+ if check:
422
+ completed_processes.append(out)
423
+ raise ValueError(f"Error deleting files: {out}")
424
+ else:
425
+ warnings.warn(f"Error deleting files: {out}")
426
+ return out
427
+
428
+ fut: Future = EXECUTOR.submit(_task)
429
+ futures.append(fut)
430
+
431
+ for fut in futures:
432
+ out = fut.result()
433
+ assert out is not None
434
+ completed_processes.append(out)
435
+
436
+ return CompletedProcess(completed_processes)
437
+
438
+ @deprecated("delete_files")
439
+ def deletefiles(
440
+ self, files: str | File | list[str] | list[File]
441
+ ) -> CompletedProcess:
442
+ out = self.delete_files(files)
443
+ return out
444
+
445
+ def exists(self, path: Dir | Remote | str | File) -> bool:
446
+ """Check if a file or directory exists."""
447
+ arg: str = convert_to_str(path)
448
+ assert isinstance(arg, str)
449
+ try:
450
+ dir_listing = self.ls(arg)
451
+ # print(dir_listing)
452
+ return len(dir_listing.dirs) > 0 or len(dir_listing.files) > 0
453
+ except subprocess.CalledProcessError:
454
+ return False
455
+
456
+ def is_synced(self, src: str | Dir, dst: str | Dir) -> bool:
457
+ """Check if two directories are in sync."""
458
+ src = convert_to_str(src)
459
+ dst = convert_to_str(dst)
460
+ cmd_list: list[str] = ["check", str(src), str(dst)]
461
+ try:
462
+ self._run(cmd_list)
463
+ return True
464
+ except subprocess.CalledProcessError:
465
+ return False
466
+
467
+ def copy_dir(
468
+ self, src: str | Dir, dst: str | Dir, args: list[str] | None = None
469
+ ) -> CompletedProcess:
470
+ """Copy a directory from source to destination."""
471
+ # convert src to str, also dst
472
+ src = convert_to_str(src)
473
+ dst = convert_to_str(dst)
474
+ cmd_list: list[str] = ["copy", src, dst]
475
+ if args is not None:
476
+ cmd_list += args
477
+ cp = self._run(cmd_list)
478
+ return CompletedProcess.from_subprocess(cp)
479
+
480
+ def copy_remote(
481
+ self, src: Remote, dst: Remote, args: list[str] | None = None
482
+ ) -> CompletedProcess:
483
+ """Copy a remote to another remote."""
484
+ cmd_list: list[str] = ["copy", str(src), str(dst)]
485
+ if args is not None:
486
+ cmd_list += args
487
+ # return self._run(cmd_list)
488
+ cp = self._run(cmd_list)
489
+ return CompletedProcess.from_subprocess(cp)
490
+
491
+ def mount(
492
+ self,
493
+ src: Remote | Dir | str,
494
+ outdir: Path,
495
+ allow_writes=False,
496
+ use_links=True,
497
+ vfs_cache_mode="full",
498
+ other_cmds: list[str] | None = None,
499
+ ) -> Process:
500
+ """Mount a remote or directory to a local path.
501
+
502
+ Args:
503
+ src: Remote or directory to mount
504
+ outdir: Local path to mount to
505
+
506
+ Returns:
507
+ CompletedProcess from the mount command execution
508
+
509
+ Raises:
510
+ subprocess.CalledProcessError: If the mount operation fails
511
+ """
512
+ if outdir.exists():
513
+ is_empty = not list(outdir.iterdir())
514
+ if not is_empty:
515
+ raise ValueError(
516
+ f"Mount directory already exists and is not empty: {outdir}"
517
+ )
518
+ outdir.rmdir()
519
+ try:
520
+ outdir.parent.mkdir(parents=True, exist_ok=True)
521
+ except PermissionError:
522
+ warnings.warn(
523
+ f"Permission error creating parent directory: {outdir.parent}"
524
+ )
525
+ src_str = convert_to_str(src)
526
+ cmd_list: list[str] = ["mount", src_str, str(outdir)]
527
+ if not allow_writes:
528
+ cmd_list.append("--read-only")
529
+ if use_links:
530
+ cmd_list.append("--links")
531
+ if vfs_cache_mode:
532
+ cmd_list.append("--vfs-cache-mode")
533
+ cmd_list.append(vfs_cache_mode)
534
+ if other_cmds:
535
+ cmd_list += other_cmds
536
+ proc = self._launch_process(cmd_list)
537
+ wait_for_mount(outdir, proc)
538
+ return proc
539
+
540
+ def mount_webdav(
541
+ self,
542
+ url: str,
543
+ outdir: Path,
544
+ vfs_cache_mode="full",
545
+ vfs_disk_space_total_size: str | None = "10G",
546
+ other_cmds: list[str] | None = None,
547
+ ) -> Process:
548
+ """Mount a remote or directory to a local path.
549
+
550
+ Args:
551
+ src: Remote or directory to mount
552
+ outdir: Local path to mount to
553
+
554
+ Returns:
555
+ CompletedProcess from the mount command execution
556
+
557
+ Raises:
558
+ subprocess.CalledProcessError: If the mount operation fails
559
+ """
560
+ if outdir.exists():
561
+ is_empty = not list(outdir.iterdir())
562
+ if not is_empty:
563
+ raise ValueError(
564
+ f"Mount directory already exists and is not empty: {outdir}"
565
+ )
566
+ outdir.rmdir()
567
+
568
+ src_str = url
569
+ cmd_list: list[str] = ["mount", src_str, str(outdir)]
570
+ cmd_list.append("--vfs-cache-mode")
571
+ cmd_list.append(vfs_cache_mode)
572
+ if other_cmds:
573
+ cmd_list += other_cmds
574
+ if vfs_disk_space_total_size is not None:
575
+ cmd_list.append("--vfs-cache-max-size")
576
+ cmd_list.append(vfs_disk_space_total_size)
577
+ proc = self._launch_process(cmd_list)
578
+ wait_for_mount(outdir, proc)
579
+ return proc
580
+
581
+ def mount_s3(
582
+ self,
583
+ url: str,
584
+ outdir: Path,
585
+ allow_writes=False,
586
+ vfs_cache_mode="full",
587
+ # dir-cache-time
588
+ dir_cache_time: str | None = "1h",
589
+ attribute_timeout: str | None = "1h",
590
+ # --vfs-cache-max-size
591
+ # vfs-cache-max-size
592
+ vfs_disk_space_total_size: str | None = "100M",
593
+ transfers: int | None = 128,
594
+ modtime_strategy: (
595
+ ModTimeStrategy | None
596
+ ) = ModTimeStrategy.USE_SERVER_MODTIME, # speeds up S3 operations
597
+ vfs_read_chunk_streams: int | None = 16,
598
+ vfs_read_chunk_size: str | None = "4M",
599
+ vfs_fast_fingerprint: bool = True,
600
+ # vfs-refresh
601
+ vfs_refresh: bool = True,
602
+ other_cmds: list[str] | None = None,
603
+ ) -> Process:
604
+ """Mount a remote or directory to a local path.
605
+
606
+ Args:
607
+ src: Remote or directory to mount
608
+ outdir: Local path to mount to
609
+ """
610
+ other_cmds = other_cmds or []
611
+ if modtime_strategy is not None:
612
+ other_cmds.append(f"--{modtime_strategy.value}")
613
+ if (vfs_cache_mode == "full" or vfs_cache_mode == "writes") and (
614
+ transfers is not None and "--transfers" not in other_cmds
615
+ ):
616
+ other_cmds.append("--transfers")
617
+ other_cmds.append(str(transfers))
618
+ if dir_cache_time is not None and "--dir-cache-time" not in other_cmds:
619
+ other_cmds.append("--dir-cache-time")
620
+ other_cmds.append(dir_cache_time)
621
+ if (
622
+ vfs_disk_space_total_size is not None
623
+ and "--vfs-cache-max-size" not in other_cmds
624
+ ):
625
+ other_cmds.append("--vfs-cache-max-size")
626
+ other_cmds.append(vfs_disk_space_total_size)
627
+ if vfs_refresh and "--vfs-refresh" not in other_cmds:
628
+ other_cmds.append("--vfs-refresh")
629
+ if attribute_timeout is not None and "--attr-timeout" not in other_cmds:
630
+ other_cmds.append("--attr-timeout")
631
+ other_cmds.append(attribute_timeout)
632
+ if vfs_read_chunk_streams:
633
+ other_cmds.append("--vfs-read-chunk-streams")
634
+ other_cmds.append(str(vfs_read_chunk_streams))
635
+ if vfs_read_chunk_size:
636
+ other_cmds.append("--vfs-read-chunk-size")
637
+ other_cmds.append(vfs_read_chunk_size)
638
+ if vfs_fast_fingerprint:
639
+ other_cmds.append("--vfs-fast-fingerprint")
640
+
641
+ other_cmds = other_cmds if other_cmds else None
642
+ return self.mount(
643
+ url,
644
+ outdir,
645
+ allow_writes=allow_writes,
646
+ vfs_cache_mode=vfs_cache_mode,
647
+ other_cmds=other_cmds,
648
+ )
649
+
650
+ def serve_webdav(
651
+ self,
652
+ src: Remote | Dir | str,
653
+ user: str,
654
+ password: str,
655
+ addr: str = "localhost:2049",
656
+ allow_other: bool = False,
657
+ ) -> Process:
658
+ """Serve a remote or directory via NFS.
659
+
660
+ Args:
661
+ src: Remote or directory to serve
662
+ addr: Network address and port to serve on (default: localhost:2049)
663
+ allow_other: Allow other users to access the share
664
+
665
+ Returns:
666
+ Process: The running NFS server process
667
+
668
+ Raises:
669
+ ValueError: If the NFS server fails to start
670
+ """
671
+ src_str = convert_to_str(src)
672
+ cmd_list: list[str] = ["serve", "webdav", "--addr", addr, src_str]
673
+ cmd_list.extend(["--user", user, "--pass", password])
674
+ if allow_other:
675
+ cmd_list.append("--allow-other")
676
+ proc = self._launch_process(cmd_list)
677
+ time.sleep(2) # give it a moment to start
678
+ if proc.poll() is not None:
679
+ raise ValueError("NFS serve process failed to start")
680
+ return proc