rclone-api 1.4.19__tar.gz → 1.4.22__tar.gz

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 (109) hide show
  1. {rclone_api-1.4.19 → rclone_api-1.4.22}/PKG-INFO +1 -1
  2. {rclone_api-1.4.19 → rclone_api-1.4.22}/pyproject.toml +1 -1
  3. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/__init__.py +8 -3
  4. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/cmd/copy_large_s3.py +0 -2
  5. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/cmd/copy_large_s3_finish.py +2 -2
  6. rclone_api-1.4.22/src/rclone_api/detail/copy_file_parts_resumable.py +42 -0
  7. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/http_server.py +4 -1
  8. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/rclone_impl.py +42 -15
  9. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/s3/api.py +2 -2
  10. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/s3/multipart/finished_piece.py +21 -3
  11. rclone_api-1.4.22/src/rclone_api/s3/multipart/info_json.py +239 -0
  12. rclone_api-1.4.19/src/rclone_api/detail/copy_file_parts.py → rclone_api-1.4.22/src/rclone_api/s3/multipart/upload_parts_resumable.py +38 -243
  13. rclone_api-1.4.19/src/rclone_api/s3/s3_multipart_uploader_by_copy.py → rclone_api-1.4.22/src/rclone_api/s3/multipart/upload_parts_server_side_merge.py +53 -25
  14. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/types.py +1 -1
  15. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api.egg-info/PKG-INFO +1 -1
  16. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api.egg-info/SOURCES.txt +6 -4
  17. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_copy_file_resumable_s3.py +21 -13
  18. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_s3.py +1 -1
  19. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_size_files.py +5 -1
  20. {rclone_api-1.4.19 → rclone_api-1.4.22}/.aiderignore +0 -0
  21. {rclone_api-1.4.19 → rclone_api-1.4.22}/.github/workflows/lint.yml +0 -0
  22. {rclone_api-1.4.19 → rclone_api-1.4.22}/.github/workflows/push_macos.yml +0 -0
  23. {rclone_api-1.4.19 → rclone_api-1.4.22}/.github/workflows/push_ubuntu.yml +0 -0
  24. {rclone_api-1.4.19 → rclone_api-1.4.22}/.github/workflows/push_win.yml +0 -0
  25. {rclone_api-1.4.19 → rclone_api-1.4.22}/.gitignore +0 -0
  26. {rclone_api-1.4.19 → rclone_api-1.4.22}/.pylintrc +0 -0
  27. {rclone_api-1.4.19 → rclone_api-1.4.22}/.vscode/launch.json +0 -0
  28. {rclone_api-1.4.19 → rclone_api-1.4.22}/.vscode/settings.json +0 -0
  29. {rclone_api-1.4.19 → rclone_api-1.4.22}/.vscode/tasks.json +0 -0
  30. {rclone_api-1.4.19 → rclone_api-1.4.22}/LICENSE +0 -0
  31. {rclone_api-1.4.19 → rclone_api-1.4.22}/MANIFEST.in +0 -0
  32. {rclone_api-1.4.19 → rclone_api-1.4.22}/README.md +0 -0
  33. {rclone_api-1.4.19 → rclone_api-1.4.22}/clean +0 -0
  34. {rclone_api-1.4.19 → rclone_api-1.4.22}/install +0 -0
  35. {rclone_api-1.4.19 → rclone_api-1.4.22}/lint +0 -0
  36. {rclone_api-1.4.19 → rclone_api-1.4.22}/requirements.testing.txt +0 -0
  37. {rclone_api-1.4.19 → rclone_api-1.4.22}/setup.cfg +0 -0
  38. {rclone_api-1.4.19 → rclone_api-1.4.22}/setup.py +0 -0
  39. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/assets/example.txt +0 -0
  40. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/cli.py +0 -0
  41. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/cmd/analyze.py +0 -0
  42. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/cmd/list_files.py +0 -0
  43. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/cmd/save_to_db.py +0 -0
  44. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/completed_process.py +0 -0
  45. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/config.py +0 -0
  46. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/convert.py +0 -0
  47. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/db/__init__.py +0 -0
  48. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/db/db.py +0 -0
  49. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/db/models.py +0 -0
  50. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/deprecated.py +0 -0
  51. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/detail/walk.py +0 -0
  52. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/diff.py +0 -0
  53. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/dir.py +0 -0
  54. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/dir_listing.py +0 -0
  55. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/exec.py +0 -0
  56. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/experimental/flags.py +0 -0
  57. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/experimental/flags_base.py +0 -0
  58. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/file.py +0 -0
  59. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/file_item.py +0 -0
  60. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/file_part.py +0 -0
  61. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/file_stream.py +0 -0
  62. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/filelist.py +0 -0
  63. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/group_files.py +0 -0
  64. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/log.py +0 -0
  65. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/mount.py +0 -0
  66. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/process.py +0 -0
  67. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/remote.py +0 -0
  68. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/rpath.py +0 -0
  69. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/s3/basic_ops.py +0 -0
  70. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/s3/chunk_task.py +0 -0
  71. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/s3/create.py +0 -0
  72. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/s3/multipart/file_info.py +0 -0
  73. {rclone_api-1.4.19/src/rclone_api/s3 → rclone_api-1.4.22/src/rclone_api/s3/multipart}/merge_state.py +0 -0
  74. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/s3/multipart/upload_info.py +0 -0
  75. /rclone_api-1.4.19/src/rclone_api/s3/upload_file_multipart.py → /rclone_api-1.4.22/src/rclone_api/s3/multipart/upload_parts_inline.py +0 -0
  76. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/s3/multipart/upload_state.py +0 -0
  77. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/s3/types.py +0 -0
  78. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/scan_missing_folders.py +0 -0
  79. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api/util.py +0 -0
  80. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api.egg-info/dependency_links.txt +0 -0
  81. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api.egg-info/entry_points.txt +0 -0
  82. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api.egg-info/requires.txt +0 -0
  83. {rclone_api-1.4.19 → rclone_api-1.4.22}/src/rclone_api.egg-info/top_level.txt +0 -0
  84. {rclone_api-1.4.19 → rclone_api-1.4.22}/test +0 -0
  85. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/archive/test_paramiko.py.disabled +0 -0
  86. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_cmd_list_files.py +0 -0
  87. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_copy.py +0 -0
  88. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_copy_bytes.py +0 -0
  89. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_copy_files.py +0 -0
  90. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_db.py +0 -0
  91. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_diff.py +0 -0
  92. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_file_item.py +0 -0
  93. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_group_files.py +0 -0
  94. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_is_synced.py +0 -0
  95. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_ls.py +0 -0
  96. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_ls_stream_files.py +0 -0
  97. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_mount.py +0 -0
  98. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_mount_s3.py +0 -0
  99. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_obscure.py +0 -0
  100. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_rclone_config.py +0 -0
  101. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_read_write_text.py +0 -0
  102. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_remote_control.py +0 -0
  103. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_remotes.py +0 -0
  104. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_scan_missing_folders.py +0 -0
  105. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_serve_http.py +0 -0
  106. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_size_suffix.py +0 -0
  107. {rclone_api-1.4.19 → rclone_api-1.4.22}/tests/test_walk.py +0 -0
  108. {rclone_api-1.4.19 → rclone_api-1.4.22}/tox.ini +0 -0
  109. {rclone_api-1.4.19 → rclone_api-1.4.22}/upload_package.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: rclone_api
