rclone-api 1.4.6__tar.gz → 1.4.8__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 (107) hide show
  1. {rclone_api-1.4.6 → rclone_api-1.4.8}/PKG-INFO +1 -1
  2. {rclone_api-1.4.6 → rclone_api-1.4.8}/pyproject.toml +1 -1
  3. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/cmd/copy_large_s3.py +5 -13
  4. rclone_api-1.4.8/src/rclone_api/cmd/copy_large_s3_finish.py +217 -0
  5. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/detail/copy_file_parts.py +109 -17
  6. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/process.py +6 -14
  7. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/rclone_impl.py +61 -44
  8. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/multipart/finished_piece.py +4 -1
  9. rclone_api-1.4.8/src/rclone_api/s3/s3_multipart_uploader_by_copy.py +395 -0
  10. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/types.py +1 -0
  11. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/types.py +13 -0
  12. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/util.py +52 -9
  13. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api.egg-info/PKG-INFO +1 -1
  14. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api.egg-info/SOURCES.txt +2 -1
  15. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_diff.py +2 -2
  16. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_s3.py +1 -0
  17. rclone_api-1.4.6/src/rclone_api/s3/s3_multipart_uploader.py +0 -138
  18. {rclone_api-1.4.6 → rclone_api-1.4.8}/.aiderignore +0 -0
  19. {rclone_api-1.4.6 → rclone_api-1.4.8}/.github/workflows/lint.yml +0 -0
  20. {rclone_api-1.4.6 → rclone_api-1.4.8}/.github/workflows/push_macos.yml +0 -0
  21. {rclone_api-1.4.6 → rclone_api-1.4.8}/.github/workflows/push_ubuntu.yml +0 -0
  22. {rclone_api-1.4.6 → rclone_api-1.4.8}/.github/workflows/push_win.yml +0 -0
  23. {rclone_api-1.4.6 → rclone_api-1.4.8}/.gitignore +0 -0
  24. {rclone_api-1.4.6 → rclone_api-1.4.8}/.pylintrc +0 -0
  25. {rclone_api-1.4.6 → rclone_api-1.4.8}/.vscode/launch.json +0 -0
  26. {rclone_api-1.4.6 → rclone_api-1.4.8}/.vscode/settings.json +0 -0
  27. {rclone_api-1.4.6 → rclone_api-1.4.8}/.vscode/tasks.json +0 -0
  28. {rclone_api-1.4.6 → rclone_api-1.4.8}/LICENSE +0 -0
  29. {rclone_api-1.4.6 → rclone_api-1.4.8}/MANIFEST.in +0 -0
  30. {rclone_api-1.4.6 → rclone_api-1.4.8}/README.md +0 -0
  31. {rclone_api-1.4.6 → rclone_api-1.4.8}/clean +0 -0
  32. {rclone_api-1.4.6 → rclone_api-1.4.8}/install +0 -0
  33. {rclone_api-1.4.6 → rclone_api-1.4.8}/lint +0 -0
  34. {rclone_api-1.4.6 → rclone_api-1.4.8}/requirements.testing.txt +0 -0
  35. {rclone_api-1.4.6 → rclone_api-1.4.8}/setup.cfg +0 -0
  36. {rclone_api-1.4.6 → rclone_api-1.4.8}/setup.py +0 -0
  37. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/__init__.py +0 -0
  38. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/assets/example.txt +0 -0
  39. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/cli.py +0 -0
  40. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/cmd/analyze.py +0 -0
  41. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/cmd/list_files.py +0 -0
  42. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/cmd/save_to_db.py +0 -0
  43. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/completed_process.py +0 -0
  44. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/config.py +0 -0
  45. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/convert.py +0 -0
  46. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/db/__init__.py +0 -0
  47. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/db/db.py +0 -0
  48. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/db/models.py +0 -0
  49. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/deprecated.py +0 -0
  50. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/detail/walk.py +0 -0
  51. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/diff.py +0 -0
  52. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/dir.py +0 -0
  53. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/dir_listing.py +0 -0
  54. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/exec.py +0 -0
  55. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/experimental/flags.py +0 -0
  56. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/experimental/flags_base.py +0 -0
  57. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/file.py +0 -0
  58. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/file_item.py +0 -0
  59. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/file_part.py +0 -0
  60. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/file_stream.py +0 -0
  61. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/filelist.py +0 -0
  62. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/group_files.py +0 -0
  63. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/http_server.py +0 -0
  64. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/log.py +0 -0
  65. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/mount.py +0 -0
  66. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/remote.py +0 -0
  67. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/rpath.py +0 -0
  68. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/api.py +0 -0
  69. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/basic_ops.py +0 -0
  70. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/chunk_task.py +0 -0
  71. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/create.py +0 -0
  72. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/multipart/file_info.py +0 -0
  73. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/multipart/upload_info.py +0 -0
  74. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/multipart/upload_state.py +0 -0
  75. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/s3/upload_file_multipart.py +0 -0
  76. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api/scan_missing_folders.py +0 -0
  77. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api.egg-info/dependency_links.txt +0 -0
  78. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api.egg-info/entry_points.txt +0 -0
  79. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api.egg-info/requires.txt +0 -0
  80. {rclone_api-1.4.6 → rclone_api-1.4.8}/src/rclone_api.egg-info/top_level.txt +0 -0
  81. {rclone_api-1.4.6 → rclone_api-1.4.8}/test +0 -0
  82. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/archive/test_paramiko.py.disabled +0 -0
  83. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_cmd_list_files.py +0 -0
  84. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_copy.py +0 -0
  85. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_copy_bytes.py +0 -0
  86. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_copy_file_resumable_s3.py +0 -0
  87. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_copy_files.py +0 -0
  88. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_db.py +0 -0
  89. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_file_item.py +0 -0
  90. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_group_files.py +0 -0
  91. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_is_synced.py +0 -0
  92. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_ls.py +0 -0
  93. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_ls_stream_files.py +0 -0
  94. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_mount.py +0 -0
  95. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_mount_s3.py +0 -0
  96. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_obscure.py +0 -0
  97. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_rclone_config.py +0 -0
  98. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_read_write_text.py +0 -0
  99. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_remote_control.py +0 -0
  100. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_remotes.py +0 -0
  101. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_scan_missing_folders.py +0 -0
  102. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_serve_http.py +0 -0
  103. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_size_files.py +0 -0
  104. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_size_suffix.py +0 -0
  105. {rclone_api-1.4.6 → rclone_api-1.4.8}/tests/test_walk.py +0 -0
  106. {rclone_api-1.4.6 → rclone_api-1.4.8}/tox.ini +0 -0
  107. {rclone_api-1.4.6 → rclone_api-1.4.8}/upload_package.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: rclone_api
