divbase-cli 0.1.0a1__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.
@@ -0,0 +1,444 @@
1
+ """
2
+ Service layer for DivBase CLI S3 file operations.
3
+ """
4
+
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+ import typer
11
+ from rich import print
12
+
13
+ from divbase_cli.cli_exceptions import (
14
+ FileDoesNotExistInSpecifiedVersionError,
15
+ FilesAlreadyInProjectError,
16
+ )
17
+ from divbase_cli.services.pre_signed_urls import (
18
+ DownloadOutcome,
19
+ FailedUpload,
20
+ SuccessfulUpload,
21
+ UploadOutcome,
22
+ download_multiple_pre_signed_urls,
23
+ perform_multipart_upload,
24
+ upload_multiple_singlepart_pre_signed_urls,
25
+ )
26
+ from divbase_cli.services.project_versions import get_version_details_command
27
+ from divbase_cli.user_auth import make_authenticated_request
28
+ from divbase_lib.api_schemas.s3 import (
29
+ FileChecksumResponse,
30
+ ListObjectsRequest,
31
+ ListObjectsResponse,
32
+ ObjectDetails,
33
+ ObjectInfoResponse,
34
+ PreSignedDownloadResponse,
35
+ PreSignedSinglePartUploadResponse,
36
+ RestoreObjectsResponse,
37
+ SoftDeletedObjectDetails,
38
+ )
39
+ from divbase_lib.divbase_constants import (
40
+ MAX_S3_API_BATCH_SIZE,
41
+ QUERY_RESULTS_FILE_PREFIX,
42
+ S3_MULTIPART_UPLOAD_THRESHOLD,
43
+ )
44
+ from divbase_lib.s3_checksums import (
45
+ MD5CheckSumFormat,
46
+ calculate_composite_md5_s3_etag,
47
+ calculate_md5_checksum,
48
+ convert_checksum_hex_to_base64,
49
+ )
50
+
51
+
52
+ @dataclass
53
+ class ToDownload:
54
+ """
55
+ Represent a file to be download in the download-all command. This unifies the 2 ways we get the files needed:
56
+ 1. From the latest files in the bucket (listing files)
57
+ 2. From a user defined project version (getting the files from the version details)"
58
+ """
59
+
60
+ name: str
61
+ etag: str
62
+ size_bytes: int
63
+ version_id: str | None = None # latest version if None
64
+
65
+
66
+ def list_files_command(
67
+ divbase_base_url: str,
68
+ project_name: str,
69
+ prefix_filter: str | None = None,
70
+ include_results_files: bool = False,
71
+ ) -> list[ObjectDetails]:
72
+ """
73
+ List all files in a project optionally filtered by a prefix.
74
+ We page through results if there are more than can be returned in a single API call.
75
+
76
+ NOTE: The current implementation is not very efficient as we page through all results before returning any.
77
+ Keeping simple for now as we don't expect projects to have huge numbers of files.
78
+ But could be revisted later if performance becomes an issue.
79
+ """
80
+ api_route = f"v1/s3/list?project_name={project_name}"
81
+ initial_request = ListObjectsRequest(
82
+ prefix=prefix_filter,
83
+ next_token=None,
84
+ )
85
+
86
+ response = make_authenticated_request(
87
+ method="POST",
88
+ divbase_base_url=divbase_base_url,
89
+ api_route=api_route,
90
+ json=initial_request.model_dump(),
91
+ )
92
+ response_data = ListObjectsResponse(**response.json())
93
+ all_matches = response_data.objects
94
+
95
+ # page through any remaining results
96
+ while response_data.next_token:
97
+ next_request = ListObjectsRequest(prefix=prefix_filter, next_token=response_data.next_token)
98
+ response = make_authenticated_request(
99
+ method="POST",
100
+ divbase_base_url=divbase_base_url,
101
+ api_route=api_route,
102
+ json=next_request.model_dump(),
103
+ )
104
+ next_page_data = ListObjectsResponse(**response.json())
105
+
106
+ all_matches.extend(next_page_data.objects)
107
+ response_data.next_token = next_page_data.next_token
108
+
109
+ # To enable us to both search by prefix and optionally hide/include query results files,
110
+ # we hide DivBase query results/job files now instead of via S3.
111
+ # so the prefix param can be used for the optional filter the user wants.
112
+ if not include_results_files:
113
+ all_matches = [obj for obj in all_matches if not obj.name.startswith(QUERY_RESULTS_FILE_PREFIX)]
114
+
115
+ return all_matches
116
+
117
+
118
+ def list_soft_deleted_files_command(divbase_base_url: str, project_name: str) -> list[SoftDeletedObjectDetails]:
119
+ """List all soft-deleted files in a project."""
120
+ api_route = f"v1/s3/list/soft-deleted?project_name={project_name}"
121
+ response = make_authenticated_request(
122
+ method="GET",
123
+ divbase_base_url=divbase_base_url,
124
+ api_route=api_route,
125
+ )
126
+ return [SoftDeletedObjectDetails(**obj) for obj in response.json()]
127
+
128
+
129
+ def get_file_info_command(divbase_base_url: str, project_name: str, object_name: str) -> ObjectInfoResponse:
130
+ """Get detailed information about a specific file/object in a project."""
131
+ response = make_authenticated_request(
132
+ method="GET",
133
+ divbase_base_url=divbase_base_url,
134
+ api_route=f"v1/s3/info?project_name={project_name}&object_name={object_name}",
135
+ )
136
+ return ObjectInfoResponse(**response.json())
137
+
138
+
139
+ def soft_delete_objects_command(divbase_base_url: str, project_name: str, all_files: list[str]) -> list[str]:
140
+ """
141
+ Soft delete objects from the project's bucket.
142
+ Returns a list of the soft deleted objects
143
+ """
144
+ deleted_objects = []
145
+ for i in range(0, len(all_files), MAX_S3_API_BATCH_SIZE):
146
+ batch_files = all_files[i : i + MAX_S3_API_BATCH_SIZE]
147
+ response = make_authenticated_request(
148
+ method="DELETE",
149
+ divbase_base_url=divbase_base_url,
150
+ api_route=f"v1/s3/?project_name={project_name}",
151
+ json=batch_files,
152
+ )
153
+ deleted_objects.extend(response.json())
154
+ return deleted_objects
155
+
156
+
157
+ def restore_objects_command(divbase_base_url: str, project_name: str, all_files: list[str]) -> RestoreObjectsResponse:
158
+ """
159
+ Restore soft_deleted objects in the project's bucket.
160
+ Returns an object containing a list of the restored objects, and those that were not restored.
161
+ """
162
+ all_restored = []
163
+ all_not_restored = []
164
+ for i in range(0, len(all_files), MAX_S3_API_BATCH_SIZE):
165
+ batch_files = all_files[i : i + MAX_S3_API_BATCH_SIZE]
166
+ response = make_authenticated_request(
167
+ method="POST",
168
+ divbase_base_url=divbase_base_url,
169
+ api_route=f"v1/s3/restore?project_name={project_name}",
170
+ json=batch_files,
171
+ )
172
+ batch_response = RestoreObjectsResponse(**response.json())
173
+ all_restored.extend(batch_response.restored)
174
+ all_not_restored.extend(batch_response.not_restored)
175
+
176
+ return RestoreObjectsResponse(restored=all_restored, not_restored=all_not_restored)
177
+
178
+
179
+ def download_files_command(
180
+ divbase_base_url: str,
181
+ project_name: str,
182
+ raw_files_input: list[str],
183
+ download_dir: Path,
184
+ verify_checksums: bool,
185
+ dry_run: bool,
186
+ project_version: str | None = None,
187
+ ) -> DownloadOutcome:
188
+ """
189
+ Download files from the given project's S3 bucket.
190
+ """
191
+ if not download_dir.is_dir():
192
+ raise NotADirectoryError(
193
+ f"The specified download directory '{download_dir}' is not a directory. Please create it or specify a valid directory before continuing."
194
+ )
195
+
196
+ if project_version is not None:
197
+ offending_files = [file for file in raw_files_input if ":" in file]
198
+ if offending_files:
199
+ print(
200
+ "[red] ERROR: bad Input: If you provide a global project version (using --project-version) "
201
+ "you cannot also specify specific versions of individual files to download. \n"
202
+ "offending files in your input: \n"
203
+ f"{'\n'.join(offending_files)} \n"
204
+ "Exiting..."
205
+ )
206
+ raise typer.Exit(1)
207
+
208
+ if project_version:
209
+ project_version_details = get_version_details_command(
210
+ project_name=project_name, divbase_base_url=divbase_base_url, version_name=project_version
211
+ )
212
+
213
+ # check if all files specified exist for download exist at this project version
214
+ missing_objects = [f for f in raw_files_input if f not in project_version_details.files]
215
+ if missing_objects:
216
+ raise FileDoesNotExistInSpecifiedVersionError(
217
+ project_name=project_name,
218
+ project_version=project_version,
219
+ missing_files=missing_objects,
220
+ )
221
+
222
+ json_data = []
223
+ for file_name, file_details in project_version_details.files.items():
224
+ if file_name in raw_files_input:
225
+ json_data.append({"name": file_name, "version_id": file_details["version_id"]})
226
+ else:
227
+ # parse raw file inputs to see if any specific version ids were provided using format:
228
+ # file_name:version_id (not possible when using project_version)
229
+ json_data = []
230
+ for file_input in raw_files_input:
231
+ if ":" in file_input:
232
+ name, version_id = file_input.split(sep=":", maxsplit=1)
233
+ json_data.append({"name": name, "version_id": version_id})
234
+ else:
235
+ json_data.append({"name": file_input, "version_id": None})
236
+
237
+ if dry_run:
238
+ print("\n[green bold]Dry run enabled, The following files would have been downloaded:[/green bold]")
239
+ for file_info in json_data:
240
+ version_id = file_info["version_id"]
241
+ if version_id:
242
+ print(f"- '{file_info['name']}' (version_id: '{version_id}')")
243
+ else:
244
+ print(f"- '{file_info['name']}' (latest version)")
245
+ raise typer.Exit(0)
246
+
247
+ successful_downloads, failed_downloads = [], []
248
+ for i in range(0, len(json_data), MAX_S3_API_BATCH_SIZE):
249
+ batch_json_data = json_data[i : i + MAX_S3_API_BATCH_SIZE]
250
+ response = make_authenticated_request(
251
+ method="POST",
252
+ divbase_base_url=divbase_base_url,
253
+ api_route=f"v1/s3/download?project_name={project_name}",
254
+ json=batch_json_data,
255
+ )
256
+ pre_signed_urls = [PreSignedDownloadResponse(**item) for item in response.json()]
257
+
258
+ batch_download_success, batch_download_failed = download_multiple_pre_signed_urls(
259
+ pre_signed_urls=pre_signed_urls,
260
+ download_dir=download_dir,
261
+ verify_checksums=verify_checksums,
262
+ )
263
+ successful_downloads.extend(batch_download_success)
264
+ failed_downloads.extend(batch_download_failed)
265
+
266
+ return DownloadOutcome(successful=successful_downloads, failed=failed_downloads)
267
+
268
+
269
+ def stream_file_command(
270
+ divbase_base_url: str, project_name: str, file_name: str, version_id: str | None = None
271
+ ) -> None:
272
+ """Stream the contents of a single file in the project's S3 bucket to stdout."""
273
+ json_data = [{"name": file_name, "version_id": version_id}]
274
+ response = make_authenticated_request(
275
+ method="POST",
276
+ divbase_base_url=divbase_base_url,
277
+ api_route=f"v1/s3/download?project_name={project_name}",
278
+ json=json_data,
279
+ )
280
+ pre_signed_url = PreSignedDownloadResponse(**response.json()[0]).pre_signed_url
281
+
282
+ try:
283
+ with httpx.stream("GET", pre_signed_url, timeout=None) as response:
284
+ response.raise_for_status()
285
+ for chunk in response.iter_bytes():
286
+ sys.stdout.buffer.write(chunk)
287
+ except BrokenPipeError:
288
+ # This happens when the user pipes to a command that closes early
289
+ # (e.g., `[divbase-cli stream command] | head -n 10`).
290
+ pass
291
+
292
+
293
+ def upload_files_command(
294
+ project_name: str, divbase_base_url: str, all_files: list[Path], safe_mode: bool
295
+ ) -> UploadOutcome:
296
+ """
297
+ Upload files to the project's S3 bucket.
298
+ Returns an UploadOutcome object containing details of which files were successfully uploaded and which failed.
299
+
300
+ - Safe mode:
301
+ 1. checks if any of the files that are to be uploaded already exist in the bucket (by comparing checksums)
302
+ 2. Adds checksum to upload request to allow server to verify upload.
303
+ """
304
+ if safe_mode:
305
+ print("[green]Preparing to upload files, first calculating the checksums of all files to upload...[/green]")
306
+ # mapping of file name to hex-encoded checksum
307
+ file_checksums_hex = compare_local_to_s3_checksums(
308
+ project_name=project_name,
309
+ divbase_base_url=divbase_base_url,
310
+ all_files=all_files,
311
+ )
312
+
313
+ files_below_threshold, files_above_threshold = [], []
314
+ for file in all_files:
315
+ if file.stat().st_size <= S3_MULTIPART_UPLOAD_THRESHOLD:
316
+ files_below_threshold.append(file)
317
+ else:
318
+ files_above_threshold.append(file)
319
+
320
+ all_successful_uploads: list[SuccessfulUpload] = []
321
+ all_failed_uploads: list[FailedUpload] = []
322
+
323
+ # P1. Process all single-part uploads in batches of max size allowed by divbase server.
324
+ for i in range(0, len(files_below_threshold), MAX_S3_API_BATCH_SIZE):
325
+ batch_files = files_below_threshold[i : i + MAX_S3_API_BATCH_SIZE]
326
+ batch_of_objects_to_upload = []
327
+ for file in batch_files:
328
+ upload_object = {
329
+ "name": file.name,
330
+ "content_length": file.stat().st_size,
331
+ }
332
+ if safe_mode:
333
+ hex_checksum = file_checksums_hex[file.name]
334
+ upload_object["md5_hash"] = convert_checksum_hex_to_base64(hex_checksum)
335
+ batch_of_objects_to_upload.append(upload_object)
336
+
337
+ response = make_authenticated_request(
338
+ method="POST",
339
+ divbase_base_url=divbase_base_url,
340
+ api_route=f"v1/s3/upload/single-part?project_name={project_name}",
341
+ json=batch_of_objects_to_upload,
342
+ )
343
+ pre_signed_urls = [PreSignedSinglePartUploadResponse(**item) for item in response.json()]
344
+ single_part_upload_outcome = upload_multiple_singlepart_pre_signed_urls(
345
+ pre_signed_urls=pre_signed_urls, all_files=batch_files
346
+ )
347
+ all_successful_uploads.extend(single_part_upload_outcome.successful)
348
+ all_failed_uploads.extend(single_part_upload_outcome.failed)
349
+
350
+ # P2. process all multipart uploads.
351
+ for file_path in files_above_threshold:
352
+ outcome = perform_multipart_upload(
353
+ project_name=project_name,
354
+ divbase_base_url=divbase_base_url,
355
+ file_path=file_path,
356
+ safe_mode=safe_mode,
357
+ )
358
+
359
+ if isinstance(outcome, SuccessfulUpload):
360
+ all_successful_uploads.append(outcome)
361
+ else:
362
+ all_failed_uploads.append(outcome)
363
+
364
+ return UploadOutcome(successful=all_successful_uploads, failed=all_failed_uploads)
365
+
366
+
367
+ def compare_local_to_s3_checksums(project_name: str, divbase_base_url: str, all_files: list[Path]) -> dict[str, str]:
368
+ """
369
+ Calculate the checksums of all local files (to be uploaded) and compares them to the checksums of all files in the project's S3 bucket.
370
+ Raises error if any files already exist in the project's S3 bucket with identical checksums.
371
+
372
+ Here, we are catching an attempt to upload an identical file twice.
373
+ This is only ran if 'safe_mode' is enabled for uploads.
374
+ We do not catch an attempt to upload an identical object if it has a different name.
375
+
376
+ Return a dict of file names with hex-encoded checksums for all files to be uploaded (including those that are not in S3).
377
+ These checksums are later used when uploading to the server so the server can verify the upload.
378
+ """
379
+ already_uploaded_files: dict[Path, str] = {} # files that already exist in S3 with identical checksum
380
+ local_checksums: dict[str, str] = {} # all local files checksums
381
+
382
+ # have to batch requests if above max number allowed by divbase server
383
+ for i in range(0, len(all_files), MAX_S3_API_BATCH_SIZE):
384
+ batch_files = all_files[i : i + MAX_S3_API_BATCH_SIZE]
385
+ batch_files_names = [file.name for file in batch_files]
386
+
387
+ response = make_authenticated_request(
388
+ method="POST",
389
+ divbase_base_url=divbase_base_url,
390
+ api_route=f"v1/s3/checksums?project_name={project_name}",
391
+ json=batch_files_names,
392
+ )
393
+ server_checksum_responses = [FileChecksumResponse(**item) for item in response.json()]
394
+ server_checksums = {item.object_name: item.md5_checksum for item in server_checksum_responses}
395
+
396
+ for file in batch_files:
397
+ local_checksums[file.name] = _calc_local_checksum(file_path=file)
398
+ if server_checksums.get(file.name) and server_checksums[file.name] == local_checksums[file.name]:
399
+ already_uploaded_files[file] = local_checksums[file.name]
400
+ print(f"MD5 Checksum calculated for file: '{file.name}'")
401
+
402
+ if already_uploaded_files:
403
+ raise FilesAlreadyInProjectError(existing_files=already_uploaded_files, project_name=project_name)
404
+
405
+ return local_checksums
406
+
407
+
408
+ def filter_out_already_downloaded_files(
409
+ all_files: list[ToDownload], download_dir: Path
410
+ ) -> tuple[list[ToDownload], list[ToDownload]]:
411
+ """
412
+ Filter out files that already exist in a local directory with the same checksum.
413
+
414
+ Returns two lists:
415
+ 1. Files that do not exist locally and need to be downloaded.
416
+ 2. Files that are not identical to the file in S3 and will be overwritten if the user wants to download them.
417
+ """
418
+ files_to_download, files_to_overwrite = [], []
419
+
420
+ for s3_file in all_files:
421
+ local_file_path = download_dir / s3_file.name
422
+
423
+ if not local_file_path.exists():
424
+ files_to_download.append(s3_file)
425
+ continue
426
+
427
+ local_checksum = _calc_local_checksum(file_path=local_file_path)
428
+ if local_checksum != s3_file.etag:
429
+ files_to_overwrite.append(s3_file)
430
+
431
+ return files_to_download, files_to_overwrite
432
+
433
+
434
+ def _calc_local_checksum(file_path: Path) -> str:
435
+ """
436
+ Calculate the checksum for a local file. Handles whether to use a single or composite checksum based on file size.
437
+
438
+ This is used to validate the integrity of a file before upload.
439
+ Or determine if a file can be skipped from downloading.
440
+ """
441
+ if file_path.stat().st_size > S3_MULTIPART_UPLOAD_THRESHOLD:
442
+ return calculate_composite_md5_s3_etag(file_path=file_path)
443
+ else:
444
+ return calculate_md5_checksum(file_path=file_path, output_format=MD5CheckSumFormat.HEX)