divbase-cli 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev4__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.
- divbase_cli/__init__.py +1 -1
- divbase_cli/cli_commands/auth_cli.py +16 -10
- divbase_cli/cli_commands/dimensions_cli.py +322 -26
- divbase_cli/cli_commands/file_cli.py +488 -101
- divbase_cli/cli_commands/query_cli.py +14 -9
- divbase_cli/cli_commands/shared_args_options.py +23 -0
- divbase_cli/cli_commands/task_history_cli.py +3 -8
- divbase_cli/cli_commands/user_config_cli.py +14 -44
- divbase_cli/cli_commands/version_cli.py +98 -37
- divbase_cli/cli_config.py +27 -9
- divbase_cli/cli_exceptions.py +37 -22
- divbase_cli/config_resolver.py +10 -10
- divbase_cli/divbase_cli.py +1 -1
- divbase_cli/retries.py +34 -0
- divbase_cli/services/__init__.py +0 -0
- divbase_cli/services/announcements.py +46 -0
- divbase_cli/services/pre_signed_urls.py +456 -0
- divbase_cli/services/project_versions.py +103 -0
- divbase_cli/services/s3_files.py +442 -0
- divbase_cli/user_auth.py +104 -46
- divbase_cli/user_config.py +20 -9
- divbase_cli/utils.py +28 -0
- {divbase_cli-0.1.0.dev2.dist-info → divbase_cli-0.1.0.dev4.dist-info}/METADATA +3 -4
- divbase_cli-0.1.0.dev4.dist-info/RECORD +28 -0
- {divbase_cli-0.1.0.dev2.dist-info → divbase_cli-0.1.0.dev4.dist-info}/WHEEL +1 -1
- divbase_cli/pre_signed_urls.py +0 -169
- divbase_cli/services.py +0 -219
- divbase_cli-0.1.0.dev2.dist-info/RECORD +0 -22
- {divbase_cli-0.1.0.dev2.dist-info → divbase_cli-0.1.0.dev4.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,442 @@
|
|
|
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
|
+
# mapping of file name to hex-encoded checksum
|
|
306
|
+
file_checksums_hex = compare_local_to_s3_checksums(
|
|
307
|
+
project_name=project_name,
|
|
308
|
+
divbase_base_url=divbase_base_url,
|
|
309
|
+
all_files=all_files,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
files_below_threshold, files_above_threshold = [], []
|
|
313
|
+
for file in all_files:
|
|
314
|
+
if file.stat().st_size <= S3_MULTIPART_UPLOAD_THRESHOLD:
|
|
315
|
+
files_below_threshold.append(file)
|
|
316
|
+
else:
|
|
317
|
+
files_above_threshold.append(file)
|
|
318
|
+
|
|
319
|
+
all_successful_uploads: list[SuccessfulUpload] = []
|
|
320
|
+
all_failed_uploads: list[FailedUpload] = []
|
|
321
|
+
|
|
322
|
+
# P1. Process all single-part uploads in batches of max size allowed by divbase server.
|
|
323
|
+
for i in range(0, len(files_below_threshold), MAX_S3_API_BATCH_SIZE):
|
|
324
|
+
batch_files = files_below_threshold[i : i + MAX_S3_API_BATCH_SIZE]
|
|
325
|
+
batch_of_objects_to_upload = []
|
|
326
|
+
for file in batch_files:
|
|
327
|
+
upload_object = {
|
|
328
|
+
"name": file.name,
|
|
329
|
+
"content_length": file.stat().st_size,
|
|
330
|
+
}
|
|
331
|
+
if safe_mode:
|
|
332
|
+
hex_checksum = file_checksums_hex[file.name]
|
|
333
|
+
upload_object["md5_hash"] = convert_checksum_hex_to_base64(hex_checksum)
|
|
334
|
+
batch_of_objects_to_upload.append(upload_object)
|
|
335
|
+
|
|
336
|
+
response = make_authenticated_request(
|
|
337
|
+
method="POST",
|
|
338
|
+
divbase_base_url=divbase_base_url,
|
|
339
|
+
api_route=f"v1/s3/upload/single-part?project_name={project_name}",
|
|
340
|
+
json=batch_of_objects_to_upload,
|
|
341
|
+
)
|
|
342
|
+
pre_signed_urls = [PreSignedSinglePartUploadResponse(**item) for item in response.json()]
|
|
343
|
+
single_part_upload_outcome = upload_multiple_singlepart_pre_signed_urls(
|
|
344
|
+
pre_signed_urls=pre_signed_urls, all_files=batch_files
|
|
345
|
+
)
|
|
346
|
+
all_successful_uploads.extend(single_part_upload_outcome.successful)
|
|
347
|
+
all_failed_uploads.extend(single_part_upload_outcome.failed)
|
|
348
|
+
|
|
349
|
+
# P2. process all multipart uploads.
|
|
350
|
+
for file_path in files_above_threshold:
|
|
351
|
+
outcome = perform_multipart_upload(
|
|
352
|
+
project_name=project_name,
|
|
353
|
+
divbase_base_url=divbase_base_url,
|
|
354
|
+
file_path=file_path,
|
|
355
|
+
safe_mode=safe_mode,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if isinstance(outcome, SuccessfulUpload):
|
|
359
|
+
all_successful_uploads.append(outcome)
|
|
360
|
+
else:
|
|
361
|
+
all_failed_uploads.append(outcome)
|
|
362
|
+
|
|
363
|
+
return UploadOutcome(successful=all_successful_uploads, failed=all_failed_uploads)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def compare_local_to_s3_checksums(project_name: str, divbase_base_url: str, all_files: list[Path]) -> dict[str, str]:
|
|
367
|
+
"""
|
|
368
|
+
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.
|
|
369
|
+
Raises error if any files already exist in the project's S3 bucket with identical checksums.
|
|
370
|
+
|
|
371
|
+
Here, we are catching an attempt to upload an identical file twice.
|
|
372
|
+
This is only ran if 'safe_mode' is enabled for uploads.
|
|
373
|
+
We do not catch an attempt to upload an identical object if it has a different name.
|
|
374
|
+
|
|
375
|
+
Return a dict of file names with hex-encoded checksums for all files to be uploaded (including those that are not in S3).
|
|
376
|
+
These checksums are later used when uploading to the server so the server can verify the upload.
|
|
377
|
+
"""
|
|
378
|
+
already_uploaded_files: dict[Path, str] = {} # files that already exist in S3 with identical checksum
|
|
379
|
+
local_checksums: dict[str, str] = {} # all local files checksums
|
|
380
|
+
|
|
381
|
+
# have to batch requests if above max number allowed by divbase server
|
|
382
|
+
for i in range(0, len(all_files), MAX_S3_API_BATCH_SIZE):
|
|
383
|
+
batch_files = all_files[i : i + MAX_S3_API_BATCH_SIZE]
|
|
384
|
+
batch_files_names = [file.name for file in batch_files]
|
|
385
|
+
|
|
386
|
+
response = make_authenticated_request(
|
|
387
|
+
method="POST",
|
|
388
|
+
divbase_base_url=divbase_base_url,
|
|
389
|
+
api_route=f"v1/s3/checksums?project_name={project_name}",
|
|
390
|
+
json=batch_files_names,
|
|
391
|
+
)
|
|
392
|
+
server_checksum_responses = [FileChecksumResponse(**item) for item in response.json()]
|
|
393
|
+
server_checksums = {item.object_name: item.md5_checksum for item in server_checksum_responses}
|
|
394
|
+
|
|
395
|
+
for file in batch_files:
|
|
396
|
+
local_checksums[file.name] = _calc_local_checksum(file_path=file)
|
|
397
|
+
if server_checksums.get(file.name) and server_checksums[file.name] == local_checksums[file.name]:
|
|
398
|
+
already_uploaded_files[file] = local_checksums[file.name]
|
|
399
|
+
|
|
400
|
+
if already_uploaded_files:
|
|
401
|
+
raise FilesAlreadyInProjectError(existing_files=already_uploaded_files, project_name=project_name)
|
|
402
|
+
|
|
403
|
+
return local_checksums
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def filter_out_already_downloaded_files(
|
|
407
|
+
all_files: list[ToDownload], download_dir: Path
|
|
408
|
+
) -> tuple[list[ToDownload], list[ToDownload]]:
|
|
409
|
+
"""
|
|
410
|
+
Filter out files that already exist in a local directory with the same checksum.
|
|
411
|
+
|
|
412
|
+
Returns two lists:
|
|
413
|
+
1. Files that do not exist locally and need to be downloaded.
|
|
414
|
+
2. Files that are not identical to the file in S3 and will be overwritten if the user wants to download them.
|
|
415
|
+
"""
|
|
416
|
+
files_to_download, files_to_overwrite = [], []
|
|
417
|
+
|
|
418
|
+
for s3_file in all_files:
|
|
419
|
+
local_file_path = download_dir / s3_file.name
|
|
420
|
+
|
|
421
|
+
if not local_file_path.exists():
|
|
422
|
+
files_to_download.append(s3_file)
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
local_checksum = _calc_local_checksum(file_path=local_file_path)
|
|
426
|
+
if local_checksum != s3_file.etag:
|
|
427
|
+
files_to_overwrite.append(s3_file)
|
|
428
|
+
|
|
429
|
+
return files_to_download, files_to_overwrite
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _calc_local_checksum(file_path: Path) -> str:
|
|
433
|
+
"""
|
|
434
|
+
Calculate the checksum for a local file. Handles whether to use a single or composite checksum based on file size.
|
|
435
|
+
|
|
436
|
+
This is used to validate the integrity of a file before upload.
|
|
437
|
+
Or determine if a file can be skipped from downloading.
|
|
438
|
+
"""
|
|
439
|
+
if file_path.stat().st_size > S3_MULTIPART_UPLOAD_THRESHOLD:
|
|
440
|
+
return calculate_composite_md5_s3_etag(file_path=file_path)
|
|
441
|
+
else:
|
|
442
|
+
return calculate_md5_checksum(file_path=file_path, output_format=MD5CheckSumFormat.HEX)
|
divbase_cli/user_auth.py
CHANGED
|
@@ -7,16 +7,21 @@ This includes login/logout and the getting, storing, using, and refreshing of ac
|
|
|
7
7
|
import time
|
|
8
8
|
import warnings
|
|
9
9
|
from dataclasses import dataclass
|
|
10
|
+
from json import JSONDecodeError
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
14
|
+
import stamina
|
|
13
15
|
import yaml
|
|
14
16
|
from pydantic import SecretStr
|
|
15
17
|
|
|
18
|
+
from divbase_cli import __version__ as cli_version
|
|
16
19
|
from divbase_cli.cli_config import cli_settings
|
|
17
20
|
from divbase_cli.cli_exceptions import AuthenticationError, DivBaseAPIConnectionError, DivBaseAPIError
|
|
21
|
+
from divbase_cli.retries import retry_only_on_retryable_divbase_api_errors
|
|
18
22
|
from divbase_cli.user_config import load_user_config
|
|
19
23
|
from divbase_lib.api_schemas.auth import LogoutRequest
|
|
24
|
+
from divbase_lib.divbase_constants import CLI_VERSION_HEADER_KEY
|
|
20
25
|
|
|
21
26
|
LOGIN_AGAIN_MESSAGE = "Your session has expired. Please log in again with 'divbase-cli auth login [EMAIL]'."
|
|
22
27
|
|
|
@@ -76,30 +81,55 @@ def check_existing_session(divbase_url: str, config) -> int | None:
|
|
|
76
81
|
return token_data.refresh_token_expires_at
|
|
77
82
|
|
|
78
83
|
|
|
79
|
-
def
|
|
80
|
-
"""
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
def _handle_divbase_api_error(response: httpx.Response, http_method: str, url: str) -> None:
|
|
85
|
+
"""Handles custom display of a HTTP error response returned by DivBase API."""
|
|
86
|
+
try:
|
|
87
|
+
response_body = response.json()
|
|
88
|
+
error_details = response_body.get("detail", "No error message provided.")
|
|
89
|
+
error_type = response_body.get("type", "unknown")
|
|
90
|
+
except (JSONDecodeError, ValueError):
|
|
91
|
+
# most likely situation for this would be if the reverse proxy returns an error, not the server itself.
|
|
92
|
+
error_details = response.text
|
|
93
|
+
error_type = "unexpected_server_error"
|
|
94
|
+
|
|
95
|
+
raise DivBaseAPIError(
|
|
96
|
+
error_details=error_details,
|
|
97
|
+
status_code=response.status_code,
|
|
98
|
+
error_type=error_type,
|
|
99
|
+
http_method=http_method,
|
|
100
|
+
url=url,
|
|
101
|
+
) from None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@stamina.retry(on=retry_only_on_retryable_divbase_api_errors, attempts=3)
|
|
105
|
+
def login_to_divbase(email: str, password: SecretStr, divbase_url: str) -> None:
|
|
106
|
+
"""Log in to the DivBase server and return user tokens."""
|
|
107
|
+
login_url = f"{divbase_url}/v1/auth/login"
|
|
83
108
|
try:
|
|
84
109
|
response = httpx.post(
|
|
85
|
-
|
|
110
|
+
url=login_url,
|
|
86
111
|
data={
|
|
87
112
|
"grant_type": "password",
|
|
88
113
|
"username": email, # OAuth2 uses 'username', not 'email'
|
|
89
114
|
"password": password.get_secret_value(),
|
|
90
115
|
},
|
|
91
|
-
headers={
|
|
116
|
+
headers={
|
|
117
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
118
|
+
CLI_VERSION_HEADER_KEY: cli_version,
|
|
119
|
+
},
|
|
92
120
|
)
|
|
93
121
|
except httpx.ConnectError:
|
|
94
122
|
# We don't raise the full error as it contains the password in the stack trace.
|
|
95
123
|
# a user could unknowingly dump this into e.g. a bug report/GitHub issue.
|
|
96
124
|
raise DivBaseAPIConnectionError() from None
|
|
97
125
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
126
|
+
try:
|
|
127
|
+
response.raise_for_status()
|
|
128
|
+
except httpx.HTTPStatusError:
|
|
129
|
+
if response.status_code == 401:
|
|
130
|
+
error_message = response.json().get("detail", "Invalid email or password.")
|
|
131
|
+
raise AuthenticationError(error_message) from None
|
|
132
|
+
_handle_divbase_api_error(response=response, http_method="POST", url=login_url)
|
|
103
133
|
|
|
104
134
|
data = response.json()
|
|
105
135
|
token_data = TokenData(
|
|
@@ -110,18 +140,16 @@ def login_to_divbase(email: str, password: SecretStr, divbase_url: str, config_p
|
|
|
110
140
|
)
|
|
111
141
|
token_data.dump_tokens()
|
|
112
142
|
|
|
113
|
-
config = load_user_config(
|
|
143
|
+
config = load_user_config()
|
|
114
144
|
config.set_login_status(url=divbase_url, email=email)
|
|
115
145
|
|
|
116
146
|
|
|
117
|
-
def logout_of_divbase(
|
|
118
|
-
token_path: Path = cli_settings.TOKENS_PATH, config_path: Path = cli_settings.CONFIG_PATH
|
|
119
|
-
) -> None:
|
|
147
|
+
def logout_of_divbase(token_path: Path = cli_settings.TOKENS_PATH) -> None:
|
|
120
148
|
"""
|
|
121
149
|
Log out of the DivBase server.
|
|
122
150
|
We send the refresh token to DivBase to be revoked server-side.
|
|
123
151
|
"""
|
|
124
|
-
config = load_user_config(
|
|
152
|
+
config = load_user_config()
|
|
125
153
|
|
|
126
154
|
# the "if" avoids raising an error on a non logged in user trying to logout
|
|
127
155
|
if config.logged_in_url:
|
|
@@ -163,9 +191,7 @@ def logout_of_divbase(
|
|
|
163
191
|
|
|
164
192
|
|
|
165
193
|
def load_user_tokens(token_path: Path = cli_settings.TOKENS_PATH) -> TokenData:
|
|
166
|
-
"""
|
|
167
|
-
Load user tokens from the specified path.
|
|
168
|
-
"""
|
|
194
|
+
"""Load user tokens from the specified path."""
|
|
169
195
|
if not token_path.exists():
|
|
170
196
|
raise AuthenticationError(
|
|
171
197
|
f"Your access tokens were not found at {token_path}. Please check you are logged in first."
|
|
@@ -182,6 +208,7 @@ def load_user_tokens(token_path: Path = cli_settings.TOKENS_PATH) -> TokenData:
|
|
|
182
208
|
)
|
|
183
209
|
|
|
184
210
|
|
|
211
|
+
@stamina.retry(on=retry_only_on_retryable_divbase_api_errors, attempts=3)
|
|
185
212
|
def make_authenticated_request(
|
|
186
213
|
method: str,
|
|
187
214
|
divbase_base_url: str,
|
|
@@ -189,9 +216,7 @@ def make_authenticated_request(
|
|
|
189
216
|
token_path: Path = cli_settings.TOKENS_PATH,
|
|
190
217
|
**kwargs,
|
|
191
218
|
) -> httpx.Response:
|
|
192
|
-
"""
|
|
193
|
-
Make an authenticated request to the DivBase server, handles refreshing tokens if needed.
|
|
194
|
-
"""
|
|
219
|
+
"""Make an authenticated request to the DivBase server, handles refreshing tokens if needed."""
|
|
195
220
|
token_data = load_user_tokens(token_path=token_path)
|
|
196
221
|
|
|
197
222
|
if token_data.is_access_token_expired():
|
|
@@ -205,27 +230,52 @@ def make_authenticated_request(
|
|
|
205
230
|
|
|
206
231
|
headers = kwargs.get("headers", {})
|
|
207
232
|
headers["Authorization"] = f"Bearer {token_data.access_token.get_secret_value()}"
|
|
233
|
+
headers[CLI_VERSION_HEADER_KEY] = cli_version
|
|
208
234
|
kwargs["headers"] = headers
|
|
209
235
|
|
|
210
236
|
url = f"{divbase_base_url}/{api_route.lstrip('/')}"
|
|
211
237
|
|
|
212
238
|
try:
|
|
213
|
-
response = httpx.request(method, url, **kwargs)
|
|
239
|
+
response = httpx.request(method=method, url=url, **kwargs)
|
|
240
|
+
except httpx.HTTPError as e:
|
|
241
|
+
raise DivBaseAPIConnectionError() from e
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
response.raise_for_status()
|
|
245
|
+
except httpx.HTTPStatusError:
|
|
246
|
+
_handle_divbase_api_error(response=response, http_method=method, url=url)
|
|
247
|
+
|
|
248
|
+
return response
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@stamina.retry(on=retry_only_on_retryable_divbase_api_errors, attempts=3)
|
|
252
|
+
def make_unauthenticated_request(
|
|
253
|
+
method: str,
|
|
254
|
+
divbase_base_url: str,
|
|
255
|
+
api_route: str,
|
|
256
|
+
token_path: Path = cli_settings.TOKENS_PATH,
|
|
257
|
+
**kwargs,
|
|
258
|
+
) -> httpx.Response:
|
|
259
|
+
"""
|
|
260
|
+
Make an unauthenticated request to the DivBase server.
|
|
261
|
+
Used for those few endpoints that require no authentication, even if the user is logged in.
|
|
262
|
+
E.g. the announcements endpoint.
|
|
263
|
+
"""
|
|
264
|
+
url = f"{divbase_base_url}/{api_route.lstrip('/')}"
|
|
265
|
+
|
|
266
|
+
headers = kwargs.get("headers", {})
|
|
267
|
+
headers[CLI_VERSION_HEADER_KEY] = cli_version
|
|
268
|
+
kwargs["headers"] = headers
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
response = httpx.request(method=method, url=url, **kwargs)
|
|
214
272
|
except httpx.HTTPError as e:
|
|
215
273
|
raise DivBaseAPIConnectionError() from e
|
|
216
274
|
|
|
217
275
|
try:
|
|
218
276
|
response.raise_for_status()
|
|
219
277
|
except httpx.HTTPStatusError:
|
|
220
|
-
|
|
221
|
-
error_type = response.json().get("type", "unknown")
|
|
222
|
-
raise DivBaseAPIError(
|
|
223
|
-
error_details=error_details,
|
|
224
|
-
status_code=response.status_code,
|
|
225
|
-
error_type=error_type,
|
|
226
|
-
http_method=method,
|
|
227
|
-
url=url,
|
|
228
|
-
) from None
|
|
278
|
+
_handle_divbase_api_error(response=response, http_method=method, url=url)
|
|
229
279
|
|
|
230
280
|
return response
|
|
231
281
|
|
|
@@ -235,23 +285,31 @@ def _refresh_access_token(token_data: TokenData, divbase_base_url: str) -> Token
|
|
|
235
285
|
Use the refresh token to get a new access token and update the token file.
|
|
236
286
|
|
|
237
287
|
Returns the new TokenData object which can be used immediately in a new request.
|
|
288
|
+
NOTE: We do not need retry logic inside this function as the calling function has it.
|
|
238
289
|
"""
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
290
|
+
refresh_url = f"{divbase_base_url}/v1/auth/refresh"
|
|
291
|
+
try:
|
|
292
|
+
response = httpx.post(
|
|
293
|
+
url=refresh_url,
|
|
294
|
+
json={"refresh_token": token_data.refresh_token.get_secret_value()},
|
|
295
|
+
headers={CLI_VERSION_HEADER_KEY: cli_version},
|
|
296
|
+
)
|
|
297
|
+
except httpx.HTTPError as e:
|
|
298
|
+
raise DivBaseAPIConnectionError() from e
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
response.raise_for_status()
|
|
302
|
+
except httpx.HTTPStatusError:
|
|
303
|
+
# Possible if e.g. token revoked on server side.
|
|
304
|
+
if response.status_code == 401:
|
|
305
|
+
# Clear logged in status in user config as tokens no longer valid.
|
|
306
|
+
# Prevents user getting warning about being already logged in when they try to log in again.
|
|
307
|
+
config = load_user_config()
|
|
308
|
+
config.set_login_status(url=None, email=None)
|
|
309
|
+
raise AuthenticationError(LOGIN_AGAIN_MESSAGE) from None
|
|
245
310
|
|
|
246
|
-
|
|
247
|
-
if response.status_code == 401:
|
|
248
|
-
# Clear logged in status in user config as tokens no longer valid.
|
|
249
|
-
# Prevents user getting warning about being already logged in when they try to log in again.
|
|
250
|
-
config = load_user_config()
|
|
251
|
-
config.set_login_status(url=None, email=None)
|
|
252
|
-
raise AuthenticationError(LOGIN_AGAIN_MESSAGE)
|
|
311
|
+
_handle_divbase_api_error(response=response, http_method="POST", url=refresh_url)
|
|
253
312
|
|
|
254
|
-
response.raise_for_status()
|
|
255
313
|
data = response.json()
|
|
256
314
|
|
|
257
315
|
new_token_data = TokenData(
|