rclone-api 1.3.27__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.
Files changed (34) hide show
  1. rclone_api/__init__.py +491 -4
  2. rclone_api/cmd/copy_large_s3.py +17 -10
  3. rclone_api/db/db.py +3 -3
  4. rclone_api/detail/copy_file_parts.py +382 -0
  5. rclone_api/dir.py +1 -1
  6. rclone_api/dir_listing.py +1 -1
  7. rclone_api/file.py +8 -0
  8. rclone_api/file_part.py +198 -0
  9. rclone_api/file_stream.py +52 -0
  10. rclone_api/http_server.py +15 -21
  11. rclone_api/{rclone.py → rclone_impl.py} +153 -321
  12. rclone_api/remote.py +3 -3
  13. rclone_api/rpath.py +11 -4
  14. rclone_api/s3/chunk_task.py +3 -19
  15. rclone_api/s3/multipart/file_info.py +7 -0
  16. rclone_api/s3/multipart/finished_piece.py +38 -0
  17. rclone_api/s3/multipart/upload_info.py +62 -0
  18. rclone_api/s3/{chunk_types.py → multipart/upload_state.py} +3 -99
  19. rclone_api/s3/s3_multipart_uploader.py +138 -0
  20. rclone_api/s3/types.py +1 -1
  21. rclone_api/s3/upload_file_multipart.py +14 -14
  22. rclone_api/scan_missing_folders.py +1 -1
  23. rclone_api/types.py +136 -165
  24. rclone_api/util.py +22 -2
  25. {rclone_api-1.3.27.dist-info → rclone_api-1.4.1.dist-info}/METADATA +1 -1
  26. rclone_api-1.4.1.dist-info/RECORD +55 -0
  27. rclone_api/mount_read_chunker.py +0 -130
  28. rclone_api/profile/mount_copy_bytes.py +0 -311
  29. rclone_api-1.3.27.dist-info/RECORD +0 -50
  30. /rclone_api/{walk.py → detail/walk.py} +0 -0
  31. {rclone_api-1.3.27.dist-info → rclone_api-1.4.1.dist-info}/LICENSE +0 -0
  32. {rclone_api-1.3.27.dist-info → rclone_api-1.4.1.dist-info}/WHEEL +0 -0
  33. {rclone_api-1.3.27.dist-info → rclone_api-1.4.1.dist-info}/entry_points.txt +0 -0
  34. {rclone_api-1.3.27.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
@@ -42,7 +42,7 @@ class DirListing:
42
42
  def __str__(self) -> str:
43
43
  n_files = len(self.files)
44
44
  n_dirs = len(self.dirs)
45
- msg = f"Files: {n_files}\n"
45
+ msg = f"\nFiles: {n_files}\n"
46
46
  if n_files > 0:
47
47
  for f in self.files:
48
48
  msg += f" {f}\n"
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
 
@@ -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()