3
- Version: 1.4.19
3
+ Version: 1.4.22
4
4
  Summary: rclone api in python
5
5
  Home-page: https://github.com/zackees/rclone-api
6
6
  License: BSD 3-Clause License
@@ -25,7 +25,7 @@ dependencies = [
25
25
  ]
26
26
 
27
27
  # Change this with the version number bump.
28
- version = "1.4.19"
28
+ version = "1.4.22"
29
29
 
30
30
  [tool.setuptools]
31
31
  package-dir = {"" = "src"}
@@ -428,11 +428,16 @@ class Rclone:
428
428
  src: str, # src:/Bucket/path/myfile.large.zst
429
429
  dst_dir: str, # dst:/Bucket/path/myfile.large.zst-parts/part.{part_number:05d}.start-end
430
430
  part_infos: list[PartInfo] | None = None,
431
- threads: int = 1, # Number of reader and writer threads to use
431
+ upload_threads: int = 8, # Number of reader and writer threads to use
432
+ merge_threads: int = 4, # Number of threads to use for merging the parts
432
433
  ) -> Exception | None:
433
434
  """Copy a file in parts."""
434
435
  return self.impl.copy_file_parts(
435
- src=src, dst_dir=dst_dir, part_infos=part_infos, threads=threads
436
+ src=src,
437
+ dst_dir=dst_dir,
438
+ part_infos=part_infos,
439
+ upload_threads=upload_threads,
440
+ merge_threads=merge_threads,
436
441
  )
