rclone-api 1.3.28__py2.py3-none-any.whl → 1.4.1__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/__init__.py +491 -4
- rclone_api/cmd/copy_large_s3.py +17 -10
- rclone_api/db/db.py +3 -3
- rclone_api/detail/copy_file_parts.py +382 -0
- rclone_api/dir.py +1 -1
- rclone_api/dir_listing.py +1 -1
- rclone_api/file.py +8 -0
- rclone_api/file_part.py +198 -0
- rclone_api/file_stream.py +52 -0
- rclone_api/http_server.py +15 -21
- rclone_api/{rclone.py → rclone_impl.py} +153 -321
- rclone_api/remote.py +3 -3
- rclone_api/rpath.py +11 -4
- rclone_api/s3/chunk_task.py +3 -19
- rclone_api/s3/multipart/file_info.py +7 -0
- rclone_api/s3/multipart/finished_piece.py +38 -0
- rclone_api/s3/multipart/upload_info.py +62 -0
- rclone_api/s3/{chunk_types.py → multipart/upload_state.py} +3 -99
- rclone_api/s3/s3_multipart_uploader.py +138 -28
- rclone_api/s3/types.py +1 -1
- rclone_api/s3/upload_file_multipart.py +6 -13
- rclone_api/scan_missing_folders.py +1 -1
- rclone_api/types.py +136 -165
- rclone_api/util.py +22 -2
- {rclone_api-1.3.28.dist-info → rclone_api-1.4.1.dist-info}/METADATA +1 -1
- rclone_api-1.4.1.dist-info/RECORD +55 -0
- rclone_api/mount_read_chunker.py +0 -130
- rclone_api/profile/mount_copy_bytes.py +0 -311
- rclone_api-1.3.28.dist-info/RECORD +0 -51
- /rclone_api/{walk.py → detail/walk.py} +0 -0
- {rclone_api-1.3.28.dist-info → rclone_api-1.4.1.dist-info}/LICENSE +0 -0
- {rclone_api-1.3.28.dist-info → rclone_api-1.4.1.dist-info}/WHEEL +0 -0
- {rclone_api-1.3.28.dist-info → rclone_api-1.4.1.dist-info}/entry_points.txt +0 -0
- {rclone_api-1.3.28.dist-info → rclone_api-1.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,382 @@
|
|
1
|
+
import _thread
|
2
|
+
import hashlib
|
3
|
+
import json
|
4
|
+
import os
|
5
|
+
import threading
|
6
|
+
import warnings
|
7
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
8
|
+
from dataclasses import dataclass
|
9
|
+
from datetime import datetime
|
10
|
+
from pathlib import Path
|
11
|
+
from tempfile import TemporaryDirectory
|
12
|
+
|
13
|
+
from rclone_api.dir_listing import DirListing
|
14
|
+
from rclone_api.http_server import HttpServer
|
15
|
+
from rclone_api.rclone_impl import RcloneImpl
|
16
|
+
from rclone_api.types import (
|
17
|
+
PartInfo,
|
18
|
+
Range,
|
19
|
+
SizeSuffix,
|
20
|
+
)
|
21
|
+
|
22
|
+
|
23
|
+
@dataclass
|
24
|
+
class UploadPart:
|
25
|
+
chunk: Path
|
26
|
+
dst_part: str
|
27
|
+
exception: Exception | None = None
|
28
|
+
finished: bool = False
|
29
|
+
|
30
|
+
def dispose(self):
|
31
|
+
try:
|
32
|
+
if self.chunk.exists():
|
33
|
+
self.chunk.unlink()
|
34
|
+
self.finished = True
|
35
|
+
except Exception as e:
|
36
|
+
warnings.warn(f"Failed to delete file {self.chunk}: {e}")
|
37
|
+
|
38
|
+
def __del__(self):
|
39
|
+
self.dispose()
|
40
|
+
|
41
|
+
|
42
|
+
def _gen_name(part_number: int, offset: SizeSuffix, end: SizeSuffix) -> str:
|
43
|
+
return f"part.{part_number:05d}_{offset.as_int()}-{end.as_int()}"
|
44
|
+
|
45
|
+
|
46
|
+
def upload_task(self: RcloneImpl, upload_part: UploadPart) -> UploadPart:
|
47
|
+
try:
|
48
|
+
if upload_part.exception is not None:
|
49
|
+
return upload_part
|
50
|
+
self.copy_to(upload_part.chunk.as_posix(), upload_part.dst_part)
|
51
|
+
return upload_part
|
52
|
+
except Exception as e:
|
53
|
+
upload_part.exception = e
|
54
|
+
return upload_part
|
55
|
+
finally:
|
56
|
+
upload_part.dispose()
|
57
|
+
|
58
|
+
|
59
|
+
def read_task(
|
60
|
+
http_server: HttpServer,
|
61
|
+
src_name: str,
|
62
|
+
tmpdir: Path,
|
63
|
+
offset: SizeSuffix,
|
64
|
+
length: SizeSuffix,
|
65
|
+
part_dst: str,
|
66
|
+
) -> UploadPart:
|
67
|
+
outchunk: Path = tmpdir / f"{offset.as_int()}-{(offset + length).as_int()}.chunk"
|
68
|
+
range = Range(offset.as_int(), (offset + length).as_int())
|
69
|
+
|
70
|
+
try:
|
71
|
+
err = http_server.download(
|
72
|
+
path=src_name,
|
73
|
+
range=range,
|
74
|
+
dst=outchunk,
|
75
|
+
)
|
76
|
+
if isinstance(err, Exception):
|
77
|
+
out = UploadPart(chunk=outchunk, dst_part="", exception=err)
|
78
|
+
out.dispose()
|
79
|
+
return out
|
80
|
+
return UploadPart(chunk=outchunk, dst_part=part_dst)
|
81
|
+
except KeyboardInterrupt as ke:
|
82
|
+
_thread.interrupt_main()
|
83
|
+
raise ke
|
84
|
+
except SystemExit as se:
|
85
|
+
_thread.interrupt_main()
|
86
|
+
raise se
|
87
|
+
except Exception as e:
|
88
|
+
return UploadPart(chunk=outchunk, dst_part=part_dst, exception=e)
|
89
|
+
|
90
|
+
|
91
|
+
def _fetch_all_names(
|
92
|
+
self: RcloneImpl,
|
93
|
+
src: str,
|
94
|
+
) -> list[str]:
|
95
|
+
dl: DirListing = self.ls(src)
|
96
|
+
files = dl.files
|
97
|
+
filenames: list[str] = [f.name for f in files]
|
98
|
+
filtered: list[str] = [f for f in filenames if f.startswith("part.")]
|
99
|
+
return filtered
|
100
|
+
|
101
|
+
|
102
|
+
def _get_info_json(self: RcloneImpl, src: str, src_info: str) -> dict:
|
103
|
+
from rclone_api.file import File
|
104
|
+
|
105
|
+
src_stat: File | Exception = self.stat(src)
|
106
|
+
if isinstance(src_stat, Exception):
|
107
|
+
raise FileNotFoundError(f"Failed to stat {src}: {src_stat}")
|
108
|
+
|
109
|
+
now: datetime = datetime.now()
|
110
|
+
new_data = {
|
111
|
+
"new": True,
|
112
|
+
"created": now.isoformat(),
|
113
|
+
"src": src,
|
114
|
+
"src_modtime": src_stat.mod_time(),
|
115
|
+
"size": src_stat.size,
|
116
|
+
"chunksize": None,
|
117
|
+
"chunksize_int": None,
|
118
|
+
"first_part": None,
|
119
|
+
"last_part": None,
|
120
|
+
"hash": None,
|
121
|
+
}
|
122
|
+
|
123
|
+
text_or_err = self.read_text(src_info)
|
124
|
+
err: Exception | None = text_or_err if isinstance(text_or_err, Exception) else None
|
125
|
+
if isinstance(text_or_err, Exception):
|
126
|
+
warnings.warn(f"Failed to read {src_info}: {text_or_err}")
|
127
|
+
return new_data
|
128
|
+
assert isinstance(text_or_err, str)
|
129
|
+
text: str = text_or_err
|
130
|
+
|
131
|
+
if err is not None:
|
132
|
+
return new_data
|
133
|
+
|
134
|
+
data: dict = {}
|
135
|
+
try:
|
136
|
+
data = json.loads(text)
|
137
|
+
return data
|
138
|
+
except Exception as e:
|
139
|
+
warnings.warn(f"Failed to parse JSON: {e} at {src_info}")
|
140
|
+
return new_data
|
141
|
+
|
142
|
+
|
143
|
+
def _save_info_json(self: RcloneImpl, src: str, data: dict) -> None:
|
144
|
+
data = data.copy()
|
145
|
+
data["new"] = False
|
146
|
+
# hash
|
147
|
+
|
148
|
+
h = hashlib.md5()
|
149
|
+
tmp = [
|
150
|
+
data.get("src"),
|
151
|
+
data.get("src_modtime"),
|
152
|
+
data.get("size"),
|
153
|
+
data.get("chunksize_int"),
|
154
|
+
]
|
155
|
+
data_vals: list[str] = [str(v) for v in tmp]
|
156
|
+
str_data = "".join(data_vals)
|
157
|
+
h.update(str_data.encode("utf-8"))
|
158
|
+
data["hash"] = h.hexdigest()
|
159
|
+
json_str = json.dumps(data, indent=0)
|
160
|
+
self.write_text(dst=src, text=json_str)
|
161
|
+
|
162
|
+
|
163
|
+
class InfoJson:
|
164
|
+
def __init__(self, rclone: RcloneImpl, src: str, src_info: str) -> None:
|
165
|
+
self.rclone = rclone
|
166
|
+
self.src = src
|
167
|
+
self.src_info = src_info
|
168
|
+
self.data: dict = {}
|
169
|
+
|
170
|
+
def load(self) -> bool:
|
171
|
+
self.data = _get_info_json(self.rclone, self.src, self.src_info)
|
172
|
+
return not self.data.get("new", False)
|
173
|
+
|
174
|
+
def save(self) -> None:
|
175
|
+
_save_info_json(self.rclone, self.src_info, self.data)
|
176
|
+
|
177
|
+
def print(self) -> None:
|
178
|
+
self.rclone.print(self.src_info)
|
179
|
+
|
180
|
+
def fetch_all_finished(self) -> list[str]:
|
181
|
+
parent_path = os.path.dirname(self.src_info)
|
182
|
+
out = _fetch_all_names(self.rclone, parent_path)
|
183
|
+
return out
|
184
|
+
|
185
|
+
def fetch_all_finished_part_numbers(self) -> list[int]:
|
186
|
+
names = self.fetch_all_finished()
|
187
|
+
part_numbers = [int(name.split("_")[0].split(".")[1]) for name in names]
|
188
|
+
return part_numbers
|
189
|
+
|
190
|
+
@property
|
191
|
+
def new(self) -> bool:
|
192
|
+
return self.data.get("new", False)
|
193
|
+
|
194
|
+
@property
|
195
|
+
def chunksize(self) -> SizeSuffix | None:
|
196
|
+
chunksize: str | None = self.data.get("chunksize")
|
197
|
+
if chunksize is None:
|
198
|
+
return None
|
199
|
+
return SizeSuffix(chunksize)
|
200
|
+
|
201
|
+
@chunksize.setter
|
202
|
+
def chunksize(self, value: SizeSuffix) -> None:
|
203
|
+
self.data["chunksize"] = str(value)
|
204
|
+
self.data["chunksize_int"] = value.as_int()
|
205
|
+
|
206
|
+
@property
|
207
|
+
def src_modtime(self) -> datetime:
|
208
|
+
return datetime.fromisoformat(self.data["src_modtime"])
|
209
|
+
|
210
|
+
@src_modtime.setter
|
211
|
+
def src_modtime(self, value: datetime) -> None:
|
212
|
+
self.data["src_modtime"] = value.isoformat()
|
213
|
+
|
214
|
+
@property
|
215
|
+
def first_part(self) -> int | None:
|
216
|
+
return self.data.get("first_part")
|
217
|
+
|
218
|
+
@first_part.setter
|
219
|
+
def first_part(self, value: int) -> None:
|
220
|
+
self.data["first_part"] = value
|
221
|
+
|
222
|
+
@property
|
223
|
+
def last_part(self) -> int | None:
|
224
|
+
return self.data.get("last_part")
|
225
|
+
|
226
|
+
@last_part.setter
|
227
|
+
def last_part(self, value: int) -> None:
|
228
|
+
self.data["last_part"] = value
|
229
|
+
|
230
|
+
@property
|
231
|
+
def hash(self) -> str | None:
|
232
|
+
return self.data.get("hash")
|
233
|
+
|
234
|
+
def to_json_str(self) -> str:
|
235
|
+
return json.dumps(self.data)
|
236
|
+
|
237
|
+
def __repr__(self):
|
238
|
+
return f"InfoJson({self.src}, {self.src_info}, {self.data})"
|
239
|
+
|
240
|
+
def __str__(self):
|
241
|
+
return self.to_json_str()
|
242
|
+
|
243
|
+
|
244
|
+
def copy_file_parts(
|
245
|
+
self: RcloneImpl,
|
246
|
+
src: str, # src:/Bucket/path/myfile.large.zst
|
247
|
+
dst_dir: str, # dst:/Bucket/path/myfile.large.zst-parts/
|
248
|
+
part_infos: list[PartInfo] | None = None,
|
249
|
+
threads: int = 1,
|
250
|
+
) -> Exception | None:
|
251
|
+
"""Copy parts of a file from source to destination."""
|
252
|
+
if dst_dir.endswith("/"):
|
253
|
+
dst_dir = dst_dir[:-1]
|
254
|
+
src_size = self.size_file(src)
|
255
|
+
if isinstance(src_size, Exception):
|
256
|
+
return src_size
|
257
|
+
|
258
|
+
part_info: PartInfo
|
259
|
+
src_dir = os.path.dirname(src)
|
260
|
+
src_name = os.path.basename(src)
|
261
|
+
http_server: HttpServer
|
262
|
+
|
263
|
+
full_part_infos: list[PartInfo] | Exception = PartInfo.split_parts(
|
264
|
+
src_size, SizeSuffix("96MB")
|
265
|
+
)
|
266
|
+
if isinstance(full_part_infos, Exception):
|
267
|
+
return full_part_infos
|
268
|
+
assert isinstance(full_part_infos, list)
|
269
|
+
|
270
|
+
if part_infos is None:
|
271
|
+
src_size = self.size_file(src)
|
272
|
+
if isinstance(src_size, Exception):
|
273
|
+
return src_size
|
274
|
+
part_infos = full_part_infos.copy()
|
275
|
+
|
276
|
+
all_part_numbers: list[int] = [p.part_number for p in part_infos]
|
277
|
+
src_info_json = f"{dst_dir}/info.json"
|
278
|
+
info_json = InfoJson(self, src, src_info_json)
|
279
|
+
|
280
|
+
if not info_json.load():
|
281
|
+
print(f"New: {src_info_json}")
|
282
|
+
# info_json.save()
|
283
|
+
|
284
|
+
all_numbers_already_done: set[int] = set(
|
285
|
+
info_json.fetch_all_finished_part_numbers()
|
286
|
+
)
|
287
|
+
print(f"all_numbers_already_done: {sorted(list(all_numbers_already_done))}")
|
288
|
+
|
289
|
+
filtered_part_infos: list[PartInfo] = []
|
290
|
+
for part_info in part_infos:
|
291
|
+
if part_info.part_number not in all_numbers_already_done:
|
292
|
+
filtered_part_infos.append(part_info)
|
293
|
+
part_infos = filtered_part_infos
|
294
|
+
|
295
|
+
remaining_part_numbers: list[int] = [p.part_number for p in part_infos]
|
296
|
+
print(f"remaining_part_numbers: {remaining_part_numbers}")
|
297
|
+
|
298
|
+
if len(part_infos) == 0:
|
299
|
+
return Exception(f"No parts to copy for {src}")
|
300
|
+
chunk_size = SizeSuffix(part_infos[0].range.end - part_infos[0].range.start)
|
301
|
+
|
302
|
+
info_json.chunksize = chunk_size
|
303
|
+
info_json.first_part = part_infos[0].part_number
|
304
|
+
info_json.last_part = part_infos[-1].part_number
|
305
|
+
info_json.save()
|
306
|
+
|
307
|
+
# We are now validated
|
308
|
+
info_json.load()
|
309
|
+
info_json.print()
|
310
|
+
|
311
|
+
print(info_json)
|
312
|
+
|
313
|
+
finished_tasks: list[UploadPart] = []
|
314
|
+
|
315
|
+
with self.serve_http(src_dir) as http_server:
|
316
|
+
with TemporaryDirectory() as tmp_dir:
|
317
|
+
tmpdir: Path = Path(tmp_dir)
|
318
|
+
write_semaphore = threading.Semaphore(threads)
|
319
|
+
with ThreadPoolExecutor(max_workers=threads) as upload_executor:
|
320
|
+
with ThreadPoolExecutor(max_workers=threads) as read_executor:
|
321
|
+
for part_info in part_infos:
|
322
|
+
part_number: int = part_info.part_number
|
323
|
+
range: Range = part_info.range
|
324
|
+
offset: SizeSuffix = SizeSuffix(range.start)
|
325
|
+
length: SizeSuffix = SizeSuffix(range.end - range.start)
|
326
|
+
end = offset + length
|
327
|
+
suffix = _gen_name(part_number, offset, end)
|
328
|
+
part_dst = f"{dst_dir}/{suffix}"
|
329
|
+
|
330
|
+
def _read_task(
|
331
|
+
src_name=src_name,
|
332
|
+
http_server=http_server,
|
333
|
+
tmpdir=tmpdir,
|
334
|
+
offset=offset,
|
335
|
+
length=length,
|
336
|
+
part_dst=part_dst,
|
337
|
+
) -> UploadPart:
|
338
|
+
return read_task(
|
339
|
+
src_name=src_name,
|
340
|
+
http_server=http_server,
|
341
|
+
tmpdir=tmpdir,
|
342
|
+
offset=offset,
|
343
|
+
length=length,
|
344
|
+
part_dst=part_dst,
|
345
|
+
)
|
346
|
+
|
347
|
+
read_fut: Future[UploadPart] = read_executor.submit(_read_task)
|
348
|
+
|
349
|
+
# Releases the semaphore when the write task is done
|
350
|
+
def queue_upload_task(
|
351
|
+
read_fut=read_fut,
|
352
|
+
) -> None:
|
353
|
+
upload_part = read_fut.result()
|
354
|
+
upload_fut: Future[UploadPart] = upload_executor.submit(
|
355
|
+
upload_task, self, upload_part
|
356
|
+
)
|
357
|
+
# SEMAPHORE RELEASE!!!
|
358
|
+
upload_fut.add_done_callback(
|
359
|
+
lambda _: write_semaphore.release()
|
360
|
+
)
|
361
|
+
upload_fut.add_done_callback(
|
362
|
+
lambda fut: finished_tasks.append(fut.result())
|
363
|
+
)
|
364
|
+
|
365
|
+
read_fut.add_done_callback(queue_upload_task)
|
366
|
+
# SEMAPHORE ACQUIRE!!!
|
367
|
+
# If we are back filled on the writers, then we stall.
|
368
|
+
write_semaphore.acquire()
|
369
|
+
|
370
|
+
exceptions: list[Exception] = [
|
371
|
+
t.exception for t in finished_tasks if t.exception is not None
|
372
|
+
]
|
373
|
+
if len(exceptions) > 0:
|
374
|
+
return Exception(f"Failed to copy parts: {exceptions}", exceptions)
|
375
|
+
|
376
|
+
finished_parts: list[int] = info_json.fetch_all_finished_part_numbers()
|
377
|
+
print(f"finished_names: {finished_parts}")
|
378
|
+
|
379
|
+
diff_set = set(all_part_numbers).symmetric_difference(set(finished_parts))
|
380
|
+
all_part_numbers_done = len(diff_set) == 0
|
381
|
+
print(f"all_part_numbers_done: {all_part_numbers_done}")
|
382
|
+
return None
|
rclone_api/dir.py
CHANGED
@@ -72,7 +72,7 @@ class Dir:
|
|
72
72
|
self, breadth_first: bool, max_depth: int = -1
|
73
73
|
) -> Generator[DirListing, None, None]:
|
74
74
|
"""List files and directories in the given path."""
|
75
|
-
from rclone_api.walk import walk
|
75
|
+
from rclone_api.detail.walk import walk
|
76
76
|
|
77
77
|
assert self.path.rclone is not None
|
78
78
|
return walk(self, breadth_first=breadth_first, max_depth=max_depth)
|
rclone_api/dir_listing.py
CHANGED
rclone_api/file.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
import json
|
2
2
|
import warnings
|
3
3
|
from dataclasses import dataclass
|
4
|
+
from datetime import datetime
|
4
5
|
from pathlib import Path
|
5
6
|
|
6
7
|
from rclone_api.rpath import RPath
|
@@ -146,6 +147,13 @@ class File:
|
|
146
147
|
def name(self) -> str:
|
147
148
|
return self.path.name
|
148
149
|
|
150
|
+
def mod_time(self) -> str:
|
151
|
+
return self.path.mod_time
|
152
|
+
|
153
|
+
def mod_time_dt(self) -> datetime:
|
154
|
+
"""Return the modification time as a datetime object."""
|
155
|
+
return self.path.mod_time_dt()
|
156
|
+
|
149
157
|
def read_text(self) -> str:
|
150
158
|
"""Read the file contents as bytes.
|
151
159
|
|
rclone_api/file_part.py
ADDED
@@ -0,0 +1,198 @@
|
|
1
|
+
import atexit
|
2
|
+
import os
|
3
|
+
import time
|
4
|
+
import warnings
|
5
|
+
from pathlib import Path
|
6
|
+
from threading import Lock
|
7
|
+
from typing import Any
|
8
|
+
|
9
|
+
_TMP_DIR_ACCESS_LOCK = Lock()
|
10
|
+
|
11
|
+
|
12
|
+
def _clean_old_files(out: Path) -> None:
|
13
|
+
# clean up files older than 1 day
|
14
|
+
from rclone_api.util import locked_print
|
15
|
+
|
16
|
+
now = time.time()
|
17
|
+
# Erase all stale files and then purge empty directories.
|
18
|
+
for root, dirs, files in os.walk(out):
|
19
|
+
for name in files:
|
20
|
+
f = Path(root) / name
|
21
|
+
filemod = f.stat().st_mtime
|
22
|
+
diff_secs = now - filemod
|
23
|
+
diff_days = diff_secs / (60 * 60 * 24)
|
24
|
+
if diff_days > 1:
|
25
|
+
locked_print(f"Removing old file: {f}")
|
26
|
+
f.unlink()
|
27
|
+
|
28
|
+
for root, dirs, _ in os.walk(out):
|
29
|
+
for dir in dirs:
|
30
|
+
d = Path(root) / dir
|
31
|
+
if not list(d.iterdir()):
|
32
|
+
locked_print(f"Removing empty directory: {d}")
|
33
|
+
d.rmdir()
|
34
|
+
|
35
|
+
|
36
|
+
def get_chunk_tmpdir() -> Path:
|
37
|
+
with _TMP_DIR_ACCESS_LOCK:
|
38
|
+
dat = get_chunk_tmpdir.__dict__
|
39
|
+
if "out" in dat:
|
40
|
+
return dat["out"] # Folder already validated.
|
41
|
+
out = Path("chunk_store")
|
42
|
+
if out.exists():
|
43
|
+
# first access, clean up directory
|
44
|
+
_clean_old_files(out)
|
45
|
+
out.mkdir(exist_ok=True, parents=True)
|
46
|
+
dat["out"] = out
|
47
|
+
return out
|
48
|
+
|
49
|
+
|
50
|
+
_CLEANUP_LIST: list[Path] = []
|
51
|
+
|
52
|
+
|
53
|
+
def _add_for_cleanup(path: Path) -> None:
|
54
|
+
_CLEANUP_LIST.append(path)
|
55
|
+
|
56
|
+
|
57
|
+
def _on_exit_cleanup() -> None:
|
58
|
+
paths = list(_CLEANUP_LIST)
|
59
|
+
for path in paths:
|
60
|
+
try:
|
61
|
+
if path.exists():
|
62
|
+
path.unlink()
|
63
|
+
except Exception as e:
|
64
|
+
warnings.warn(f"Cannot cleanup {path}: {e}")
|
65
|
+
|
66
|
+
|
67
|
+
atexit.register(_on_exit_cleanup)
|
68
|
+
|
69
|
+
|
70
|
+
_FILEPARTS: list["FilePart"] = []
|
71
|
+
|
72
|
+
_FILEPARTS_LOCK = Lock()
|
73
|
+
|
74
|
+
|
75
|
+
def _add_filepart(part: "FilePart") -> None:
|
76
|
+
with _FILEPARTS_LOCK:
|
77
|
+
if part not in _FILEPARTS:
|
78
|
+
_FILEPARTS.append(part)
|
79
|
+
|
80
|
+
|
81
|
+
def _remove_filepart(part: "FilePart") -> None:
|
82
|
+
with _FILEPARTS_LOCK:
|
83
|
+
if part in _FILEPARTS:
|
84
|
+
_FILEPARTS.remove(part)
|
85
|
+
|
86
|
+
|
87
|
+
def run_debug_parts():
|
88
|
+
while True:
|
89
|
+
print("\nAlive file parts:")
|
90
|
+
for part in list(_FILEPARTS):
|
91
|
+
print(part)
|
92
|
+
# print(part.stacktrace)
|
93
|
+
print("\n\n")
|
94
|
+
time.sleep(60)
|
95
|
+
|
96
|
+
|
97
|
+
# dbg_thread = threading.Thread(target=run_debug_parts)
|
98
|
+
# dbg_thread.start()
|
99
|
+
|
100
|
+
|
101
|
+
class FilePart:
|
102
|
+
def __init__(self, payload: Path | bytes | Exception, extra: Any) -> None:
|
103
|
+
import traceback
|
104
|
+
|
105
|
+
from rclone_api.util import random_str
|
106
|
+
|
107
|
+
stacktrace = traceback.format_stack()
|
108
|
+
stacktrace_str = "".join(stacktrace)
|
109
|
+
self.stacktrace = stacktrace_str
|
110
|
+
# _FILEPARTS.append(self)
|
111
|
+
_add_filepart(self)
|
112
|
+
|
113
|
+
self.extra = extra
|
114
|
+
self._lock = Lock()
|
115
|
+
self.payload: Path | Exception
|
116
|
+
if isinstance(payload, Exception):
|
117
|
+
self.payload = payload
|
118
|
+
return
|
119
|
+
if isinstance(payload, bytes):
|
120
|
+
print(f"Creating file part with payload: {len(payload)}")
|
121
|
+
self.payload = get_chunk_tmpdir() / f"{random_str(12)}.chunk"
|
122
|
+
with _TMP_DIR_ACCESS_LOCK:
|
123
|
+
if not self.payload.parent.exists():
|
124
|
+
self.payload.parent.mkdir(parents=True, exist_ok=True)
|
125
|
+
self.payload.write_bytes(payload)
|
126
|
+
_add_for_cleanup(self.payload)
|
127
|
+
if isinstance(payload, Path):
|
128
|
+
print("Adopting payload: ", payload)
|
129
|
+
self.payload = payload
|
130
|
+
_add_for_cleanup(self.payload)
|
131
|
+
|
132
|
+
def get_file(self) -> Path | Exception:
|
133
|
+
return self.payload
|
134
|
+
|
135
|
+
@property
|
136
|
+
def size(self) -> int:
|
137
|
+
with self._lock:
|
138
|
+
if isinstance(self.payload, Path):
|
139
|
+
return self.payload.stat().st_size
|
140
|
+
return -1
|
141
|
+
|
142
|
+
def n_bytes(self) -> int:
|
143
|
+
with self._lock:
|
144
|
+
if isinstance(self.payload, Path):
|
145
|
+
return self.payload.stat().st_size
|
146
|
+
return -1
|
147
|
+
|
148
|
+
def load(self) -> bytes:
|
149
|
+
with self._lock:
|
150
|
+
if isinstance(self.payload, Path):
|
151
|
+
with open(self.payload, "rb") as f:
|
152
|
+
return f.read()
|
153
|
+
raise ValueError("Cannot load from error")
|
154
|
+
|
155
|
+
def __post_init__(self):
|
156
|
+
if isinstance(self.payload, Path):
|
157
|
+
assert self.payload.exists(), f"File part {self.payload} does not exist"
|
158
|
+
assert self.payload.is_file(), f"File part {self.payload} is not a file"
|
159
|
+
assert self.payload.stat().st_size > 0, f"File part {self.payload} is empty"
|
160
|
+
elif isinstance(self.payload, Exception):
|
161
|
+
warnings.warn(f"File part error: {self.payload}")
|
162
|
+
print(f"File part created with payload: {self.payload}")
|
163
|
+
|
164
|
+
def is_error(self) -> bool:
|
165
|
+
return isinstance(self.payload, Exception)
|
166
|
+
|
167
|
+
def dispose(self) -> None:
|
168
|
+
# _FILEPARTS.remove(self)
|
169
|
+
_remove_filepart(self)
|
170
|
+
print("Disposing file part")
|
171
|
+
with self._lock:
|
172
|
+
if isinstance(self.payload, Exception):
|
173
|
+
warnings.warn(
|
174
|
+
f"Cannot close file part because the payload represents an error: {self.payload}"
|
175
|
+
)
|
176
|
+
print("Cannot close file part because the payload represents an error")
|
177
|
+
return
|
178
|
+
if self.payload.exists():
|
179
|
+
print(f"File part {self.payload} exists")
|
180
|
+
try:
|
181
|
+
print(f"Unlinking file part {self.payload}")
|
182
|
+
self.payload.unlink()
|
183
|
+
print(f"File part {self.payload} deleted")
|
184
|
+
except Exception as e:
|
185
|
+
warnings.warn(f"Cannot close file part because of error: {e}")
|
186
|
+
else:
|
187
|
+
warnings.warn(
|
188
|
+
f"Cannot close file part because it does not exist: {self.payload}"
|
189
|
+
)
|
190
|
+
|
191
|
+
def __del__(self):
|
192
|
+
self.dispose()
|
193
|
+
|
194
|
+
def __repr__(self):
|
195
|
+
from rclone_api.types import SizeSuffix
|
196
|
+
|
197
|
+
payload_str = "err" if self.is_error() else f"{SizeSuffix(self.n_bytes())}"
|
198
|
+
return f"FilePart(payload={payload_str}, extra={self.extra})"
|
@@ -0,0 +1,52 @@
|
|
1
|
+
"""
|
2
|
+
Unit test file.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from typing import Generator
|
6
|
+
|
7
|
+
from rclone_api.file import FileItem
|
8
|
+
from rclone_api.process import Process
|
9
|
+
|
10
|
+
|
11
|
+
class FilesStream:
|
12
|
+
|
13
|
+
def __init__(self, path: str, process: Process) -> None:
|
14
|
+
self.path = path
|
15
|
+
self.process = process
|
16
|
+
|
17
|
+
def __enter__(self) -> "FilesStream":
|
18
|
+
self.process.__enter__()
|
19
|
+
return self
|
20
|
+
|
21
|
+
def __exit__(self, *exc_info):
|
22
|
+
self.process.__exit__(*exc_info)
|
23
|
+
|
24
|
+
def files(self) -> Generator[FileItem, None, None]:
|
25
|
+
line: bytes
|
26
|
+
for line in self.process.stdout:
|
27
|
+
linestr: str = line.decode("utf-8").strip()
|
28
|
+
if linestr.startswith("["):
|
29
|
+
continue
|
30
|
+
if linestr.endswith(","):
|
31
|
+
linestr = linestr[:-1]
|
32
|
+
if linestr.endswith("]"):
|
33
|
+
continue
|
34
|
+
fileitem: FileItem | None = FileItem.from_json_str(self.path, linestr)
|
35
|
+
if fileitem is None:
|
36
|
+
continue
|
37
|
+
yield fileitem
|
38
|
+
|
39
|
+
def files_paged(
|
40
|
+
self, page_size: int = 1000
|
41
|
+
) -> Generator[list[FileItem], None, None]:
|
42
|
+
page: list[FileItem] = []
|
43
|
+
for fileitem in self.files():
|
44
|
+
page.append(fileitem)
|
45
|
+
if len(page) >= page_size:
|
46
|
+
yield page
|
47
|
+
page = []
|
48
|
+
if len(page) > 0:
|
49
|
+
yield page
|
50
|
+
|
51
|
+
def __iter__(self) -> Generator[FileItem, None, None]:
|
52
|
+
return self.files()
|