rclone-api 1.0.49__py2.py3-none-any.whl → 1.0.50__py2.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.
rclone_api/rclone.py CHANGED
@@ -1,677 +1,670 @@
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
+ other_args: list[str] | None = None,
277
+ ) -> list[CompletedProcess]:
278
+ """Copy multiple files from source to destination.
279
+
280
+ Args:
281
+ payload: Dictionary of source and destination file paths
282
+ """
283
+ payload: list[str] = convert_to_filestr_list(files)
284
+ if len(payload) == 0:
285
+ return []
286
+
287
+ datalists: dict[str, list[str]] = group_files(payload, fully_qualified=False)
288
+ # out: subprocess.CompletedProcess | None = None
289
+ out: list[CompletedProcess] = []
290
+
291
+ futures: list[Future] = []
292
+
293
+ for common_prefix, files in datalists.items():
294
+
295
+ def _task(files=files) -> subprocess.CompletedProcess:
296
+ with TemporaryDirectory() as tmpdir:
297
+ include_files_txt = Path(tmpdir) / "include_files.txt"
298
+ include_files_txt.write_text("\n".join(files), encoding="utf-8")
299
+ if common_prefix:
300
+ src_path = f"{src}/{common_prefix}"
301
+ dst_path = f"{dst}/{common_prefix}"
302
+ else:
303
+ src_path = src
304
+ dst_path = dst
305
+ # print(include_files_txt)
306
+ cmd_list: list[str] = [
307
+ "copy",
308
+ src_path,
309
+ dst_path,
310
+ "--files-from",
311
+ str(include_files_txt),
312
+ "--checkers",
313
+ "1000",
314
+ "--transfers",
315
+ "1000",
316
+ ]
317
+ if other_args is not None:
318
+ cmd_list += other_args
319
+ out = self._run(cmd_list)
320
+ return out
321
+
322
+ fut: Future = EXECUTOR.submit(_task)
323
+ futures.append(fut)
324
+ for fut in futures:
325
+ cp: subprocess.CompletedProcess = fut.result()
326
+ assert cp is not None
327
+ out.append(CompletedProcess.from_subprocess(cp))
328
+ if cp.returncode != 0:
329
+ if check:
330
+ raise ValueError(f"Error deleting files: {cp.stderr}")
331
+ else:
332
+ warnings.warn(f"Error deleting files: {cp.stderr}")
333
+ return out
334
+
335
+ def copy(self, src: Dir | str, dst: Dir | str) -> CompletedProcess:
336
+ """Copy files from source to destination.
337
+
338
+ Args:
339
+ src: Source directory
340
+ dst: Destination directory
341
+ """
342
+ # src_dir = src.path.path
343
+ # dst_dir = dst.path.path
344
+ src_dir = convert_to_str(src)
345
+ dst_dir = convert_to_str(dst)
346
+ cmd_list: list[str] = ["copy", src_dir, dst_dir]
347
+ cp = self._run(cmd_list)
348
+ return CompletedProcess.from_subprocess(cp)
349
+
350
+ def purge(self, path: Dir | str) -> CompletedProcess:
351
+ """Purge a directory"""
352
+ # path should always be a string
353
+ path = path if isinstance(path, str) else str(path.path)
354
+ cmd_list: list[str] = ["purge", str(path)]
355
+ cp = self._run(cmd_list)
356
+ return CompletedProcess.from_subprocess(cp)
357
+
358
+ def delete_files(
359
+ self,
360
+ files: str | File | list[str] | list[File],
361
+ check=True,
362
+ rmdirs=False,
363
+ verbose: bool | None = None,
364
+ other_args: list[str] | None = None,
365
+ ) -> CompletedProcess:
366
+ """Delete a directory"""
367
+ payload: list[str] = convert_to_filestr_list(files)
368
+ if len(payload) == 0:
369
+ cp = subprocess.CompletedProcess(
370
+ args=["rclone", "delete", "--files-from", "[]"],
371
+ returncode=0,
372
+ stdout="",
373
+ stderr="",
374
+ )
375
+ return CompletedProcess.from_subprocess(cp)
376
+
377
+ datalists: dict[str, list[str]] = group_files(payload)
378
+ completed_processes: list[subprocess.CompletedProcess] = []
379
+ verbose = get_verbose(verbose)
380
+
381
+ futures: list[Future] = []
382
+
383
+ for remote, files in datalists.items():
384
+
385
+ def _task(
386
+ files=files, check=check, remote=remote
387
+ ) -> subprocess.CompletedProcess:
388
+ with TemporaryDirectory() as tmpdir:
389
+ include_files_txt = Path(tmpdir) / "include_files.txt"
390
+ include_files_txt.write_text("\n".join(files), encoding="utf-8")
391
+
392
+ # print(include_files_txt)
393
+ cmd_list: list[str] = [
394
+ "delete",
395
+ remote,
396
+ "--files-from",
397
+ str(include_files_txt),
398
+ "--checkers",
399
+ "1000",
400
+ "--transfers",
401
+ "1000",
402
+ ]
403
+ if verbose:
404
+ cmd_list.append("-vvvv")
405
+ if rmdirs:
406
+ cmd_list.append("--rmdirs")
407
+ if other_args:
408
+ cmd_list += other_args
409
+ out = self._run(cmd_list, check=check)
410
+ if out.returncode != 0:
411
+ if check:
412
+ completed_processes.append(out)
413
+ raise ValueError(f"Error deleting files: {out}")
414
+ else:
415
+ warnings.warn(f"Error deleting files: {out}")
416
+ return out
417
+
418
+ fut: Future = EXECUTOR.submit(_task)
419
+ futures.append(fut)
420
+
421
+ for fut in futures:
422
+ out = fut.result()
423
+ assert out is not None
424
+ completed_processes.append(out)
425
+
426
+ return CompletedProcess(completed_processes)
427
+
428
+ @deprecated("delete_files")
429
+ def deletefiles(
430
+ self, files: str | File | list[str] | list[File]
431
+ ) -> CompletedProcess:
432
+ out = self.delete_files(files)
433
+ return out
434
+
435
+ def exists(self, path: Dir | Remote | str | File) -> bool:
436
+ """Check if a file or directory exists."""
437
+ arg: str = convert_to_str(path)
438
+ assert isinstance(arg, str)
439
+ try:
440
+ dir_listing = self.ls(arg)
441
+ # print(dir_listing)
442
+ return len(dir_listing.dirs) > 0 or len(dir_listing.files) > 0
443
+ except subprocess.CalledProcessError:
444
+ return False
445
+
446
+ def is_synced(self, src: str | Dir, dst: str | Dir) -> bool:
447
+ """Check if two directories are in sync."""
448
+ src = convert_to_str(src)
449
+ dst = convert_to_str(dst)
450
+ cmd_list: list[str] = ["check", str(src), str(dst)]
451
+ try:
452
+ self._run(cmd_list)
453
+ return True
454
+ except subprocess.CalledProcessError:
455
+ return False
456
+
457
+ def copy_dir(
458
+ self, src: str | Dir, dst: str | Dir, args: list[str] | None = None
459
+ ) -> CompletedProcess:
460
+ """Copy a directory from source to destination."""
461
+ # convert src to str, also dst
462
+ src = convert_to_str(src)
463
+ dst = convert_to_str(dst)
464
+ cmd_list: list[str] = ["copy", src, dst]
465
+ if args is not None:
466
+ cmd_list += args
467
+ cp = self._run(cmd_list)
468
+ return CompletedProcess.from_subprocess(cp)
469
+
470
+ def copy_remote(
471
+ self, src: Remote, dst: Remote, args: list[str] | None = None
472
+ ) -> CompletedProcess:
473
+ """Copy a remote to another remote."""
474
+ cmd_list: list[str] = ["copy", str(src), str(dst)]
475
+ if args is not None:
476
+ cmd_list += args
477
+ # return self._run(cmd_list)
478
+ cp = self._run(cmd_list)
479
+ return CompletedProcess.from_subprocess(cp)
480
+
481
+ def mount(
482
+ self,
483
+ src: Remote | Dir | str,
484
+ outdir: Path,
485
+ allow_writes=False,
486
+ use_links=True,
487
+ vfs_cache_mode="full",
488
+ other_cmds: list[str] | None = None,
489
+ ) -> Process:
490
+ """Mount a remote or directory to a local path.
491
+
492
+ Args:
493
+ src: Remote or directory to mount
494
+ outdir: Local path to mount to
495
+
496
+ Returns:
497
+ CompletedProcess from the mount command execution
498
+
499
+ Raises:
500
+ subprocess.CalledProcessError: If the mount operation fails
501
+ """
502
+ if outdir.exists():
503
+ is_empty = not list(outdir.iterdir())
504
+ if not is_empty:
505
+ raise ValueError(
506
+ f"Mount directory already exists and is not empty: {outdir}"
507
+ )
508
+ outdir.rmdir()
509
+ try:
510
+ outdir.parent.mkdir(parents=True, exist_ok=True)
511
+ except PermissionError:
512
+ warnings.warn(
513
+ f"Permission error creating parent directory: {outdir.parent}"
514
+ )
515
+ src_str = convert_to_str(src)
516
+ cmd_list: list[str] = ["mount", src_str, str(outdir)]
517
+ if not allow_writes:
518
+ cmd_list.append("--read-only")
519
+ if use_links:
520
+ cmd_list.append("--links")
521
+ if vfs_cache_mode:
522
+ cmd_list.append("--vfs-cache-mode")
523
+ cmd_list.append(vfs_cache_mode)
524
+ if other_cmds:
525
+ cmd_list += other_cmds
526
+ proc = self._launch_process(cmd_list)
527
+ wait_for_mount(outdir, proc)
528
+ return proc
529
+
530
+ def mount_webdav(
531
+ self,
532
+ url: str,
533
+ outdir: Path,
534
+ vfs_cache_mode="full",
535
+ vfs_disk_space_total_size: str | None = "10G",
536
+ other_cmds: list[str] | None = None,
537
+ ) -> Process:
538
+ """Mount a remote or directory to a local path.
539
+
540
+ Args:
541
+ src: Remote or directory to mount
542
+ outdir: Local path to mount to
543
+
544
+ Returns:
545
+ CompletedProcess from the mount command execution
546
+
547
+ Raises:
548
+ subprocess.CalledProcessError: If the mount operation fails
549
+ """
550
+ if outdir.exists():
551
+ is_empty = not list(outdir.iterdir())
552
+ if not is_empty:
553
+ raise ValueError(
554
+ f"Mount directory already exists and is not empty: {outdir}"
555
+ )
556
+ outdir.rmdir()
557
+
558
+ src_str = url
559
+ cmd_list: list[str] = ["mount", src_str, str(outdir)]
560
+ cmd_list.append("--vfs-cache-mode")
561
+ cmd_list.append(vfs_cache_mode)
562
+ if other_cmds:
563
+ cmd_list += other_cmds
564
+ if vfs_disk_space_total_size is not None:
565
+ cmd_list.append("--vfs-cache-max-size")
566
+ cmd_list.append(vfs_disk_space_total_size)
567
+ proc = self._launch_process(cmd_list)
568
+ wait_for_mount(outdir, proc)
569
+ return proc
570
+
571
+ def mount_s3(
572
+ self,
573
+ url: str,
574
+ outdir: Path,
575
+ allow_writes=False,
576
+ vfs_cache_mode="full",
577
+ # dir-cache-time
578
+ dir_cache_time: str | None = "1h",
579
+ attribute_timeout: str | None = "1h",
580
+ # --vfs-cache-max-size
581
+ # vfs-cache-max-size
582
+ vfs_disk_space_total_size: str | None = "100M",
583
+ transfers: int | None = 128,
584
+ modtime_strategy: (
585
+ ModTimeStrategy | None
586
+ ) = ModTimeStrategy.USE_SERVER_MODTIME, # speeds up S3 operations
587
+ vfs_read_chunk_streams: int | None = 16,
588
+ vfs_read_chunk_size: str | None = "4M",
589
+ vfs_fast_fingerprint: bool = True,
590
+ # vfs-refresh
591
+ vfs_refresh: bool = True,
592
+ other_cmds: list[str] | None = None,
593
+ ) -> Process:
594
+ """Mount a remote or directory to a local path.
595
+
596
+ Args:
597
+ src: Remote or directory to mount
598
+ outdir: Local path to mount to
599
+ """
600
+ other_cmds = other_cmds or []
601
+ if modtime_strategy is not None:
602
+ other_cmds.append(f"--{modtime_strategy.value}")
603
+ if (vfs_cache_mode == "full" or vfs_cache_mode == "writes") and (
604
+ transfers is not None and "--transfers" not in other_cmds
605
+ ):
606
+ other_cmds.append("--transfers")
607
+ other_cmds.append(str(transfers))
608
+ if dir_cache_time is not None and "--dir-cache-time" not in other_cmds:
609
+ other_cmds.append("--dir-cache-time")
610
+ other_cmds.append(dir_cache_time)
611
+ if (
612
+ vfs_disk_space_total_size is not None
613
+ and "--vfs-cache-max-size" not in other_cmds
614
+ ):
615
+ other_cmds.append("--vfs-cache-max-size")
616
+ other_cmds.append(vfs_disk_space_total_size)
617
+ if vfs_refresh and "--vfs-refresh" not in other_cmds:
618
+ other_cmds.append("--vfs-refresh")
619
+ if attribute_timeout is not None and "--attr-timeout" not in other_cmds:
620
+ other_cmds.append("--attr-timeout")
621
+ other_cmds.append(attribute_timeout)
622
+ if vfs_read_chunk_streams:
623
+ other_cmds.append("--vfs-read-chunk-streams")
624
+ other_cmds.append(str(vfs_read_chunk_streams))
625
+ if vfs_read_chunk_size:
626
+ other_cmds.append("--vfs-read-chunk-size")
627
+ other_cmds.append(vfs_read_chunk_size)
628
+ if vfs_fast_fingerprint:
629
+ other_cmds.append("--vfs-fast-fingerprint")
630
+
631
+ other_cmds = other_cmds if other_cmds else None
632
+ return self.mount(
633
+ url,
634
+ outdir,
635
+ allow_writes=allow_writes,
636
+ vfs_cache_mode=vfs_cache_mode,
637
+ other_cmds=other_cmds,
638
+ )
639
+
640
+ def serve_webdav(
641
+ self,
642
+ src: Remote | Dir | str,
643
+ user: str,
644
+ password: str,
645
+ addr: str = "localhost:2049",
646
+ allow_other: bool = False,
647
+ ) -> Process:
648
+ """Serve a remote or directory via NFS.
649
+
650
+ Args:
651
+ src: Remote or directory to serve
652
+ addr: Network address and port to serve on (default: localhost:2049)
653
+ allow_other: Allow other users to access the share
654
+
655
+ Returns:
656
+ Process: The running NFS server process
657
+
658
+ Raises:
659
+ ValueError: If the NFS server fails to start
660
+ """
661
+ src_str = convert_to_str(src)
662
+ cmd_list: list[str] = ["serve", "webdav", "--addr", addr, src_str]
663
+ cmd_list.extend(["--user", user, "--pass", password])
664
+ if allow_other:
665
+ cmd_list.append("--allow-other")
666
+ proc = self._launch_process(cmd_list)
667
+ time.sleep(2) # give it a moment to start
668
+ if proc.poll() is not None:
669
+ raise ValueError("NFS serve process failed to start")
670
+ return proc