earthscope-cli 1.1.2__tar.gz → 1.2.0__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 (27) hide show
  1. {earthscope_cli-1.1.2/src/earthscope_cli.egg-info → earthscope_cli-1.2.0}/PKG-INFO +8 -5
  2. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/README.md +1 -1
  3. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/pyproject.toml +8 -5
  4. earthscope_cli-1.2.0/src/earthscope_cli/__init__.py +1 -0
  5. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/dropoff/render.py +126 -26
  6. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/dropoff/typer.py +98 -40
  7. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/dropoff/util.py +54 -45
  8. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0/src/earthscope_cli.egg-info}/PKG-INFO +8 -5
  9. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/requires.txt +2 -1
  10. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/tests/test_dropoff.py +457 -304
  11. earthscope_cli-1.1.2/src/earthscope_cli/__init__.py +0 -1
  12. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/LICENSE +0 -0
  13. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/setup.cfg +0 -0
  14. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/setup.py +0 -0
  15. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/common.py +0 -0
  16. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/debug.py +0 -0
  17. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/login.py +0 -0
  18. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/main.py +0 -0
  19. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/user.py +0 -0
  20. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/util.py +0 -0
  21. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/SOURCES.txt +0 -0
  22. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/dependency_links.txt +0 -0
  23. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/entry_points.txt +0 -0
  24. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/top_level.txt +0 -0
  25. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/tests/test_cli.py +0 -0
  26. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/tests/test_login.py +0 -0
  27. {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/tests/test_user.py +0 -0
@@ -1,24 +1,27 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: earthscope-cli
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: A CLI for the EarthScope API
5
5
  Author-email: EarthScope <data-help@earthscope.org>
6
6
  License-Expression: Apache-2.0
7
- Project-URL: Homepage, https://gitlab.com/earthscope/public/earthscope-cli
7
+ Project-URL: Homepage, https://docs.earthscope.org/projects/CLI
8
+ Project-URL: Repository, https://gitlab.com/earthscope/public/earthscope-cli
9
+ Project-URL: Changelog, https://docs.earthscope.org/projects/CLI/page/changelog.html
8
10
  Classifier: Programming Language :: Python
9
11
  Classifier: Programming Language :: Python :: 3
10
12
  Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.9
13
+ Requires-Python: >=3.10
12
14
  Description-Content-Type: text/markdown
13
15
  License-File: LICENSE
14
16
  Requires-Dist: typer<0.24.0
15
17
  Requires-Dist: typer-di>=0.1.3
16
- Requires-Dist: earthscope-sdk>=1.3.1
18
+ Requires-Dist: earthscope-sdk>=1.4.0
17
19
  Requires-Dist: rich>=13.8.0
18
20
  Provides-Extra: dev
19
21
  Requires-Dist: black; extra == "dev"
20
22
  Requires-Dist: bumpver; extra == "dev"
21
23
  Requires-Dist: build; extra == "dev"
24
+ Requires-Dist: pip>=22.0; extra == "dev"
22
25
  Requires-Dist: pre-commit; extra == "dev"
23
26
  Requires-Dist: pytest; extra == "dev"
24
27
  Requires-Dist: twine>=6.0.1; extra == "dev"
@@ -36,7 +39,7 @@ A CLI for interacting with EarthScope's APIs
36
39
  To use the CLI you must have:
37
40
 
38
41
  - Registered an account with Earthscope ([sign up now](https://earthscope.org/user/login)). See [here](https://www.earthscope.org/data/authentication) for more information
39
- - Python >= 3.9
42
+ - Python >= 3.10
40
43
 
41
44
  ### Installation
42
45
 
@@ -9,7 +9,7 @@ A CLI for interacting with EarthScope's APIs
9
9
  To use the CLI you must have:
10
10
 
11
11
  - Registered an account with Earthscope ([sign up now](https://earthscope.org/user/login)). See [here](https://www.earthscope.org/data/authentication) for more information
12
- - Python >= 3.9
12
+ - Python >= 3.10
13
13
 
14
14
  ### Installation
15
15
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "earthscope-cli"
7
- version = "1.1.2"
7
+ version = "1.2.0"
8
8
  description = "A CLI for the EarthScope API"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "EarthScope", email = "data-help@earthscope.org" }]
@@ -20,16 +20,17 @@ classifiers = [
20
20
  dependencies = [
21
21
  "typer<0.24.0",
22
22
  "typer-di>=0.1.3",
23
- "earthscope-sdk>=1.3.1",
23
+ "earthscope-sdk>=1.4.0",
24
24
  "rich>=13.8.0",
25
25
  ]
26
- requires-python = ">=3.9"
26
+ requires-python = ">=3.10"
27
27
 
28
28
  [project.optional-dependencies]
29
29
  dev = [
30
30
  "black",
31
31
  "bumpver",
32
32
  "build",
33
+ "pip>=22.0",
33
34
  "pre-commit",
34
35
  "pytest",
35
36
  "twine>=6.0.1",
@@ -37,13 +38,15 @@ dev = [
37
38
  ]
38
39
 
39
40
  [project.urls]
40
- Homepage = "https://gitlab.com/earthscope/public/earthscope-cli"
41
+ Homepage = "https://docs.earthscope.org/projects/CLI"
42
+ Repository = "https://gitlab.com/earthscope/public/earthscope-cli"
43
+ Changelog = "https://docs.earthscope.org/projects/CLI/page/changelog.html"
41
44
 
42
45
  [project.scripts]
43
46
  es = "earthscope_cli.main:app"
44
47
 
45
48
  [tool.bumpver]
46
- current_version = "1.1.2"
49
+ current_version = "1.2.0"
47
50
  version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]"
48
51
  commit_message = "chore: bump version {old_version} -> {new_version}"
49
52
  commit = true
@@ -0,0 +1 @@
1
+ __version__ = "1.2.0"
@@ -45,12 +45,25 @@ def _colorize_status(status: str) -> Text:
45
45
  return Text(f"{status or '-'}", style="dim")
46
46
 
47
47
 
48
+ def _format_details(details: List[str], is_truncated: bool) -> Text:
49
+ """Format structured_messages (with truncation indicator) or fall back to status_message."""
50
+ if details:
51
+ content = "\n".join(details)
52
+ result = Text(content)
53
+ if is_truncated:
54
+ result.append("\n(truncated)", style="dim")
55
+ return result
56
+
57
+ return Text("-", style="dim")
58
+
59
+
48
60
  def print_dropoff_objects_table(
49
61
  objects: List[DropoffObject],
50
62
  *,
51
63
  title: str,
52
64
  has_next: bool,
53
65
  next_offset: int,
66
+ show_details: bool = False,
54
67
  ):
55
68
  if has_next:
56
69
  caption = f"Use --offset {next_offset} to view more results."
@@ -69,16 +82,28 @@ def print_dropoff_objects_table(
69
82
  table.add_column("Last Modified", justify="right")
70
83
  table.add_column("Status", justify="center")
71
84
  table.add_column("Message")
72
-
73
- for o in objects:
74
- table.add_row(
75
- safe_str(o.path),
76
- filesize.decimal(o.size),
77
- safe_str(o.received_at),
78
- _colorize_status(o.status),
79
- safe_str(o.status_message),
80
- )
81
-
85
+ if show_details:
86
+ table.add_column("Details")
87
+ for o in objects:
88
+ table.add_row(
89
+ safe_str(o.path),
90
+ filesize.decimal(o.size),
91
+ safe_str(o.received_at),
92
+ _colorize_status(o.status),
93
+ safe_str(o.status_message),
94
+ _format_details(
95
+ o.structured_messages, o.structured_messages_were_truncated
96
+ ),
97
+ )
98
+ else:
99
+ for o in objects:
100
+ table.add_row(
101
+ safe_str(o.path),
102
+ filesize.decimal(o.size),
103
+ safe_str(o.received_at),
104
+ _colorize_status(o.status),
105
+ safe_str(o.status_message),
106
+ )
82
107
  print()
83
108
  print(table)
84
109
  print()
@@ -236,10 +261,10 @@ class ConcurrentUploadProgressDisplay:
236
261
  hidden to keep the display clean and focused on active uploads.
237
262
 
238
263
  Usage:
239
- display = ConcurrentUploadProgressDisplay(num_files=10)
264
+ display = ConcurrentUploadProgressDisplay(num_collected=10)
240
265
  with display:
241
- sdk.dropoff.put_dropoff_files(
242
- files=file_list,
266
+ sdk.dropoff.put_objects(
267
+ objects=validated_files_generator(),
243
268
  category="miniseed",
244
269
  progress_cb=display.callback,
245
270
  )
@@ -247,7 +272,7 @@ class ConcurrentUploadProgressDisplay:
247
272
 
248
273
  def __init__(
249
274
  self,
250
- num_files: int,
275
+ num_collected: int,
251
276
  hide_completed_threshold: int = 10,
252
277
  hide_delay_seconds: float = 1.5,
253
278
  ):
@@ -255,17 +280,29 @@ class ConcurrentUploadProgressDisplay:
255
280
  Initialize the progress display.
256
281
 
257
282
  Args:
258
- num_files: Total number of files to upload
283
+ num_collected: Total number of files collected (to be validated then uploaded)
259
284
  hide_completed_threshold: Threshold for hiding completed progress bars
260
285
  hide_delay_seconds: Delay in seconds before hiding completed progress bars
261
286
  """
262
287
  self.hide_completed_threshold = hide_completed_threshold
263
288
  self.hide_delay_seconds = hide_delay_seconds
264
- self.num_files = num_files
289
+ self.num_collected = num_collected
290
+ self.num_valid = 0
291
+ self.num_invalid = 0
292
+ self.validation_complete = False
265
293
  self.files_completed = 0
266
294
  self.upload_started = False # Track if we've received any bytes yet
267
295
 
268
- # Overall progress (files completed, plus aggregate throughput)
296
+ # Validation progress
297
+ self.validation_progress = Progress(
298
+ TextColumn("[bold blue]{task.description}"),
299
+ BarColumn(),
300
+ MofNCompleteColumn(),
301
+ TextColumn("•"),
302
+ TimeElapsedColumn(),
303
+ )
304
+
305
+ # Overall upload progress (files completed, plus aggregate throughput)
269
306
  self.overall_progress = Progress(
270
307
  TextColumn("[bold blue]{task.description}"),
271
308
  BarColumn(),
@@ -291,6 +328,7 @@ class ConcurrentUploadProgressDisplay:
291
328
  self.file_tasks: Dict[str, TaskID] = {}
292
329
  self.file_bytes_done: Dict[str, int] = {} # Track current bytes_done per file
293
330
  self.file_bytes_resumed: Dict[str, int] = {} # Track bytes resumed per file
331
+ self.validation_task: Optional[TaskID] = None
294
332
  self.overall_task: Optional[TaskID] = None
295
333
  self.completed_files: set[str] = set()
296
334
  self.live: Optional[Live] = None
@@ -298,12 +336,17 @@ class ConcurrentUploadProgressDisplay:
298
336
 
299
337
  def __enter__(self):
300
338
  """Start the live progress display."""
301
- # Create overall progress task tracking files completed
302
- # (visual_bytes shows total progress, throughput_bytes for speed calculation)
303
- # Don't start the timer yet - wait for first byte update
339
+ # Validation progress task
340
+ self.validation_task = self.validation_progress.add_task(
341
+ "[bold]Validating...",
342
+ total=self.num_collected,
343
+ )
344
+
345
+ # Upload progress task — total starts at num_collected, decremented as files fail validation
346
+ # Don't start the timer yet — wait for first byte update
304
347
  self.overall_task = self.overall_progress.add_task(
305
- f"[bold]Uploading {self.num_files} files...",
306
- total=self.num_files,
348
+ "[bold]Uploading...",
349
+ total=self.num_collected,
307
350
  visual_bytes=0,
308
351
  throughput_bytes=0,
309
352
  start=False,
@@ -311,6 +354,7 @@ class ConcurrentUploadProgressDisplay:
311
354
 
312
355
  # Create progress group
313
356
  progress_group = Group(
357
+ self.validation_progress,
314
358
  self.overall_progress,
315
359
  self.file_progress,
316
360
  )
@@ -329,10 +373,10 @@ class ConcurrentUploadProgressDisplay:
329
373
 
330
374
  if self.live:
331
375
  # Update final status
332
- if exc_type is None and self.overall_task is not None:
376
+ if exc_type is None and self.overall_task is not None and self.num_valid > 0:
333
377
  self.overall_progress.update(
334
378
  self.overall_task,
335
- description=f"[bold green]✓ Completed {self.num_files} uploads",
379
+ description=f"[bold green]✓ Completed {self.num_valid} uploads",
336
380
  )
337
381
 
338
382
  # Hide all individual file progress bars when complete
@@ -343,6 +387,62 @@ class ConcurrentUploadProgressDisplay:
343
387
  self.live.__exit__(exc_type, exc_val, exc_tb)
344
388
  return False
345
389
 
390
+ def validation_passed(self):
391
+ """Called when a file passes validation and will be uploaded."""
392
+ self.num_valid += 1
393
+ if self.validation_task is not None:
394
+ self.validation_progress.advance(self.validation_task, 1)
395
+
396
+ def validation_failed(self):
397
+ """Called when a file fails validation and will not be uploaded."""
398
+ self.num_invalid += 1
399
+ if self.validation_task is not None:
400
+ self.validation_progress.advance(self.validation_task, 1)
401
+ self.validation_progress.update(
402
+ self.validation_task,
403
+ description=f"[bold blue]Validating... [dim]({self.num_invalid} failed)",
404
+ )
405
+ # Decrement upload total since this file won't be uploaded
406
+ if self.overall_task is not None:
407
+ self.overall_progress.update(
408
+ self.overall_task,
409
+ total=self.num_collected - self.num_invalid,
410
+ )
411
+
412
+ def finalize_validation(self):
413
+ """Called when all validation is complete."""
414
+ self.validation_complete = True
415
+ if self.validation_task is not None:
416
+ self.validation_progress.stop_task(self.validation_task)
417
+ if self.num_invalid == 0:
418
+ self.validation_progress.update(
419
+ self.validation_task,
420
+ description=f"[bold green]✓ Validated {self.num_valid} files",
421
+ )
422
+ elif self.num_valid > 0:
423
+ self.validation_progress.update(
424
+ self.validation_task,
425
+ description=f"[bold yellow]✓ Validated {self.num_valid}/{self.num_collected} files ({self.num_invalid} failed)",
426
+ )
427
+ else:
428
+ self.validation_progress.update(
429
+ self.validation_task,
430
+ description=f"[bold red]✗ Validation failed for all {self.num_invalid} files",
431
+ )
432
+ # Update upload description with final count
433
+ if self.overall_task is not None and self.num_valid > 0:
434
+ self.overall_progress.update(
435
+ self.overall_task,
436
+ description=f"[bold]Uploading {self.num_valid} files...",
437
+ )
438
+ # Check if all uploads already completed
439
+ if (
440
+ self.overall_task is not None
441
+ and self.num_valid > 0
442
+ and len(self.completed_files) == self.num_valid
443
+ ):
444
+ self.overall_progress.stop_task(self.overall_task)
445
+
346
446
  def _hide_completed_task(self, task_id: TaskID, key: str):
347
447
  """
348
448
  Hide a completed task from the display.
@@ -426,11 +526,11 @@ class ConcurrentUploadProgressDisplay:
426
526
  self.overall_progress.advance(self.overall_task, 1)
427
527
 
428
528
  # If all files are complete, stop the timer to freeze throughput
429
- if len(self.completed_files) == self.num_files:
529
+ if self.validation_complete and len(self.completed_files) == self.num_valid:
430
530
  self.overall_progress.stop_task(self.overall_task)
431
531
 
432
532
  # For large uploads, hide completed progress bars after a delay
433
- if self.num_files > self.hide_completed_threshold:
533
+ if self.num_collected > self.hide_completed_threshold:
434
534
  # Schedule hiding the task after a delay
435
535
  timer = threading.Timer(
436
536
  self.hide_delay_seconds,
@@ -1,6 +1,6 @@
1
1
  from pathlib import Path
2
2
  from textwrap import dedent
3
- from typing import Annotated
3
+ from typing import Annotated, Iterable
4
4
 
5
5
  import typer
6
6
  from earthscope_sdk import EarthScopeClient
@@ -65,6 +65,7 @@ def get_object_history(
65
65
  title=f"History for: {key}",
66
66
  has_next=result.has_next,
67
67
  next_offset=result.offset + result.limit,
68
+ show_details=True,
68
69
  )
69
70
 
70
71
  cat_str = options.category.value
@@ -147,26 +148,53 @@ def list_objects(
147
148
  )
148
149
 
149
150
 
151
+ def destinaton_callback(value: str) -> str:
152
+ if not value:
153
+ print("[red]Destination cannot be empty.")
154
+ raise typer.Exit(code=2)
155
+ return value.removeprefix("/")
156
+
157
+
150
158
  @app.command(name="upload")
151
159
  def upload(
152
160
  sdk: Annotated[EarthScopeClient, Depends(get_sdk_refreshed)],
153
161
  category: Annotated[DropoffCategory, Depends(get_category)],
154
- paths: Annotated[
162
+ source: Annotated[
155
163
  list[Path],
156
- typer.Argument(
164
+ typer.Option(
165
+ "--source",
166
+ "-s",
157
167
  exists=True,
158
168
  file_okay=True,
159
169
  dir_okay=True,
160
170
  resolve_path=True,
171
+ help="One or more local files or directories to upload.",
172
+ ),
173
+ ],
174
+ destination: Annotated[
175
+ str,
176
+ typer.Option(
177
+ "--destination",
178
+ "-d",
161
179
  help=dedent(
162
- """
163
- The local path of a file or directory to upload.
164
-
165
- The path must include a directory e.g. 2026-01-01/file1 or 2025-01-01/
180
+ """\
181
+ Remote object key or prefix. A trailing '/' indicates a prefix
182
+ (source filename is appended); without a trailing '/' the
183
+ destination is used as the exact object key name. All keys are
184
+ relative to your dropoff home path.
166
185
  """
167
186
  ),
187
+ callback=destinaton_callback,
168
188
  ),
169
189
  ],
190
+ recursive: Annotated[
191
+ bool,
192
+ typer.Option(
193
+ "--recursive",
194
+ "-r",
195
+ help="Recursively traverse source directories.",
196
+ ),
197
+ ] = False,
170
198
  object_concurrency: Annotated[
171
199
  int,
172
200
  typer.Option(
@@ -192,53 +220,76 @@ def upload(
192
220
  """
193
221
  Upload files or directories to EarthScope's dropoff system.
194
222
 
223
+ The source is one or more local files or directories. The destination
224
+ specifies the remote object key or prefix.
225
+
226
+ A destination with a trailing '/' is treated as a prefix -- the source
227
+ filename is appended. Without a trailing '/', the destination is the
228
+ exact object key name.
229
+
230
+ When the source is a directory or multiple files, the destination must
231
+ be a prefix (trailing '/').
232
+
233
+ Use -r to recursively include files in subdirectories.
234
+
195
235
  Examples:
196
- es dropoff upload -c miniseed file.mseed
197
- es dropoff upload -c miniseed mydata/
198
- es dropoff upload -c miniseed project1/data.mseed project2/data.mseed
199
236
 
200
- How S3 keys are generated:
237
+ Single file to exact key:
238
+ es dropoff upload -c miniseed -d dest/keyname -s file.mseed
201
239
 
202
- - For individual files: The parent directory name is included in the S3 key
203
- to provide context and prevent collisions.
204
- Example: uploading 'project1/data.mseed' creates key 'project1/data.mseed'
240
+ Single file to prefix:
241
+ es dropoff upload -c miniseed -d dest/prefix/ -s file.mseed
205
242
 
206
- - For directories: All files maintain their relative paths from the directory,
207
- with the directory name as a prefix.
208
- Example: uploading 'mydata/' containing 'subdir/file.mseed' creates
209
- key 'mydata/subdir/file.mseed'
243
+ Directory (top-level files only):
244
+ es dropoff upload -c miniseed -d dest/prefix/ -s mydata/
210
245
 
211
- - Multiple files: Each file preserves its parent directory context.
212
- Example: uploading 'proj1/data.mseed' and 'proj2/data.mseed' creates
213
- distinct keys 'proj1/data.mseed' and 'proj2/data.mseed'
246
+ Directory (recursive):
247
+ es dropoff upload -c miniseed -d dest/prefix/ -r -s mydata/
214
248
 
215
- This behavior ensures files with the same name from different locations
216
- don't overwrite each other and maintains meaningful organization in S3.
249
+ Multiple files:
250
+ es dropoff upload -c miniseed -d dest/prefix/ -s file1.mseed -s file2.mseed
217
251
  """
218
- # Parse the input path to get a list of files
219
- total_collected = 0
252
+ is_prefix = destination.endswith("/")
253
+
254
+ if not is_prefix and (len(source) > 1 or any(s.is_dir() for s in source)):
255
+ print(
256
+ "[red]Multiple sources require destination to be a prefix (trailing '/')."
257
+ )
258
+ raise typer.Exit(code=2)
259
+
260
+ # Collect all files first (fast filesystem traversal)
261
+ collected = list(collect_files(source, destination, recursive))
262
+ total_collected = len(collected)
220
263
  succeeded = 0
221
- files: list[tuple[Path, str]] = []
222
- for p, k in collect_files(paths):
223
- total_collected += 1
224
- try:
225
- Validator(p, category=category).validate_all()
226
- except Exception as e:
227
- print(f"[red]{e}")
228
- else:
229
- files.append((p, k))
230
-
231
- if not files:
232
- paths_str = ", ".join(str(p) for p in paths)
233
- print(f"[red]No valid {category.value} files found in paths: {paths_str}")
264
+
265
+ if not collected:
266
+ src_str = ", ".join(str(s) for s in source)
267
+ print(f"[red]No valid {category.value} files found in sources: {src_str}")
234
268
  UploadSummary(total=total_collected, succeeded=succeeded).print()
235
269
  raise typer.Exit(code=1)
236
270
 
237
- display = ConcurrentUploadProgressDisplay(len(files))
271
+ # Validation runs inline in the generator. The SDK advances this iterator
272
+ # via asyncio.to_thread, so validation runs off the event loop and uploads
273
+ # can proceed concurrently with validation of remaining files.
274
+ validation_errors: list[str] = []
275
+
276
+ def _validated_files() -> Iterable[tuple[Path, str]]:
277
+ for p, k in collected:
278
+ try:
279
+ Validator(p, category=category).validate_all()
280
+ display.validation_passed()
281
+ yield (p, k)
282
+ except Exception as e:
283
+ validation_errors.append(str(e))
284
+ display.validation_failed()
285
+ display.finalize_validation()
286
+
287
+ display = ConcurrentUploadProgressDisplay(num_collected=total_collected)
288
+
238
289
  try:
239
290
  with display:
240
291
  sdk.dropoff.put_objects(
241
- objects=files,
292
+ objects=_validated_files(),
242
293
  category=category,
243
294
  object_concurrency=object_concurrency,
244
295
  part_concurrency=part_concurrency,
@@ -249,5 +300,12 @@ def upload(
249
300
  print()
250
301
  print("[yellow]Upload interrupted. Use the same command to resume the upload.")
251
302
  finally:
303
+ for error in validation_errors:
304
+ print(f"[red]{error}")
252
305
  succeeded = len(display.completed_files)
253
306
  UploadSummary(total=total_collected, succeeded=succeeded).print()
307
+
308
+ if validation_errors and len(validation_errors) == total_collected:
309
+ paths_str = ", ".join(str(p) for p in source)
310
+ print(f"[red]No valid {category.value} files found in paths: {paths_str}")
311
+ raise typer.Exit(code=1)