3
- Version: 1.4.6
3
+ Version: 1.4.8
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.6"
28
+ version = "1.4.8"
29
29
 
30
30
  [tool.setuptools]
31
31
  package-dir = {"" = "src"}
@@ -11,8 +11,7 @@ class Args:
11
11
  src: str
12
12
  dst: str
13
13
  chunk_size: SizeSuffix
14
- read_threads: int
15
- write_threads: int
14
+ threads: int
16
15
  retries: int
17
16
  save_state_json: Path
18
17
  verbose: bool
@@ -40,16 +39,10 @@ def _parse_args() -> Args:
40
39
  default="128MB", # if this is too low or too high an s3 service
41
40
  )
42
41
  parser.add_argument(
43
- "--read-threads",
44
- help="Number of concurrent read threads per chunk, only one chunk will be read at a time",
45
- type=int,
46
- default=8,
47
- )
48
- parser.add_argument(
49
- "--write-threads",
42
+ "--threads",
50
43
  help="Max number of chunks to upload in parallel to the destination, each chunk is uploaded in a separate thread",
51
44
  type=int,
52
- default=16,
45
+ default=8,
53
46
  )
54
47
  parser.add_argument("--retries", help="Number of retries", type=int, default=3)
55
48
  parser.add_argument(
@@ -70,9 +63,8 @@ def _parse_args() -> Args:
70
63
  config_path=config,
71
64
  src=args.src,
72
65
  dst=args.dst,
66
+ threads=args.threads,
73
67
  chunk_size=SizeSuffix(args.chunk_size),
74
- read_threads=args.read_threads,
75
- write_threads=args.write_threads,
76
68
  retries=args.retries,
77
69
  save_state_json=args.resume_json,
78
70
  verbose=args.verbose,
@@ -122,6 +114,6 @@ if __name__ == "__main__":
122
114
  "45061:aa_misc_data/aa_misc_data/world_lending_library_2024_11.tar.zst"
123
115
  )