437
442
 
438
443
  def mount(
@@ -496,7 +501,7 @@ class Rclone:
496
501
  other_args: list[str] | None = None,
497
502
  check: bool | None = False,
498
503
  verbose: bool | None = None,
499
- ) -> SizeResult:
504
+ ) -> SizeResult | Exception:
500
505
  """Get the size of a list of files. Example of files items: "remote:bucket/to/file"."""
501
506
  return self.impl.size_files(
502
507
  src=src,
@@ -90,8 +90,6 @@ def main() -> int:
90
90
  err: Exception | None = rclone.copy_file_parts(
91
91
  src=args.src,
92
92
  dst_dir=args.dst,
93
- threads=8,
94
- # verbose=args.verbose,
95
93
  )
96
94
  if err is not None:
97
95
  print(f"Error: {err}")
@@ -3,7 +3,7 @@ from dataclasses import dataclass
3
3
  from pathlib import Path
4
4
 
5
5
  from rclone_api import Rclone
6
- from rclone_api.s3.s3_multipart_uploader_by_copy import (
6
+ from rclone_api.s3.multipart.upload_parts_server_side_merge import (
7
7
  s3_server_side_multi_part_merge,
8
8
  )
9
9
 
@@ -57,7 +57,7 @@ def main() -> int:
57
57
  rclone = Rclone(rclone_conf=args.config_path)
58
58
  info_path = _get_info_path(src=args.src)
59
59
  s3_server_side_multi_part_merge(
60
- rclone=rclone.impl, info_path=info_path, max_workers=1
60
+ rclone=rclone.impl, info_path=info_path, max_workers=5
61
61
  )
62
62
  return 0
63
63
 
@@ -0,0 +1,42 @@
1
+ from rclone_api.rclone_impl import RcloneImpl
2
+ from rclone_api.types import (
3
+ PartInfo,
4
+ )
5
+
6
+
7
+ def copy_file_parts_resumable(
8
+ self: RcloneImpl,
9
+ src: str, # src:/Bucket/path/myfile.large.zst
10
+ dst_dir: str, # dst:/Bucket/path/myfile.large.zst-parts/
11
+ part_infos: list[PartInfo] | None = None,
12
+ upload_threads: int = 10,
13
+ merge_threads: int = 5,
14
+ verbose: bool | None = None,
15
+ ) -> Exception | None:
16
+ # _upload_parts
17
+ from rclone_api.s3.multipart.upload_parts_resumable import upload_parts_resumable
18
+ from rclone_api.s3.multipart.upload_parts_server_side_merge import (
19
+ s3_server_side_multi_part_merge,
20
+ )
21
+
22
+ if verbose is None:
23
+ verbose = self.get_verbose()
24
+
25
+ err: Exception | None = upload_parts_resumable(
26
+ self=self,
27
+ src=src,
28
+ dst_dir=dst_dir,
29
+ part_infos=part_infos,
30
+ threads=upload_threads,
31
+ )
32
+ if isinstance(err, Exception):
33
+ return err
34
+ if dst_dir.endswith("/"):
35
+ dst_dir = dst_dir[:-1]
36
+ dst_info = f"{dst_dir}/info.json"
37
+ err = s3_server_side_multi_part_merge(
38
+ rclone=self, info_path=dst_info, max_workers=merge_threads, verbose=verbose
39
+ )
40
+ if isinstance(err, Exception):
41
+ return err
42
+ return None
@@ -86,7 +86,10 @@ class HttpServer:
86
86
  assert response.is_closed
87
87
  # print(f"Downloaded bytes {start}-{end} to {dst}")
88
88
  if range:
89
- print(f"Downloaded bytes {range.start}-{range.end} to {dst}")
89
+ length = range.end - range.start
90
+ print(
91
+ f"Downloaded bytes starting at {range.start} with size {length} to {dst}"
92
+ )
90
93
  else:
91
94
  size = dst.stat().st_size
92
95
  print(f"Downloaded {size} bytes to {dst}")
@@ -455,6 +455,9 @@ class RcloneImpl:
455
455
  out = self._run(cmd)
456
456
  return CompletedProcess.from_subprocess(out)
457
457
 
458
+ def get_verbose(self) -> bool:
459
+ return get_verbose(None)
460
+
458
461
  def copy_to(
459
462
  self,
460
463
  src: File | str,
@@ -789,17 +792,21 @@ class RcloneImpl:
789
792
  src: str, # src:/Bucket/path/myfile.large.zst
790
793
  dst_dir: str, # dst:/Bucket/path/myfile.large.zst-parts/
791
794
  part_infos: list[PartInfo] | None = None,
792
- threads: int = 1,
795
+ upload_threads: int = 8,
796
+ merge_threads: int = 4,
793
797
  ) -> Exception | None:
794
798
  """Copy parts of a file from source to destination."""
795
- from rclone_api.detail.copy_file_parts import copy_file_parts
799
+ from rclone_api.detail.copy_file_parts_resumable import (
800
+ copy_file_parts_resumable,
801
+ )
796
802
 
797
- out = copy_file_parts(
803
+ out = copy_file_parts_resumable(
798
804
  self=self,
799
805
  src=src,
800
806
  dst_dir=dst_dir,
801
807
  part_infos=part_infos,
802
- threads=threads,
808
+ upload_threads=upload_threads,
809
+ merge_threads=merge_threads,
803
810
  )
804
811
  return out
805
812
 
@@ -853,15 +860,25 @@ class RcloneImpl:
853
860
 
854
861
  def size_file(self, src: str) -> SizeSuffix | Exception:
855
862
  """Get the size of a file or directory."""
856
- src_parent = os.path.dirname(src)
857
- src_name = os.path.basename(src)
858
- out: SizeResult = self.size_files(src_parent, [src_name])
859
- one_file = len(out.file_sizes) == 1
860
- if not one_file:
861
- return Exception(
862
- f"More than one result returned, is this is a directory? {out}"
863
- )
864
- return SizeSuffix(out.total_size)
863
+ # src_parent = os.path.dirname(src)
864
+ # src_name = os.path.basename(src)
865
+ # can't use this because it's only one file.
866
+ # out: SizeResult = self.size_files(src_parent, [src_name])
867
+ # one_file = len(out.file_sizes) == 1
868
+ # if not one_file:
869
+ # return Exception(
870
+ # f"More than one result returned, is this is a directory? {out}"
871
+ # )
872
+ # return SizeSuffix(out.total_size)
873
+ dirlist: DirListing = self.ls(
874
+ src, listing_option=ListingOption.FILES_ONLY, max_depth=0
875
+ )
876
+ if len(dirlist.files) == 0:
877
+ return FileNotFoundError(f"File not found: {src}")
878
+ if len(dirlist.files) > 1:
879
+ return Exception(f"More than one file found: {src}")
880
+ file: File = dirlist.files[0]
881
+ return SizeSuffix(file.size)
865
882
 
866
883
  def get_s3_credentials(
867
884
  self, remote: str, verbose: bool | None = None
@@ -943,7 +960,9 @@ class RcloneImpl:
943
960
  name = src_path.name
944
961
  src_parent_path = Path(src).parent.as_posix()
945
962
 
946
- size_result: SizeResult = self.size_files(src_parent_path, [name])
963
+ size_result: SizeResult | Exception = self.size_files(src_parent_path, [name])
964
+ if isinstance(size_result, Exception):
965
+ raise size_result
947
966
  target_size = SizeSuffix(size_result.total_size)
948
967
 
949
968
  chunk_size = chunk_size or SizeSuffix("64M")
@@ -1286,10 +1305,18 @@ class RcloneImpl:
1286
1305
  other_args: list[str] | None = None,
1287
1306
  check: bool | None = False,
1288
1307
  verbose: bool | None = None,
1289
- ) -> SizeResult:
1308
+ ) -> SizeResult | Exception:
1290
1309
  """Get the size of a list of files. Example of files items: "remote:bucket/to/file"."""
1291
1310
  verbose = get_verbose(verbose)
1292
1311
  check = get_check(check)
1312
+ if len(files) < 2:
1313
+ tmp = self.size_file(files[0])
1314
+ if isinstance(tmp, Exception):
1315
+ return tmp
1316
+ assert isinstance(tmp, SizeSuffix)
1317
+ return SizeResult(
1318
+ prefix=src, total_size=tmp.as_int(), file_sizes={files[0]: tmp.as_int()}
1319
+ )
1293
1320
  if fast_list or (other_args and "--fast-list" in other_args):
1294
1321
  warnings.warn(
1295
1322
  "It's not recommended to use --fast-list with size_files as this will perform poorly on large repositories since the entire repository has to be scanned."
@@ -10,11 +10,11 @@ from rclone_api.s3.basic_ops import (
10
10
  upload_file,
11
11
  )
12
12
  from rclone_api.s3.create import S3Config, create_s3_client
13
- from rclone_api.s3.types import S3Credentials, S3MutliPartUploadConfig, S3UploadTarget
14
- from rclone_api.s3.upload_file_multipart import (
13
+ from rclone_api.s3.multipart.upload_parts_inline import (
15
14
  MultiUploadResult,
16
15
  upload_file_multipart,
17
16
  )
17
+ from rclone_api.s3.types import S3Credentials, S3MutliPartUploadConfig, S3UploadTarget
18
18
 
19
19
  _MIN_THRESHOLD_FOR_CHUNKING = 5 * 1024 * 1024
20
20
 
@@ -10,7 +10,17 @@ class FinishedPiece:
10
10
  etag: str
11
11
 
12
12
  def to_json(self) -> dict:
13
- return {"part_number": self.part_number, "etag": self.etag}
13
+ # return {"part_number": self.part_number, "etag": self.etag}
14
+ # amazon s3 style dict
15
+ tag = self.etag
16
+ if not tag.startswith('"'):
17
+ tag = f'"{tag}"'
18
+ out = {"PartNumber": self.part_number, "ETag": self.etag}
19
+ return out
20
+
21
+ def __post_init__(self):
22
+ assert isinstance(self.part_number, int)
23
+ assert isinstance(self.etag, str)
14
24
 
15
25
  @staticmethod
16
26
  def to_json_array(
@@ -30,13 +40,21 @@ class FinishedPiece:
30
40
  # assert count_eos <= 1, "Only one EndOfStream should be present"
31
41
  if count_eos > 1:
32
42
  warnings.warn(f"Only one EndOfStream should be present, found {count_eos}")
33
- return [p.to_json() for p in non_none]
43
+ out = [p.to_json() for p in non_none]
44
+ return out
34
45
 
35
46
  @staticmethod
36
47
  def from_json(json: dict | None) -> "FinishedPiece | EndOfStream":
37
48
  if json is None:
38
49
  return EndOfStream()
39
- return FinishedPiece(**json)
50
+ part_number = json.get("PartNumber")
51
+ etag = json.get("ETag")
52
+ assert isinstance(etag, str)
53
+ # handle the double quotes around the etag
54
+ etag = etag.replace('"', "")
55
+ assert isinstance(part_number, int)
56
+ assert isinstance(etag, str)
57
+ return FinishedPiece(part_number=part_number, etag=etag)
40
58
 
41
59
  @staticmethod
42
60
  def from_json_array(json: dict) -> list["FinishedPiece"]:
@@ -0,0 +1,239 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import warnings
5
+ from datetime import datetime
6
+
7
+ from rclone_api.dir_listing import DirListing
8
+ from rclone_api.rclone_impl import RcloneImpl
9
+ from rclone_api.types import (
10
+ PartInfo,
11
+ SizeSuffix,
12
+ )
13
+
14
+
15
+ def _fetch_all_names(
16
+ self: RcloneImpl,
17
+ src: str,
18
+ ) -> list[str]:
19
+ dl: DirListing = self.ls(src)
20
+ files = dl.files
21
+ filenames: list[str] = [f.name for f in files]
22
+ filtered: list[str] = [f for f in filenames if f.startswith("part.")]
23
+ return filtered
24
+
25
+
26
+ def _get_info_json(self: RcloneImpl, src: str | None, src_info: str) -> dict:
27
+ from rclone_api.file import File
28
+
29
+ data: dict
30
+ text: str
31
+ if src is None:
32
+ # just try to load the file
33
+ text_or_err = self.read_text(src_info)
34
+ if isinstance(text_or_err, Exception):
35
+ raise FileNotFoundError(f"Could not load {src_info}: {text_or_err}")
36
+ assert isinstance(text_or_err, str)
37
+ text = text_or_err
38
+ data = json.loads(text)
39
+ return data
40
+
41
+ src_stat: File | Exception = self.stat(src)
42
+ if isinstance(src_stat, Exception):
43
+ # just try to load the file
44
+ raise FileNotFoundError(f"Failed to stat {src}: {src_stat}")
45
+
46
+ now: datetime = datetime.now()
47
+ new_data = {
48
+ "new": True,
49
+ "created": now.isoformat(),
50
+ "src": src,
51
+ "src_modtime": src_stat.mod_time(),
52
+ "size": src_stat.size,
53
+ "chunksize": None,
54
+ "chunksize_int": None,
55
+ "first_part": None,
56
+ "last_part": None,
57
+ "hash": None,
58
+ }
59
+
60
+ text_or_err = self.read_text(src_info)
61
+ err: Exception | None = text_or_err if isinstance(text_or_err, Exception) else None
62
+ if isinstance(text_or_err, Exception):
63
+ warnings.warn(f"Failed to read {src_info}: {text_or_err}")
64
+ return new_data
65
+ assert isinstance(text_or_err, str)
66
+ text = text_or_err
67
+
68
+ if err is not None:
69
+ return new_data
70
+
71
+ try:
72
+ data = json.loads(text)
73
+ return data
74
+ except Exception as e:
75
+ warnings.warn(f"Failed to parse JSON: {e} at {src_info}")
76
+ return new_data
77
+
78
+
79
+ def _save_info_json(self: RcloneImpl, src: str, data: dict) -> None:
80
+ data = data.copy()
81
+ data["new"] = False
82
+ # hash
83
+
84
+ h = hashlib.md5()
85
+ tmp = [
86
+ data.get("src"),
87
+ data.get("src_modtime"),
88
+ data.get("size"),
89
+ data.get("chunksize_int"),
90
+ ]
91
+ data_vals: list[str] = [str(v) for v in tmp]
92
+ str_data = "".join(data_vals)
93
+ h.update(str_data.encode("utf-8"))
94
+ data["hash"] = h.hexdigest()
95
+ json_str = json.dumps(data, indent=0)
96
+ self.write_text(dst=src, text=json_str)
97
+
98
+
99
+ class InfoJson:
100
+ def __init__(self, rclone: RcloneImpl, src: str | None, src_info: str) -> None:
101
+ self.rclone = rclone
102
+ self.src = src
103
+ self.src_info = src_info
104
+ self.data: dict = {}
105
+
106
+ def load(self) -> bool:
107
+ """Returns true if the file exist and is now loaded."""
108
+ self.data = _get_info_json(self.rclone, self.src, self.src_info)
109
+ return not self.data.get("new", False)
110
+
111
+ def save(self) -> None:
112
+ _save_info_json(self.rclone, self.src_info, self.data)
113
+
114
+ def print(self) -> None:
115
+ self.rclone.print(self.src_info)
116
+
117
+ def fetch_all_finished(self) -> list[str]:
118
+ parent_path = os.path.dirname(self.src_info)
119
+ out = _fetch_all_names(self.rclone, parent_path)
120
+ return out
121
+
122
+ def fetch_all_finished_part_numbers(self) -> list[int]:
123
+ names = self.fetch_all_finished()
124
+ part_numbers = [int(name.split("_")[0].split(".")[1]) for name in names]
125
+ return part_numbers
126
+
127
+ @property
128
+ def parts_dir(self) -> str:
129
+ parts_dir = os.path.dirname(self.src_info)
130
+ if parts_dir.endswith("/"):
131
+ parts_dir = parts_dir[:-1]
132
+ return parts_dir
133
+
134
+ @property
135
+ def dst(self) -> str:
136
+ parts_dir = self.parts_dir
137
+ assert parts_dir.endswith("-parts")
138
+ out = parts_dir[:-6]
139
+ return out
140
+
141
+ @property
142
+ def dst_name(self) -> str:
143
+ return os.path.basename(self.dst)
144
+
145
+ def compute_all_parts(self) -> list[PartInfo] | Exception:
146
+ # full_part_infos: list[PartInfo] | Exception = PartInfo.split_parts(
147
+ # src_size, SizeSuffix("96MB")
148
+ try:
149
+
150
+ src_size = self.size
151
+ chunk_size = self.chunksize
152
+ assert isinstance(src_size, SizeSuffix)
153
+ assert isinstance(chunk_size, SizeSuffix)
154
+ first_part = self.data["first_part"]
155
+ last_part = self.data["last_part"]
156
+ full_part_infos: list[PartInfo] = PartInfo.split_parts(src_size, chunk_size)
157
+ return full_part_infos[first_part : last_part + 1]
158
+ except Exception as e:
159
+ return e
160
+
161
+ def compute_all_part_numbers(self) -> list[int] | Exception:
162
+ all_parts: list[PartInfo] | Exception = self.compute_all_parts()
163
+ if isinstance(all_parts, Exception):
164
+ raise all_parts
165
+
166
+ all_part_nums: list[int] = [p.part_number for p in all_parts]
167
+ return all_part_nums
168
+
169
+ def fetch_remaining_part_numbers(self) -> list[int] | Exception:
170
+ all_part_nums: list[int] | Exception = self.compute_all_part_numbers()
171
+ if isinstance(all_part_nums, Exception):
172
+ return all_part_nums
173
+ finished_part_nums: list[int] = self.fetch_all_finished_part_numbers()
174
+ remaining_part_nums: list[int] = list(
175
+ set(all_part_nums) - set(finished_part_nums)
176
+ )
177
+ return sorted(remaining_part_nums)
178
+
179
+ def fetch_is_done(self) -> bool:
180
+ remaining_part_nums: list[int] | Exception = self.fetch_remaining_part_numbers()
181
+ if isinstance(remaining_part_nums, Exception):
182
+ return False
183
+ return len(remaining_part_nums) == 0
184
+
185
+ @property
186
+ def new(self) -> bool:
187
+ return self.data.get("new", False)
188
+
189
+ @property
190
+ def chunksize(self) -> SizeSuffix | None:
191
+ chunksize_int: int | None = self.data.get("chunksize_int")
192
+ if chunksize_int is None:
193
+ return None
194
+ return SizeSuffix(chunksize_int)
195
+
196
+ @chunksize.setter
197
+ def chunksize(self, value: SizeSuffix) -> None:
198
+ self.data["chunksize"] = str(value)
199
+ self.data["chunksize_int"] = value.as_int()
200
+
201
+ @property
202
+ def src_modtime(self) -> datetime:
203
+ return datetime.fromisoformat(self.data["src_modtime"])
204
+
205
+ @src_modtime.setter
206
+ def src_modtime(self, value: datetime) -> None:
207
+ self.data["src_modtime"] = value.isoformat()
208
+
209
+ @property
210
+ def size(self) -> SizeSuffix:
211
+ return SizeSuffix(self.data["size"])
212
+
213
+ def _get_first_part(self) -> int | None:
214
+ return self.data.get("first_part")
215
+
216
+ def _set_first_part(self, value: int) -> None:
217
+ self.data["first_part"] = value
218
+
219
+ def _get_last_part(self) -> int | None:
220
+ return self.data.get("last_part")
221
+
222
+ def _set_last_part(self, value: int) -> None:
223
+ self.data["last_part"] = value
224
+
225
+ first_part: int | None = property(_get_first_part, _set_first_part) # type: ignore
226
+ last_part: int | None = property(_get_last_part, _set_last_part) # type: ignore
227
+
228
+ @property
229
+ def hash(self) -> str | None:
230
+ return self.data.get("hash")
231
+
232
+ def to_json_str(self) -> str:
233
+ return json.dumps(self.data)
234
+
235
+ def __repr__(self):
236
+ return f"InfoJson({self.src}, {self.src_info}, {self.data})"
237
+
238
+ def __str__(self):
239
+ return self.to_json_str()