rclone-api 1.5.37__py3-none-any.whl → 1.5.39__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/__init__.py +1003 -1003
- rclone_api/config.py +186 -153
- rclone_api/db/__init__.py +3 -3
- rclone_api/detail/walk.py +116 -116
- rclone_api/dir.py +113 -113
- rclone_api/http_server.py +1 -1
- rclone_api/log.py +44 -44
- rclone_api/rclone_impl.py +1360 -1360
- rclone_api/s3/multipart/upload_parts_server_side_merge.py +546 -546
- rclone_api/scan_missing_folders.py +153 -153
- {rclone_api-1.5.37.dist-info → rclone_api-1.5.39.dist-info}/METADATA +1100 -1100
- {rclone_api-1.5.37.dist-info → rclone_api-1.5.39.dist-info}/RECORD +16 -16
- {rclone_api-1.5.37.dist-info → rclone_api-1.5.39.dist-info}/WHEEL +0 -0
- {rclone_api-1.5.37.dist-info → rclone_api-1.5.39.dist-info}/entry_points.txt +0 -0
- {rclone_api-1.5.37.dist-info → rclone_api-1.5.39.dist-info}/licenses/LICENSE +0 -0
- {rclone_api-1.5.37.dist-info → rclone_api-1.5.39.dist-info}/top_level.txt +0 -0
rclone_api/config.py
CHANGED
@@ -1,153 +1,186 @@
|
|
1
|
-
import os
|
2
|
-
from dataclasses import dataclass, field
|
3
|
-
from pathlib import Path
|
4
|
-
from typing import Any, Dict, List
|
5
|
-
|
6
|
-
|
7
|
-
@dataclass
|
8
|
-
class Section:
|
9
|
-
name: str
|
10
|
-
data: Dict[str, str] = field(default_factory=dict)
|
11
|
-
|
12
|
-
def add(self, key: str, value: str) -> None:
|
13
|
-
self.data[key] = value
|
14
|
-
|
15
|
-
def type(self) -> str:
|
16
|
-
return self.data["type"]
|
17
|
-
|
18
|
-
def provider(self) -> str | None:
|
19
|
-
return self.data.get("provider")
|
20
|
-
|
21
|
-
def access_key_id(self) -> str:
|
22
|
-
if "access_key_id" in self.data:
|
23
|
-
return self.data["access_key_id"]
|
24
|
-
elif "account" in self.data:
|
25
|
-
return self.data["account"]
|
26
|
-
raise KeyError("No access key found")
|
27
|
-
|
28
|
-
def secret_access_key(self) -> str:
|
29
|
-
# return self.data["secret_access_key"]
|
30
|
-
if "secret_access_key" in self.data:
|
31
|
-
return self.data["secret_access_key"]
|
32
|
-
elif "key" in self.data:
|
33
|
-
return self.data["key"]
|
34
|
-
raise KeyError("No secret access key found")
|
35
|
-
|
36
|
-
def endpoint(self) -> str | None:
|
37
|
-
return self.data.get("endpoint")
|
38
|
-
|
39
|
-
|
40
|
-
@dataclass
|
41
|
-
class Parsed:
|
42
|
-
# sections: List[ParsedSection]
|
43
|
-
sections: dict[str, Section]
|
44
|
-
|
45
|
-
@staticmethod
|
46
|
-
def parse(content: str) -> "Parsed":
|
47
|
-
return parse_rclone_config(content)
|
48
|
-
|
49
|
-
|
50
|
-
@dataclass
|
51
|
-
class Config:
|
52
|
-
"""Rclone configuration dataclass."""
|
53
|
-
|
54
|
-
text: str
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
def
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
"
|
88
|
-
|
89
|
-
]
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
1
|
+
import os
|
2
|
+
from dataclasses import dataclass, field
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Any, Dict, List
|
5
|
+
|
6
|
+
|
7
|
+
@dataclass
|
8
|
+
class Section:
|
9
|
+
name: str
|
10
|
+
data: Dict[str, str] = field(default_factory=dict)
|
11
|
+
|
12
|
+
def add(self, key: str, value: str) -> None:
|
13
|
+
self.data[key] = value
|
14
|
+
|
15
|
+
def type(self) -> str:
|
16
|
+
return self.data["type"]
|
17
|
+
|
18
|
+
def provider(self) -> str | None:
|
19
|
+
return self.data.get("provider")
|
20
|
+
|
21
|
+
def access_key_id(self) -> str:
|
22
|
+
if "access_key_id" in self.data:
|
23
|
+
return self.data["access_key_id"]
|
24
|
+
elif "account" in self.data:
|
25
|
+
return self.data["account"]
|
26
|
+
raise KeyError("No access key found")
|
27
|
+
|
28
|
+
def secret_access_key(self) -> str:
|
29
|
+
# return self.data["secret_access_key"]
|
30
|
+
if "secret_access_key" in self.data:
|
31
|
+
return self.data["secret_access_key"]
|
32
|
+
elif "key" in self.data:
|
33
|
+
return self.data["key"]
|
34
|
+
raise KeyError("No secret access key found")
|
35
|
+
|
36
|
+
def endpoint(self) -> str | None:
|
37
|
+
return self.data.get("endpoint")
|
38
|
+
|
39
|
+
|
40
|
+
@dataclass
|
41
|
+
class Parsed:
|
42
|
+
# sections: List[ParsedSection]
|
43
|
+
sections: dict[str, Section]
|
44
|
+
|
45
|
+
@staticmethod
|
46
|
+
def parse(content: str) -> "Parsed":
|
47
|
+
return parse_rclone_config(content)
|
48
|
+
|
49
|
+
|
50
|
+
@dataclass
|
51
|
+
class Config:
|
52
|
+
"""Rclone configuration dataclass."""
|
53
|
+
|
54
|
+
text: str
|
55
|
+
|
56
|
+
@staticmethod
|
57
|
+
def from_json(json_data: dict) -> "Config | Exception":
|
58
|
+
return json_to_rclone_config(json_data)
|
59
|
+
|
60
|
+
def parse(self) -> Parsed:
|
61
|
+
return Parsed.parse(self.text)
|
62
|
+
|
63
|
+
|
64
|
+
def find_conf_file(rclone: Any | None = None) -> Path | None:
|
65
|
+
import subprocess
|
66
|
+
|
67
|
+
from rclone_api import Rclone
|
68
|
+
from rclone_api.rclone_impl import RcloneImpl
|
69
|
+
|
70
|
+
# if os.environ.get("RCLONE_CONFIG"):
|
71
|
+
# return Path(os.environ["RCLONE_CONFIG"])
|
72
|
+
# return None
|
73
|
+
# rclone_conf = rclone_conf or Path.cwd() / "rclone.conf"
|
74
|
+
|
75
|
+
if os.environ.get("RCLONE_CONFIG"):
|
76
|
+
return Path(os.environ["RCLONE_CONFIG"])
|
77
|
+
if (conf := Path.cwd() / "rclone.conf").exists():
|
78
|
+
return conf
|
79
|
+
|
80
|
+
if rclone is None:
|
81
|
+
from rclone_api.install import rclone_download
|
82
|
+
|
83
|
+
err = rclone_download(Path("."))
|
84
|
+
if isinstance(err, Exception):
|
85
|
+
import warnings
|
86
|
+
|
87
|
+
warnings.warn(f"rclone_download failed: {err}")
|
88
|
+
return None
|
89
|
+
cmd_list: list[str] = [
|
90
|
+
"rclone",
|
91
|
+
"config",
|
92
|
+
"paths",
|
93
|
+
]
|
94
|
+
subproc: subprocess.CompletedProcess = subprocess.run(
|
95
|
+
args=cmd_list,
|
96
|
+
shell=True,
|
97
|
+
capture_output=True,
|
98
|
+
text=True,
|
99
|
+
)
|
100
|
+
if subproc.returncode == 0:
|
101
|
+
stdout = subproc.stdout
|
102
|
+
for line in stdout.splitlines():
|
103
|
+
parts = line.split(":", 1)
|
104
|
+
if len(parts) == 2:
|
105
|
+
_, value = parts
|
106
|
+
value = value.strip()
|
107
|
+
value_path = Path(value.strip())
|
108
|
+
if value_path.exists():
|
109
|
+
return value_path
|
110
|
+
else:
|
111
|
+
if isinstance(rclone, Rclone):
|
112
|
+
rclone = rclone.impl
|
113
|
+
else:
|
114
|
+
assert isinstance(rclone, RcloneImpl)
|
115
|
+
rclone_impl: RcloneImpl = rclone
|
116
|
+
assert isinstance(rclone_impl, RcloneImpl)
|
117
|
+
paths_or_err = rclone_impl.config_paths()
|
118
|
+
if isinstance(paths_or_err, Exception):
|
119
|
+
return None
|
120
|
+
paths = paths_or_err
|
121
|
+
path: Path
|
122
|
+
for path in paths:
|
123
|
+
if path.exists():
|
124
|
+
return path
|
125
|
+
return None
|
126
|
+
|
127
|
+
|
128
|
+
def parse_rclone_config(content: str) -> Parsed:
|
129
|
+
"""
|
130
|
+
Parses an rclone configuration file and returns a list of RcloneConfigSection objects.
|
131
|
+
|
132
|
+
Each section in the file starts with a line like [section_name]
|
133
|
+
followed by key=value pairs.
|
134
|
+
"""
|
135
|
+
sections: List[Section] = []
|
136
|
+
current_section: Section | None = None
|
137
|
+
|
138
|
+
lines = content.splitlines()
|
139
|
+
for line in lines:
|
140
|
+
line = line.strip()
|
141
|
+
# Skip empty lines and comments (assumed to start with '#' or ';')
|
142
|
+
if not line or line.startswith(("#", ";")):
|
143
|
+
continue
|
144
|
+
# New section header detected
|
145
|
+
if line.startswith("[") and line.endswith("]"):
|
146
|
+
section_name = line[1:-1].strip()
|
147
|
+
current_section = Section(name=section_name)
|
148
|
+
sections.append(current_section)
|
149
|
+
elif "=" in line and current_section is not None:
|
150
|
+
# Parse key and value, splitting only on the first '=' found
|
151
|
+
key, value = line.split("=", 1)
|
152
|
+
current_section.add(key.strip(), value.strip())
|
153
|
+
|
154
|
+
data: dict[str, Section] = {}
|
155
|
+
for section in sections:
|
156
|
+
data[section.name] = section
|
157
|
+
return Parsed(sections=data)
|
158
|
+
|
159
|
+
|
160
|
+
# JSON_DATA = {
|
161
|
+
# "dst": {
|
162
|
+
# "type": "s3",
|
163
|
+
# "bucket": "bucket",
|
164
|
+
# "endpoint": "https://s3.amazonaws.com",
|
165
|
+
# "access_key_id": "access key",
|
166
|
+
# "access_secret_key": "access secret key",
|
167
|
+
# }
|
168
|
+
# }
|
169
|
+
|
170
|
+
|
171
|
+
def _json_to_rclone_config_str_or_raise(json_data: dict) -> str:
|
172
|
+
"""Convert JSON data to rclone config."""
|
173
|
+
out = ""
|
174
|
+
for key, value in json_data.items():
|
175
|
+
out += f"[{key}]\n"
|
176
|
+
for k, v in value.items():
|
177
|
+
out += f"{k} = {v}\n"
|
178
|
+
return out
|
179
|
+
|
180
|
+
|
181
|
+
def json_to_rclone_config(json_data: dict) -> Config | Exception:
|
182
|
+
try:
|
183
|
+
text = _json_to_rclone_config_str_or_raise(json_data)
|
184
|
+
return Config(text=text)
|
185
|
+
except Exception as e:
|
186
|
+
return e
|
rclone_api/db/__init__.py
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
from .db import DB
|
2
|
-
|
3
|
-
__all__ = ["DB"]
|
1
|
+
from .db import DB
|
2
|
+
|
3
|
+
__all__ = ["DB"]
|
rclone_api/detail/walk.py
CHANGED
@@ -1,116 +1,116 @@
|
|
1
|
-
import random
|
2
|
-
from queue import Queue
|
3
|
-
from threading import Thread
|
4
|
-
from typing import Generator
|
5
|
-
|
6
|
-
from rclone_api import Dir
|
7
|
-
from rclone_api.dir_listing import DirListing
|
8
|
-
from rclone_api.remote import Remote
|
9
|
-
from rclone_api.types import Order
|
10
|
-
|
11
|
-
_MAX_OUT_QUEUE_SIZE = 50
|
12
|
-
|
13
|
-
|
14
|
-
def walk_runner_breadth_first(
|
15
|
-
dir: Dir,
|
16
|
-
max_depth: int,
|
17
|
-
out_queue: Queue[DirListing | None],
|
18
|
-
order: Order = Order.NORMAL,
|
19
|
-
) -> None:
|
20
|
-
queue: Queue[Dir] = Queue()
|
21
|
-
queue.put(dir)
|
22
|
-
try:
|
23
|
-
while not queue.empty():
|
24
|
-
current_dir = queue.get()
|
25
|
-
dirlisting = current_dir.ls(max_depth=0, order=order)
|
26
|
-
out_queue.put(dirlisting)
|
27
|
-
dirs = dirlisting.dirs
|
28
|
-
|
29
|
-
if max_depth != 0 and len(dirs) > 0:
|
30
|
-
for child in dirs:
|
31
|
-
queue.put(child)
|
32
|
-
if max_depth < 0:
|
33
|
-
continue
|
34
|
-
if max_depth > 0:
|
35
|
-
max_depth -= 1
|
36
|
-
out_queue.put(None)
|
37
|
-
except KeyboardInterrupt:
|
38
|
-
import _thread
|
39
|
-
|
40
|
-
out_queue.put(None)
|
41
|
-
|
42
|
-
_thread.interrupt_main()
|
43
|
-
|
44
|
-
|
45
|
-
def walk_runner_depth_first(
|
46
|
-
dir: Dir,
|
47
|
-
max_depth: int,
|
48
|
-
out_queue: Queue[DirListing | None],
|
49
|
-
order: Order = Order.NORMAL,
|
50
|
-
) -> None:
|
51
|
-
try:
|
52
|
-
stack = [(dir, max_depth)]
|
53
|
-
while stack:
|
54
|
-
current_dir, depth = stack.pop()
|
55
|
-
dirlisting = current_dir.ls()
|
56
|
-
if order == Order.REVERSE:
|
57
|
-
dirlisting.dirs.reverse()
|
58
|
-
if order == Order.RANDOM:
|
59
|
-
|
60
|
-
random.shuffle(dirlisting.dirs)
|
61
|
-
if depth != 0:
|
62
|
-
for subdir in dirlisting.dirs: # Process deeper directories first
|
63
|
-
# stack.append((child, depth - 1 if depth > 0 else depth))
|
64
|
-
next_depth = depth - 1 if depth > 0 else depth
|
65
|
-
walk_runner_depth_first(subdir, next_depth, out_queue, order=order)
|
66
|
-
out_queue.put(dirlisting)
|
67
|
-
out_queue.put(None)
|
68
|
-
except KeyboardInterrupt:
|
69
|
-
import _thread
|
70
|
-
|
71
|
-
out_queue.put(None)
|
72
|
-
_thread.interrupt_main()
|
73
|
-
|
74
|
-
|
75
|
-
def walk(
|
76
|
-
dir: Dir | Remote,
|
77
|
-
breadth_first: bool,
|
78
|
-
max_depth: int = -1,
|
79
|
-
order: Order = Order.NORMAL,
|
80
|
-
) -> Generator[DirListing, None, None]:
|
81
|
-
"""Walk through the given directory recursively.
|
82
|
-
|
83
|
-
Args:
|
84
|
-
dir: Directory or Remote to walk through
|
85
|
-
max_depth: Maximum depth to traverse (-1 for unlimited)
|
86
|
-
|
87
|
-
Yields:
|
88
|
-
DirListing: Directory listing for each directory encountered
|
89
|
-
"""
|
90
|
-
try:
|
91
|
-
# Convert Remote to Dir if needed
|
92
|
-
if isinstance(dir, Remote):
|
93
|
-
dir = Dir(dir)
|
94
|
-
out_queue: Queue[DirListing | None] = Queue(maxsize=_MAX_OUT_QUEUE_SIZE)
|
95
|
-
|
96
|
-
def _task() -> None:
|
97
|
-
if breadth_first:
|
98
|
-
walk_runner_breadth_first(dir, max_depth, out_queue, order)
|
99
|
-
else:
|
100
|
-
walk_runner_depth_first(dir, max_depth, out_queue, order)
|
101
|
-
|
102
|
-
# Start worker thread
|
103
|
-
worker = Thread(
|
104
|
-
target=_task,
|
105
|
-
daemon=True,
|
106
|
-
)
|
107
|
-
worker.start()
|
108
|
-
|
109
|
-
while dirlisting := out_queue.get():
|
110
|
-
if dirlisting is None:
|
111
|
-
break
|
112
|
-
yield dirlisting
|
113
|
-
|
114
|
-
worker.join()
|
115
|
-
except KeyboardInterrupt:
|
116
|
-
pass
|
1
|
+
import random
|
2
|
+
from queue import Queue
|
3
|
+
from threading import Thread
|
4
|
+
from typing import Generator
|
5
|
+
|
6
|
+
from rclone_api import Dir
|
7
|
+
from rclone_api.dir_listing import DirListing
|
8
|
+
from rclone_api.remote import Remote
|
9
|
+
from rclone_api.types import Order
|
10
|
+
|
11
|
+
_MAX_OUT_QUEUE_SIZE = 50
|
12
|
+
|
13
|
+
|
14
|
+
def walk_runner_breadth_first(
|
15
|
+
dir: Dir,
|
16
|
+
max_depth: int,
|
17
|
+
out_queue: Queue[DirListing | None],
|
18
|
+
order: Order = Order.NORMAL,
|
19
|
+
) -> None:
|
20
|
+
queue: Queue[Dir] = Queue()
|
21
|
+
queue.put(dir)
|
22
|
+
try:
|
23
|
+
while not queue.empty():
|
24
|
+
current_dir = queue.get()
|
25
|
+
dirlisting = current_dir.ls(max_depth=0, order=order)
|
26
|
+
out_queue.put(dirlisting)
|
27
|
+
dirs = dirlisting.dirs
|
28
|
+
|
29
|
+
if max_depth != 0 and len(dirs) > 0:
|
30
|
+
for child in dirs:
|
31
|
+
queue.put(child)
|
32
|
+
if max_depth < 0:
|
33
|
+
continue
|
34
|
+
if max_depth > 0:
|
35
|
+
max_depth -= 1
|
36
|
+
out_queue.put(None)
|
37
|
+
except KeyboardInterrupt:
|
38
|
+
import _thread
|
39
|
+
|
40
|
+
out_queue.put(None)
|
41
|
+
|
42
|
+
_thread.interrupt_main()
|
43
|
+
|
44
|
+
|
45
|
+
def walk_runner_depth_first(
|
46
|
+
dir: Dir,
|
47
|
+
max_depth: int,
|
48
|
+
out_queue: Queue[DirListing | None],
|
49
|
+
order: Order = Order.NORMAL,
|
50
|
+
) -> None:
|
51
|
+
try:
|
52
|
+
stack = [(dir, max_depth)]
|
53
|
+
while stack:
|
54
|
+
current_dir, depth = stack.pop()
|
55
|
+
dirlisting = current_dir.ls()
|
56
|
+
if order == Order.REVERSE:
|
57
|
+
dirlisting.dirs.reverse()
|
58
|
+
if order == Order.RANDOM:
|
59
|
+
|
60
|
+
random.shuffle(dirlisting.dirs)
|
61
|
+
if depth != 0:
|
62
|
+
for subdir in dirlisting.dirs: # Process deeper directories first
|
63
|
+
# stack.append((child, depth - 1 if depth > 0 else depth))
|
64
|
+
next_depth = depth - 1 if depth > 0 else depth
|
65
|
+
walk_runner_depth_first(subdir, next_depth, out_queue, order=order)
|
66
|
+
out_queue.put(dirlisting)
|
67
|
+
out_queue.put(None)
|
68
|
+
except KeyboardInterrupt:
|
69
|
+
import _thread
|
70
|
+
|
71
|
+
out_queue.put(None)
|
72
|
+
_thread.interrupt_main()
|
73
|
+
|
74
|
+
|
75
|
+
def walk(
|
76
|
+
dir: Dir | Remote,
|
77
|
+
breadth_first: bool,
|
78
|
+
max_depth: int = -1,
|
79
|
+
order: Order = Order.NORMAL,
|
80
|
+
) -> Generator[DirListing, None, None]:
|
81
|
+
"""Walk through the given directory recursively.
|
82
|
+
|
83
|
+
Args:
|
84
|
+
dir: Directory or Remote to walk through
|
85
|
+
max_depth: Maximum depth to traverse (-1 for unlimited)
|
86
|
+
|
87
|
+
Yields:
|
88
|
+
DirListing: Directory listing for each directory encountered
|
89
|
+
"""
|
90
|
+
try:
|
91
|
+
# Convert Remote to Dir if needed
|
92
|
+
if isinstance(dir, Remote):
|
93
|
+
dir = Dir(dir)
|
94
|
+
out_queue: Queue[DirListing | None] = Queue(maxsize=_MAX_OUT_QUEUE_SIZE)
|
95
|
+
|
96
|
+
def _task() -> None:
|
97
|
+
if breadth_first:
|
98
|
+
walk_runner_breadth_first(dir, max_depth, out_queue, order)
|
99
|
+
else:
|
100
|
+
walk_runner_depth_first(dir, max_depth, out_queue, order)
|
101
|
+
|
102
|
+
# Start worker thread
|
103
|
+
worker = Thread(
|
104
|
+
target=_task,
|
105
|
+
daemon=True,
|
106
|
+
)
|
107
|
+
worker.start()
|
108
|
+
|
109
|
+
while dirlisting := out_queue.get():
|
110
|
+
if dirlisting is None:
|
111
|
+
break
|
112
|
+
yield dirlisting
|
113
|
+
|
114
|
+
worker.join()
|
115
|
+
except KeyboardInterrupt:
|
116
|
+
pass
|