124
116
  sys.argv.append(
125
- "dst:TorrentBooks/aa_misc_data/aa_misc_data/world_lending_library_2024_11.tar.zst"
117
+ "dst:TorrentBooks/aa_misc_data/aa_misc_data/world_lending_library_2024_11.tar.zst-parts"
126
118
  )
127
119
  main()
@@ -0,0 +1,217 @@
1
+ import argparse
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from rclone_api import Rclone
6
+ from rclone_api.detail.copy_file_parts import InfoJson
7
+ from rclone_api.s3.s3_multipart_uploader_by_copy import (
8
+ finish_multipart_upload_from_keys,
9
+ )
10
+ from rclone_api.types import SizeSuffix
11
+
12
+ DATA_SOURCE = (
13
+ "dst:TorrentBooks/aa_misc_data/aa_misc_data/world_lending_library_2024_11.tar.zst"
14
+ )
15
+
16
+
17
+ # response = client.upload_part_copy(
18
+ # Bucket='string',
19
+ # CopySource='string' or {'Bucket': 'string', 'Key': 'string', 'VersionId': 'string'},
20
+ # CopySourceIfMatch='string',
21
+ # CopySourceIfModifiedSince=datetime(2015, 1, 1),
22
+ # CopySourceIfNoneMatch='string',
23
+ # CopySourceIfUnmodifiedSince=datetime(2015, 1, 1),
24
+ # CopySourceRange='string',
25
+ # Key='string',
26
+ # PartNumber=123,
27
+ # UploadId='string',
28
+ # SSECustomerAlgorithm='string',
29
+ # SSECustomerKey='string',
30
+ # CopySourceSSECustomerAlgorithm='string',
31
+ # CopySourceSSECustomerKey='string',
32
+ # RequestPayer='requester',
33
+ # ExpectedBucketOwner='string',
34
+ # ExpectedSourceBucketOwner='string'
35
+ # )
36
+
37
+
38
+ @dataclass
39
+ class Args:
40
+ config_path: Path
41
+ src: str # like dst:TorrentBooks/aa_misc_data/aa_misc_data/world_lending_library_2024_11.tar.zst-parts/ (info.json will be located here)
42
+ dst: str # like dst:TorrentBooks/aa_misc_data/aa_misc_data/world_lending_library_2024_11.tar.zst
43
+ verbose: bool
44
+
45
+
46
+ def list_files(rclone: Rclone, path: str):
47
+ """List files in a remote path."""
48
+ for dirlisting in rclone.walk(path):
49
+ for file in dirlisting.files:
50
+ print(file.path)
51
+
52
+
53
+ def _parse_args() -> Args:
54
+ parser = argparse.ArgumentParser(description="List files in a remote path.")
55
+ parser.add_argument("src", help="File to copy")
56
+ parser.add_argument("dst", help="Destination file")
57
+ parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true")
58
+ parser.add_argument(
59
+ "--config", help="Path to rclone config file", type=Path, required=False
60
+ )
61
+ parser.add_argument(
62
+ "--chunk-size",
63
+ help="Chunk size that will be read and uploaded in SizeSuffix form, too low or too high will cause issues",
64
+ type=str,
65
+ default="128MB", # if this is too low or too high an s3 service
66
+ )
67
+
68
+ args = parser.parse_args()
69
+ config: Path | None = args.config
70
+ if config is None:
71
+ config = Path("rclone.conf")
72
+ if not config.exists():
73
+ raise FileNotFoundError(f"Config file not found: {config}")
74
+ assert config is not None
75
+ out = Args(
76
+ config_path=config,
77
+ src=args.src,
78
+ dst=args.dst,
79
+ verbose=args.verbose,
80
+ )
81
+ return out
82
+
83
+
84
+ # from dataclasses import dataclass
85
+
86
+ # def parse_info_json(text: str) -> UploadInfo:
87
+ # import json
88
+ # data = json.loads(text)
89
+ # chunk_size = data["chunksize_int"]
90
+ # first_part = data["first_part"]
91
+ # last_part = data["last_part"]
92
+ # assert isinstance(chunk_size, int)
93
+ # assert isinstance(first_part, int)
94
+ # assert isinstance(last_part, int)
95
+ # assert first_part <= last_part
96
+ # parts: list[str] = []
97
+ # fmt = "part.{:05d}_{}-{}"
98
+ # for i in range(first_part, last_part + 1):
99
+ # offset: int = i * chunk_size
100
+ # end: int = (i + 1) * chunk_size
101
+ # part = fmt.format(i, offset, end)
102
+ # parts.append(part)
103
+ # return UploadInfo(chunk_size=chunk_size, parts=parts)
104
+
105
+
106
+ def do_finish_part(rclone: Rclone, info: InfoJson, dst: str) -> None:
107
+ from rclone_api.s3.create import BaseClient, S3Credentials, create_s3_client
108
+
109
+ s3_creds: S3Credentials = rclone.impl.get_s3_credentials(remote=dst)
110
+ s3_client: BaseClient = create_s3_client(s3_creds)
111
+ s3_bucket = s3_creds.bucket_name
112
+ is_done = info.fetch_is_done()
113
+ assert is_done, f"Upload is not done: {info}"
114
+
115
+ parts_dir = info.parts_dir
116
+ if parts_dir.endswith("/"):
117
+ parts_dir = parts_dir[:-1]
118
+ source_keys = info.fetch_all_finished()
119
+
120
+ print(parts_dir)
121
+ print(source_keys)
122
+
123
+ parent_path = parts_dir.split(s3_bucket)[1]
124
+ if parent_path.startswith("/"):
125
+ parent_path = parent_path[1:]
126
+
127
+ first_part: int | None = info.first_part
128
+ last_part: int | None = info.last_part
129
+ size: SizeSuffix | None = info.size
130
+
131
+ assert first_part is not None
132
+ assert last_part is not None
133
+ assert size is not None
134
+
135
+ def _to_s3_key(name: str) -> str:
136
+ out = f"{parent_path}/{name}"
137
+ return out
138
+
139
+ # s3_keys: list[str] = [_to_s3_key(name=p) for p in source_keys]
140
+ parts: list[tuple[int, str]] = []
141
+ for i in range(first_part, last_part + 1):
142
+ part_name = f"part.{i:05d}"
143
+ s3_key = _to_s3_key(name=part_name)
144
+ parts.append((i, s3_key))
145
+
146
+ # for key in parts:
147
+ # print(key)
148
+
149
+ chunksize = info.chunksize
150
+ assert chunksize is not None
151
+
152
+ finish_multipart_upload_from_keys(
153
+ s3_client=s3_client,
154
+ source_bucket=s3_creds.bucket_name,
155
+ parts=parts,
156
+ destination_bucket=s3_creds.bucket_name,
157
+ destination_key=dst,
158
+ chunk_size=chunksize.as_int(),
159
+ final_size=size.as_int(),
160
+ retries=3,
161
+ )
162
+
163
+ if False:
164
+ print(finish_multipart_upload_from_keys)
165
+ print(s3_client)
166
+ print("done")
167
+
168
+ # def finish_multipart_upload_from_keys(
169
+ # s3_client: BaseClient,
170
+ # source_bucket: str,
171
+ # source_keys: list[str],
172
+ # destination_bucket: str,
173
+ # destination_key: str,
174
+ # chunk_size: int = 5 * 1024 * 1024, # 5MB default
175
+ # retries: int = 3,
176
+ # byte_ranges: list[str] | None = None,
177
+
178
+ # if False:
179
+ # finish_multipart_upload_from_keys(
180
+ # s3_client=s3_client,
181
+ # source_bucket="TODO",
182
+ # source_keys=[p.key for p in all_parts],
183
+ # destination_bucket=info.dst_bucket,
184
+ # destination_key=info.dst_key,
185
+ # chunk_size=5 * 1024 * 1024,
186
+ # retries=3,
187
+ # byte_ranges=None,
188
+ # )
189
+
190
+ # print(all_parts)
191
+
192
+
193
+ def main() -> int:
194
+ """Main entry point."""
195
+ args = _parse_args()
196
+ rclone = Rclone(rclone_conf=args.config_path)
197
+ info_json = f"{args.src}/info.json".replace("//", "/")
198
+ info = InfoJson(rclone.impl, src=None, src_info=info_json)
199
+ loaded = info.load()
200
+ assert loaded
201
+ print(info)
202
+ do_finish_part(rclone=rclone, info=info, dst=args.dst)
203
+ return 0
204
+
205
+
206
+ if __name__ == "__main__":
207
+ import sys
208
+
209
+ sys.argv.append("--config")
210
+ sys.argv.append("rclone.conf")
211
+ sys.argv.append(
212
+ "dst:TorrentBooks/aa_misc_data/aa_misc_data/world_lending_library_2024_11.tar.zst-parts/"
213
+ )
214
+ sys.argv.append(
215
+ "dst:TorrentBooks/aa_misc_data/aa_misc_data/world_lending_library_2024_11.tar.zst"
216
+ )
217
+ main()
@@ -53,7 +53,7 @@ def upload_task(self: RcloneImpl, upload_part: UploadPart) -> UploadPart:
53
53
  msg = "\n#########################################\n"
