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.
@@ -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 login_to_divbase(email: str, password: SecretStr, divbase_url: str, config_path: Path) -> None:
80
- """
81
- Log in to the DivBase server and return user tokens.
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
- f"{divbase_url}/v1/auth/login",
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={"Content-Type": "application/x-www-form-urlencoded"},
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
- if response.status_code == 401:
99
- error_message = response.json().get("detail", "Invalid email or password.")
100
- raise AuthenticationError(error_message)
101
-
102
- response.raise_for_status()
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(config_path)
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(config_path)
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
- error_details = response.json().get("detail", "No error details provided")
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
- response = httpx.post(
240
- f"{divbase_base_url}/v1/auth/refresh",
241
- json={
242
- "refresh_token": token_data.refresh_token.get_secret_value(),
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
- # Possible if e.g. token revoked on server side.
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(