54
54
  msg += f"# Uploading {upload_part.chunk} to {upload_part.dst_part}\n"
55
55
  msg += "#########################################\n"
56
- print
56
+ print(msg)
57
57
  self.copy_to(upload_part.chunk.as_posix(), upload_part.dst_part)
58
58
  return upload_part
59
59
  except Exception as e:
@@ -106,11 +106,24 @@ def _fetch_all_names(
106
106
  return filtered
107
107
 
108
108
 
109
- def _get_info_json(self: RcloneImpl, src: str, src_info: str) -> dict:
109
+ def _get_info_json(self: RcloneImpl, src: str | None, src_info: str) -> dict:
110
110
  from rclone_api.file import File
111
111
 
112
+ data: dict
113
+ text: str
114
+ if src is None:
115
+ # just try to load the file
116
+ text_or_err = self.read_text(src_info)
117
+ if isinstance(text_or_err, Exception):
118
+ raise FileNotFoundError(f"Could not load {src_info}: {text_or_err}")
119
+ assert isinstance(text_or_err, str)
120
+ text = text_or_err
121
+ data = json.loads(text)
122
+ return data
123
+
112
124
  src_stat: File | Exception = self.stat(src)
113
125
  if isinstance(src_stat, Exception):
126
+ # just try to load the file
114
127
  raise FileNotFoundError(f"Failed to stat {src}: {src_stat}")
115
128
 
116
129
  now: datetime = datetime.now()
@@ -133,12 +146,11 @@ def _get_info_json(self: RcloneImpl, src: str, src_info: str) -> dict:
133
146
  warnings.warn(f"Failed to read {src_info}: {text_or_err}")
134
147
  return new_data
135
148
  assert isinstance(text_or_err, str)
136
- text: str = text_or_err
149
+ text = text_or_err
137
150
 
138
151
  if err is not None:
139
152
  return new_data
140
153
 
141
- data: dict = {}
142
154
  try:
143
155
  data = json.loads(text)
144
156
  return data
@@ -168,13 +180,14 @@ def _save_info_json(self: RcloneImpl, src: str, data: dict) -> None:
168
180
 
169
181
 
170
182
  class InfoJson:
171
- def __init__(self, rclone: RcloneImpl, src: str, src_info: str) -> None:
183
+ def __init__(self, rclone: RcloneImpl, src: str | None, src_info: str) -> None:
172
184
  self.rclone = rclone
173
185
  self.src = src
174
186
  self.src_info = src_info
175
187
  self.data: dict = {}
176
188
 
177
189
  def load(self) -> bool:
190
+ """Returns true if the file exist and is now loaded."""
178
191
  self.data = _get_info_json(self.rclone, self.src, self.src_info)
179
192
  return not self.data.get("new", False)
180
193
 
@@ -194,16 +207,60 @@ class InfoJson:
194
207
  part_numbers = [int(name.split("_")[0].split(".")[1]) for name in names]
195
208
  return part_numbers
196
209
 
210
+ @property
211
+ def parts_dir(self) -> str:
212
+ return os.path.dirname(self.src_info)
213
+
214
+ def compute_all_parts(self) -> list[PartInfo] | Exception:
215
+ # full_part_infos: list[PartInfo] | Exception = PartInfo.split_parts(
216
+ # src_size, SizeSuffix("96MB")
217
+ try:
218
+
219
+ src_size = self.size
220
+ chunk_size = self.chunksize
221
+ assert isinstance(src_size, SizeSuffix)
222
+ assert isinstance(chunk_size, SizeSuffix)
223
+ first_part = self.data["first_part"]
224
+ last_part = self.data["last_part"]
225
+ full_part_infos: list[PartInfo] = PartInfo.split_parts(src_size, chunk_size)
226
+ return full_part_infos[first_part : last_part + 1]
227
+ except Exception as e:
228
+ return e
229
+
230
+ def compute_all_part_numbers(self) -> list[int] | Exception:
231
+ all_parts: list[PartInfo] | Exception = self.compute_all_parts()
232
+ if isinstance(all_parts, Exception):
233
+ raise all_parts
234
+
235
+ all_part_nums: list[int] = [p.part_number for p in all_parts]
236
+ return all_part_nums
237
+
238
+ def fetch_remaining_part_numbers(self) -> list[int] | Exception:
239
+ all_part_nums: list[int] | Exception = self.compute_all_part_numbers()
240
+ if isinstance(all_part_nums, Exception):
241
+ return all_part_nums
242
+ finished_part_nums: list[int] = self.fetch_all_finished_part_numbers()
243
+ remaining_part_nums: list[int] = list(
244
+ set(all_part_nums) - set(finished_part_nums)
245
+ )
246
+ return sorted(remaining_part_nums)
247
+
248
+ def fetch_is_done(self) -> bool:
249
+ remaining_part_nums: list[int] | Exception = self.fetch_remaining_part_numbers()
250
+ if isinstance(remaining_part_nums, Exception):
251
+ return False
252
+ return len(remaining_part_nums) == 0
253
+
197
254
  @property
198
255
  def new(self) -> bool:
199
256
  return self.data.get("new", False)
200
257
 
201
258
  @property
202
259
  def chunksize(self) -> SizeSuffix | None:
203
- chunksize: str | None = self.data.get("chunksize")
204
- if chunksize is None:
260
+ chunksize_int: int | None = self.data.get("chunksize_int")
261
+ if chunksize_int is None:
205
262
  return None
206
- return SizeSuffix(chunksize)
263
+ return SizeSuffix(chunksize_int)
207
264
 
208
265
  @chunksize.setter
209
266
  def chunksize(self, value: SizeSuffix) -> None:
@@ -219,21 +276,24 @@ class InfoJson:
219
276
  self.data["src_modtime"] = value.isoformat()
220
277
 
221
278
  @property
222
- def first_part(self) -> int | None:
279
+ def size(self) -> SizeSuffix:
280
+ return SizeSuffix(self.data["size"])
281
+
282
+ def _get_first_part(self) -> int | None:
223
283
  return self.data.get("first_part")
224
284
 
225
- @first_part.setter
226
- def first_part(self, value: int) -> None:
285
+ def _set_first_part(self, value: int) -> None:
227
286
  self.data["first_part"] = value
228
287
 
229
- @property
230
- def last_part(self) -> int | None:
288
+ def _get_last_part(self) -> int | None:
231
289
  return self.data.get("last_part")
232
290
 
233
- @last_part.setter
234
- def last_part(self, value: int) -> None:
291
+ def _set_last_part(self, value: int) -> None:
235
292
  self.data["last_part"] = value
236
293
 
294
+ first_part: int | None = property(_get_first_part, _set_first_part) # type: ignore
295
+ last_part: int | None = property(_get_last_part, _set_last_part) # type: ignore
296
+
237
297
  @property
238
298
  def hash(self) -> str | None:
239
299
  return self.data.get("hash")
@@ -248,6 +308,36 @@ class InfoJson:
248
308
  return self.to_json_str()
249
309
 
250
310
 
311
+ def collapse_runs(numbers: list[int]) -> list[str]:
312
+ if not numbers:
313
+ return []
314
+
315
+ runs = []
316
+ start = numbers[0]
317
+ prev = numbers[0]
318
+
319
+ for num in numbers[1:]:
320
+ if num == prev + 1:
321
+ # Continue current run
322
+ prev = num
323
+ else:
324
+ # End current run
325
+ if start == prev:
326
+ runs.append(str(start))
327
+ else:
328
+ runs.append(f"{start}-{prev}")
329
+ start = num
330
+ prev = num
331
+
332
+ # Append the final run
333
+ if start == prev:
334
+ runs.append(str(start))
335
+ else:
336
+ runs.append(f"{start}-{prev}")
337
+
338
+ return runs
339
+
340
+
251
341
  def copy_file_parts(
252
342
  self: RcloneImpl,
253
343
  src: str, # src:/Bucket/path/myfile.large.zst
@@ -297,7 +387,9 @@ def copy_file_parts(
297
387
  first_part_number = part_infos[0].part_number
298
388
  last_part_number = part_infos[-1].part_number
299
389
 
300
- print(f"all_numbers_already_done: {sorted(list(all_numbers_already_done))}")
390
+ print(
391
+ f"all_numbers_already_done: {collapse_runs(sorted(list(all_numbers_already_done)))}"
392
+ )
301
393
 
302
394
  filtered_part_infos: list[PartInfo] = []
303
395
  for part_info in part_infos:
@@ -306,7 +398,7 @@ def copy_file_parts(
306
398
  part_infos = filtered_part_infos
307
399
 
308
400
  remaining_part_numbers: list[int] = [p.part_number for p in part_infos]
309
- print(f"remaining_part_numbers: {remaining_part_numbers}")
401
+ print(f"remaining_part_numbers: {collapse_runs(remaining_part_numbers)}")
310
402
 
311
403
  if len(part_infos) == 0:
312
404
  return Exception(f"No parts to copy for {src}")
@@ -5,11 +5,10 @@ import time
5
5
  import weakref
6
6
  from dataclasses import dataclass
7
7
  from pathlib import Path
8
- from tempfile import TemporaryDirectory
9
8
  from typing import Any
10
9
 
11
10
  from rclone_api.config import Config
12
- from rclone_api.util import get_verbose
11
+ from rclone_api.util import clear_temp_config_file, get_verbose, make_temp_config_file
13
12
 
14
13
 
15
14
  @dataclass
@@ -28,17 +27,14 @@ class Process:
28
27
  assert args.rclone_exe.exists()
29
28
  self.args = args
30
29
  self.log = args.log
31
- self.tempdir: TemporaryDirectory | None = None
30
+ self.tempfile: Path | None = None
32
31
  verbose = get_verbose(args.verbose)
33
32
  if isinstance(args.rclone_conf, Config):
34
- self.tempdir = TemporaryDirectory()
35
- tmpfile = Path(self.tempdir.name) / "rclone.conf"
36
- tmpfile.write_text(args.rclone_conf.text, encoding="utf-8")
37
- rclone_conf = tmpfile
38
- self.needs_cleanup = True
33
+ self.tmpfile = make_temp_config_file()
34
+ self.tmpfile.write_text(args.rclone_conf.text, encoding="utf-8")
35
+ rclone_conf = self.tmpfile
39
36
  else:
40
37
  rclone_conf = args.rclone_conf
41
- self.needs_cleanup = False
42
38
 
43
39
  assert rclone_conf.exists()
44
40
 
@@ -81,11 +77,7 @@ class Process:
81
77
  self.cleanup()
82
78
 
83
79
  def cleanup(self) -> None:
84
- if self.tempdir and self.needs_cleanup:
85
- try:
86
- self.tempdir.cleanup()
87
- except Exception as e:
88
- print(f"Error cleaning up tempdir: {e}")
80
+ clear_temp_config_file(self.tempfile)
89
81
 
90
82
  def _atexit_terminate(self) -> None:
91
83
  """
@@ -32,6 +32,7 @@ from rclone_api.mount import Mount, clean_mount, prepare_mount
32
32
  from rclone_api.process import Process
33
33
  from rclone_api.remote import Remote
34
34
  from rclone_api.rpath import RPath
35
+ from rclone_api.s3.create import S3Credentials
35
36
  from rclone_api.s3.types import (
36
37
  MultiUploadResult,
37
38
  S3MutliPartUploadConfig,
@@ -862,6 +863,64 @@ class RcloneImpl:
862
863
  )
863
864
  return SizeSuffix(out.total_size)
864
865
 
866
+ def get_s3_credentials(
867
+ self, remote: str, verbose: bool | None = None
868
+ ) -> S3Credentials:
869
+ from rclone_api.util import S3PathInfo, split_s3_path
870
+
871
+ verbose = get_verbose(verbose)
872
+ path_info: S3PathInfo = split_s3_path(remote)
873
+
874
+ # path_info: S3PathInfo = split_s3_path(remote)
875
+ remote = path_info.remote
876
+ bucket_name = path_info.bucket
877
+
878
+ remote = path_info.remote
879
+ parsed: Parsed = self.config.parse()
880
+ sections: dict[str, Section] = parsed.sections
881
+ if remote not in sections:
882
+ raise ValueError(
883
+ f"Remote {remote} not found in rclone config, remotes are: {sections.keys()}"
884
+ )
885
+
886
+ section: Section = sections[remote]
887
+ dst_type = section.type()
888
+ if dst_type != "s3" and dst_type != "b2":
889
+ raise ValueError(
890
+ f"Remote {remote} is not an S3 remote, it is of type {dst_type}"
891
+ )
892
+
893
+ def get_provider_str(section=section) -> str | None:
894
+ type: str = section.type()
895
+ provider: str | None = section.provider()
896
+ if provider is not None:
897
+ return provider
898
+ if type == "b2":
899
+ return S3Provider.BACKBLAZE.value
900
+ if type != "s3":
901
+ raise ValueError(f"Remote {remote} is not an S3 remote")
902
+ return S3Provider.S3.value
903
+
904
+ provider: str
905
+ if provided_provider_str := get_provider_str():
906
+ if verbose:
907
+ print(f"Using provided provider: {provided_provider_str}")
908
+ provider = provided_provider_str
909
+ else:
910
+ if verbose:
911
+ print(f"Using default provider: {S3Provider.S3.value}")
912
+ provider = S3Provider.S3.value
913
+ provider_enum = S3Provider.from_str(provider)
914
+
915
+ s3_creds: S3Credentials = S3Credentials(
916
+ bucket_name=bucket_name,
917
+ provider=provider_enum,
918
+ access_key_id=section.access_key_id(),
919
+ secret_access_key=section.secret_access_key(),
920
+ endpoint_url=section.endpoint(),
921
+ )
922
+ return s3_creds
923
+
865
924
  def copy_file_resumable_s3(
866
925
  self,
867
926
  src: str,
@@ -878,7 +937,6 @@ class RcloneImpl:
878
937
  """For massive files that rclone can't handle in one go, this function will copy the file in chunks to an S3 store"""
879
938
  from rclone_api.http_server import HttpFetcher, HttpServer
880
939
  from rclone_api.s3.api import S3Client
881
- from rclone_api.s3.create import S3Credentials
882
940
  from rclone_api.util import S3PathInfo, split_s3_path
883
941
 
884
942
  src_path = Path(src)
@@ -909,51 +967,10 @@ class RcloneImpl:
909
967
  )
910
968
 
911
969
  path_info: S3PathInfo = split_s3_path(dst)
912
- remote = path_info.remote
970
+ # remote = path_info.remote
913
971
  bucket_name = path_info.bucket
914
972
  s3_key = path_info.key
915
- parsed: Parsed = self.config.parse()
916
- sections: dict[str, Section] = parsed.sections
917
- if remote not in sections:
918
- raise ValueError(
919
- f"Remote {remote} not found in rclone config, remotes are: {sections.keys()}"
920
- )
921
-
922
- section: Section = sections[remote]
923
- dst_type = section.type()
924
- if dst_type != "s3" and dst_type != "b2":
925
- raise ValueError(
926
- f"Remote {remote} is not an S3 remote, it is of type {dst_type}"
927
- )
928
-
929
- def get_provider_str(section=section) -> str | None:
930
- type: str = section.type()
931
- provider: str | None = section.provider()
932
- if provider is not None:
933
- return provider
934
- if type == "b2":
935
- return S3Provider.BACKBLAZE.value
936
- if type != "s3":
937
- raise ValueError(f"Remote {remote} is not an S3 remote")
938
- return S3Provider.S3.value
939
-
940
- provider: str
941
- if provided_provider_str := get_provider_str():
942
- if verbose:
943
- print(f"Using provided provider: {provided_provider_str}")
944
- provider = provided_provider_str
945
- else:
946
- if verbose:
947
- print(f"Using default provider: {S3Provider.S3.value}")
948
- provider = S3Provider.S3.value
949
- provider_enum = S3Provider.from_str(provider)
950
-
951
- s3_creds: S3Credentials = S3Credentials(
952
- provider=provider_enum,
953
- access_key_id=section.access_key_id(),
954
- secret_access_key=section.secret_access_key(),
955
- endpoint_url=section.endpoint(),
956
- )
973
+ s3_creds: S3Credentials = self.get_s3_credentials(dst, verbose=verbose)
957
974
 
958
975
  port = random.randint(10000, 20000)
959
976
  http_server: HttpServer = self.serve_http(
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import warnings
2
3
  from dataclasses import dataclass
3
4
 
4
5
  from rclone_api.types import EndOfStream
@@ -28,7 +29,9 @@ class FinishedPiece:
28
29
  for p in parts:
29
30
  if p is EndOfStream:
30
31
  count_eos += 1
31
- assert count_eos <= 1, "Only one EndOfStream should be present"
32
+ # assert count_eos <= 1, "Only one EndOfStream should be present"
33
+ if count_eos > 1:
34
+ warnings.warn(f"Only one EndOfStream should be present, found {count_eos}")
32
35
  return [p.to_json() for p in non_none]
33
36
 
34
37
  @staticmethod