earthscope-cli 1.1.2__tar.gz → 1.3.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 (28) hide show
  1. {earthscope_cli-1.1.2/src/earthscope_cli.egg-info → earthscope_cli-1.3.0}/PKG-INFO +8 -5
  2. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/README.md +1 -1
  3. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/pyproject.toml +8 -5
  4. earthscope_cli-1.3.0/src/earthscope_cli/__init__.py +1 -0
  5. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/dropoff/render.py +142 -28
  6. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/dropoff/typer.py +120 -49
  7. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/dropoff/util.py +2 -61
  8. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0/src/earthscope_cli.egg-info}/PKG-INFO +8 -5
  9. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/SOURCES.txt +1 -0
  10. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/requires.txt +2 -1
  11. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/tests/test_dropoff.py +308 -401
  12. earthscope_cli-1.3.0/tests/test_startup_imports.py +88 -0
  13. earthscope_cli-1.1.2/src/earthscope_cli/__init__.py +0 -1
  14. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/LICENSE +0 -0
  15. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/setup.cfg +0 -0
  16. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/setup.py +0 -0
  17. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/common.py +0 -0
  18. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/debug.py +0 -0
  19. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/login.py +0 -0
  20. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/main.py +0 -0
  21. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/user.py +0 -0
  22. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/util.py +0 -0
  23. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/dependency_links.txt +0 -0
  24. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/entry_points.txt +0 -0
  25. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/top_level.txt +0 -0
  26. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/tests/test_cli.py +0 -0
  27. {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/tests/test_login.py +0 -0
  28. {earthscope_cli-1.1.2 → earthscope_cli-1.3.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.3.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.10.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.3.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.10.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.3.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.3.0"
@@ -1,8 +1,11 @@
1
1
  import threading
2
2
  from typing import Dict, List, Optional
3
3
 
4
- from earthscope_sdk.client.dropoff._multipart_uploader import UploadStatus
5
- from earthscope_sdk.client.dropoff.models import DropoffObject, DropoffSummary
4
+ from earthscope_sdk.client.dropoff.models import (
5
+ DropoffObject,
6
+ DropoffSummary,
7
+ UploadStatus,
8
+ )
6
9
  from rich import print
7
10
  from rich.console import Group
8
11
  from rich.live import Live
@@ -40,17 +43,34 @@ def _colorize_status(status: str) -> Text:
40
43
  return Text("ACCEPTED", style="green")
41
44
  if s == "FAILED":
42
45
  return Text("FAILED", style="red")
46
+ if s == "COPYING":
47
+ return Text("COPYING", style="yellow")
48
+ if s == "LOADING":
49
+ return Text("LOADING", style="yellow")
43
50
  if s == "INTERNAL_ERROR":
44
51
  return Text("INTERNAL_ERROR", style="red")
45
52
  return Text(f"{status or '-'}", style="dim")
46
53
 
47
54
 
55
+ def _format_details(details: List[str], is_truncated: bool) -> Text:
56
+ """Format structured_messages (with truncation indicator) or fall back to status_message."""
57
+ if details:
58
+ content = "\n".join(details)
59
+ result = Text(content)
60
+ if is_truncated:
61
+ result.append("\n(truncated)", style="dim")
62
+ return result
63
+
64
+ return Text("-", style="dim")
65
+
66
+
48
67
  def print_dropoff_objects_table(
49
68
  objects: List[DropoffObject],
50
69
  *,
51
70
  title: str,
52
71
  has_next: bool,
53
72
  next_offset: int,
73
+ show_details: bool = False,
54
74
  ):
55
75
  if has_next:
56
76
  caption = f"Use --offset {next_offset} to view more results."
@@ -69,16 +89,28 @@ def print_dropoff_objects_table(
69
89
  table.add_column("Last Modified", justify="right")
70
90
  table.add_column("Status", justify="center")
71
91
  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
-
92
+ if show_details:
93
+ table.add_column("Details")
94
+ for o in objects:
95
+ table.add_row(
96
+ safe_str(o.path),
97
+ filesize.decimal(o.size),
98
+ safe_str(o.received_at),
99
+ _colorize_status(o.status),
100
+ safe_str(o.status_message),
101
+ _format_details(
102
+ o.structured_messages, o.structured_messages_were_truncated
103
+ ),
104
+ )
105
+ else:
106
+ for o in objects:
107
+ table.add_row(
108
+ safe_str(o.path),
109
+ filesize.decimal(o.size),
110
+ safe_str(o.received_at),
111
+ _colorize_status(o.status),
112
+ safe_str(o.status_message),
113
+ )
82
114
  print()
83
115
  print(table)
84
116
  print()
@@ -236,10 +268,10 @@ class ConcurrentUploadProgressDisplay:
236
268
  hidden to keep the display clean and focused on active uploads.
237
269
 
238
270
  Usage:
239
- display = ConcurrentUploadProgressDisplay(num_files=10)
271
+ display = ConcurrentUploadProgressDisplay(num_collected=10)
240
272
  with display:
241
- sdk.dropoff.put_dropoff_files(
242
- files=file_list,
273
+ sdk.dropoff.put_objects(
274
+ objects=validated_files_generator(),
243
275
  category="miniseed",
244
276
  progress_cb=display.callback,
245
277
  )
@@ -247,7 +279,7 @@ class ConcurrentUploadProgressDisplay:
247
279
 
248
280
  def __init__(
249
281
  self,
250
- num_files: int,
282
+ num_collected: int,
251
283
  hide_completed_threshold: int = 10,
252
284
  hide_delay_seconds: float = 1.5,
253
285
  ):
@@ -255,17 +287,29 @@ class ConcurrentUploadProgressDisplay:
255
287
  Initialize the progress display.
256
288
 
257
289
  Args:
258
- num_files: Total number of files to upload
290
+ num_collected: Total number of files collected (to be validated then uploaded)
259
291
  hide_completed_threshold: Threshold for hiding completed progress bars
260
292
  hide_delay_seconds: Delay in seconds before hiding completed progress bars
261
293
  """
262
294
  self.hide_completed_threshold = hide_completed_threshold
263
295
  self.hide_delay_seconds = hide_delay_seconds
264
- self.num_files = num_files
296
+ self.num_collected = num_collected
297
+ self.num_valid = 0
298
+ self.num_invalid = 0
299
+ self.validation_complete = False
265
300
  self.files_completed = 0
266
301
  self.upload_started = False # Track if we've received any bytes yet
267
302
 
268
- # Overall progress (files completed, plus aggregate throughput)
303
+ # Validation progress
304
+ self.validation_progress = Progress(
305
+ TextColumn("[bold blue]{task.description}"),
306
+ BarColumn(),
307
+ MofNCompleteColumn(),
308
+ TextColumn("•"),
309
+ TimeElapsedColumn(),
310
+ )
311
+
312
+ # Overall upload progress (files completed, plus aggregate throughput)
269
313
  self.overall_progress = Progress(
270
314
  TextColumn("[bold blue]{task.description}"),
271
315
  BarColumn(),
@@ -291,6 +335,7 @@ class ConcurrentUploadProgressDisplay:
291
335
  self.file_tasks: Dict[str, TaskID] = {}
292
336
  self.file_bytes_done: Dict[str, int] = {} # Track current bytes_done per file
293
337
  self.file_bytes_resumed: Dict[str, int] = {} # Track bytes resumed per file
338
+ self.validation_task: Optional[TaskID] = None
294
339
  self.overall_task: Optional[TaskID] = None
295
340
  self.completed_files: set[str] = set()
296
341
  self.live: Optional[Live] = None
@@ -298,12 +343,17 @@ class ConcurrentUploadProgressDisplay:
298
343
 
299
344
  def __enter__(self):
300
345
  """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
346
+ # Validation progress task
347
+ self.validation_task = self.validation_progress.add_task(
348
+ "[bold]Validating...",
349
+ total=self.num_collected,
350
+ )
351
+
352
+ # Upload progress task — total starts at num_collected, decremented as files fail validation
353
+ # Don't start the timer yet — wait for first byte update
304
354
  self.overall_task = self.overall_progress.add_task(
305
- f"[bold]Uploading {self.num_files} files...",
306
- total=self.num_files,
355
+ "[bold]Uploading...",
356
+ total=self.num_collected,
307
357
  visual_bytes=0,
308
358
  throughput_bytes=0,
309
359
  start=False,
@@ -311,6 +361,7 @@ class ConcurrentUploadProgressDisplay:
311
361
 
312
362
  # Create progress group
313
363
  progress_group = Group(
364
+ self.validation_progress,
314
365
  self.overall_progress,
315
366
  self.file_progress,
316
367
  )
@@ -329,10 +380,14 @@ class ConcurrentUploadProgressDisplay:
329
380
 
330
381
  if self.live:
331
382
  # Update final status
332
- if exc_type is None and self.overall_task is not None:
383
+ if (
384
+ exc_type is None
385
+ and self.overall_task is not None
386
+ and self.num_valid > 0
387
+ ):
333
388
  self.overall_progress.update(
334
389
  self.overall_task,
335
- description=f"[bold green]✓ Completed {self.num_files} uploads",
390
+ description=f"[bold green]✓ Completed {self.num_valid} uploads",
336
391
  )
337
392
 
338
393
  # Hide all individual file progress bars when complete
@@ -343,6 +398,62 @@ class ConcurrentUploadProgressDisplay:
343
398
  self.live.__exit__(exc_type, exc_val, exc_tb)
344
399
  return False
345
400
 
401
+ def validation_passed(self):
402
+ """Called when a file passes validation and will be uploaded."""
403
+ self.num_valid += 1
404
+ if self.validation_task is not None:
405
+ self.validation_progress.advance(self.validation_task, 1)
406
+
407
+ def validation_failed(self):
408
+ """Called when a file fails validation and will not be uploaded."""
409
+ self.num_invalid += 1
410
+ if self.validation_task is not None:
411
+ self.validation_progress.advance(self.validation_task, 1)
412
+ self.validation_progress.update(
413
+ self.validation_task,
414
+ description=f"[bold blue]Validating... [dim]({self.num_invalid} failed)",
415
+ )
416
+ # Decrement upload total since this file won't be uploaded
417
+ if self.overall_task is not None:
418
+ self.overall_progress.update(
419
+ self.overall_task,
420
+ total=self.num_collected - self.num_invalid,
421
+ )
422
+
423
+ def finalize_validation(self):
424
+ """Called when all validation is complete."""
425
+ self.validation_complete = True
426
+ if self.validation_task is not None:
427
+ self.validation_progress.stop_task(self.validation_task)
428
+ if self.num_invalid == 0:
429
+ self.validation_progress.update(
430
+ self.validation_task,
431
+ description=f"[bold green]✓ Validated {self.num_valid} files",
432
+ )
433
+ elif self.num_valid > 0:
434
+ self.validation_progress.update(
435
+ self.validation_task,
436
+ description=f"[bold yellow]✓ Validated {self.num_valid}/{self.num_collected} files ({self.num_invalid} failed)",
437
+ )
438
+ else:
439
+ self.validation_progress.update(
440
+ self.validation_task,
441
+ description=f"[bold red]✗ Validation failed for all {self.num_invalid} files",
442
+ )
443
+ # Update upload description with final count
444
+ if self.overall_task is not None and self.num_valid > 0:
445
+ self.overall_progress.update(
446
+ self.overall_task,
447
+ description=f"[bold]Uploading {self.num_valid} files...",
448
+ )
449
+ # Check if all uploads already completed
450
+ if (
451
+ self.overall_task is not None
452
+ and self.num_valid > 0
453
+ and len(self.completed_files) == self.num_valid
454
+ ):
455
+ self.overall_progress.stop_task(self.overall_task)
456
+
346
457
  def _hide_completed_task(self, task_id: TaskID, key: str):
347
458
  """
348
459
  Hide a completed task from the display.
@@ -426,11 +537,14 @@ class ConcurrentUploadProgressDisplay:
426
537
  self.overall_progress.advance(self.overall_task, 1)
427
538
 
428
539
  # If all files are complete, stop the timer to freeze throughput
429
- if len(self.completed_files) == self.num_files:
540
+ if (
541
+ self.validation_complete
542
+ and len(self.completed_files) == self.num_valid
543
+ ):
430
544
  self.overall_progress.stop_task(self.overall_task)
431
545
 
432
546
  # For large uploads, hide completed progress bars after a delay
433
- if self.num_files > self.hide_completed_threshold:
547
+ if self.num_collected > self.hide_completed_threshold:
434
548
  # Schedule hiding the task after a delay
435
549
  timer = threading.Timer(
436
550
  self.hide_delay_seconds,
@@ -1,25 +1,17 @@
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
7
- from earthscope_sdk.client.dropoff.models import DropoffCategory
8
- from earthscope_sdk.client.dropoff.validation import Validator
7
+ from earthscope_sdk.client.dropoff.enums import DropoffCategory
9
8
  from rich import print
10
9
  from typer_di import Depends, TyperDI
11
10
 
12
11
  from earthscope_cli.common import get_sdk_refreshed
13
- from earthscope_cli.dropoff.render import (
14
- ConcurrentUploadProgressDisplay,
15
- UploadSummary,
16
- print_dropoff_objects_table,
17
- print_dropoff_summary,
18
- )
19
12
  from earthscope_cli.dropoff.util import (
20
13
  DropoffOptions,
21
14
  FilterOptions,
22
- collect_files,
23
15
  get_category,
24
16
  get_dropoff_options,
25
17
  get_filter_options,
@@ -52,6 +44,9 @@ def get_object_history(
52
44
  es dropoff get-object-history -c miniseed -k demo/SAML.IU.2025.303
53
45
  es dropoff get-object-history -c miniseed -k demo/SAML.IU.2025.303 --offset 100 --limit 100
54
46
  """
47
+ # lazy import
48
+ from earthscope_cli.dropoff.render import print_dropoff_objects_table
49
+
55
50
  result = sdk.dropoff.get_object_history(
56
51
  category=options.category.value,
57
52
  key=key,
@@ -65,6 +60,7 @@ def get_object_history(
65
60
  title=f"History for: {key}",
66
61
  has_next=result.has_next,
67
62
  next_offset=result.offset + result.limit,
63
+ show_details=True,
68
64
  )
69
65
 
70
66
  cat_str = options.category.value
@@ -89,6 +85,9 @@ def get_summary(
89
85
  es dropoff get-summary -c miniseed --prefix demo/ --submitted-after 2025-01-01T00:00:00Z
90
86
  es dropoff get-summary -c miniseed --prefix demo/ --submitted-before 2025-12-31T23:59:59Z
91
87
  """
88
+ # lazy import
89
+ from earthscope_cli.dropoff.render import print_dropoff_summary
90
+
92
91
  result = sdk.dropoff.get_summary(
93
92
  category=options.category.value,
94
93
  prefix=prefix,
@@ -119,6 +118,9 @@ def list_objects(
119
118
  es dropoff list-objects -c miniseed --submitted-after 2025-01-01T00:00:00Z
120
119
  es dropoff list-objects -c miniseed --submitted-before 2025-12-31T23:59:59Z
121
120
  """
121
+ # lazy import
122
+ from earthscope_cli.dropoff.render import print_dropoff_objects_table
123
+
122
124
  result = sdk.dropoff.list_objects(
123
125
  category=options.category.value,
124
126
  prefix=prefix,
@@ -147,26 +149,53 @@ def list_objects(
147
149
  )
148
150
 
149
151
 
152
+ def destinaton_callback(value: str) -> str:
153
+ if not value:
154
+ print("[red]Destination cannot be empty.")
155
+ raise typer.Exit(code=2)
156
+ return value.removeprefix("/")
157
+
158
+
150
159
  @app.command(name="upload")
151
160
  def upload(
152
161
  sdk: Annotated[EarthScopeClient, Depends(get_sdk_refreshed)],
153
162
  category: Annotated[DropoffCategory, Depends(get_category)],
154
- paths: Annotated[
163
+ source: Annotated[
155
164
  list[Path],
156
- typer.Argument(
165
+ typer.Option(
166
+ "--source",
167
+ "-s",
157
168
  exists=True,
158
169
  file_okay=True,
159
170
  dir_okay=True,
160
171
  resolve_path=True,
172
+ help="One or more local files or directories to upload.",
173
+ ),
174
+ ],
175
+ destination: Annotated[
176
+ str,
177
+ typer.Option(
178
+ "--destination",
179
+ "-d",
161
180
  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/
181
+ """\
182
+ Remote object key or prefix. A trailing '/' indicates a prefix
183
+ (source filename is appended); without a trailing '/' the
184
+ destination is used as the exact object key name. All keys are
185
+ relative to your dropoff home path.
166
186
  """
167
187
  ),
188
+ callback=destinaton_callback,
168
189
  ),
169
190
  ],
191
+ recursive: Annotated[
192
+ bool,
193
+ typer.Option(
194
+ "--recursive",
195
+ "-r",
196
+ help="Recursively traverse source directories.",
197
+ ),
198
+ ] = False,
170
199
  object_concurrency: Annotated[
171
200
  int,
172
201
  typer.Option(
@@ -192,53 +221,88 @@ def upload(
192
221
  """
193
222
  Upload files or directories to EarthScope's dropoff system.
194
223
 
224
+ The source is one or more local files or directories. The destination
225
+ specifies the remote object key or prefix.
226
+
227
+ A destination with a trailing '/' is treated as a prefix -- the source
228
+ filename is appended. Without a trailing '/', the destination is the
229
+ exact object key name.
230
+
231
+ When the source is a directory or multiple files, the destination must
232
+ be a prefix (trailing '/').
233
+
234
+ Use -r to recursively include files in subdirectories.
235
+
195
236
  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
237
 
200
- How S3 keys are generated:
238
+ Single file to exact key:
239
+ es dropoff upload -c miniseed -d dest/keyname -s file.mseed
201
240
 
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'
241
+ Single file to prefix:
242
+ es dropoff upload -c miniseed -d dest/prefix/ -s file.mseed
205
243
 
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'
244
+ Directory (top-level files only):
245
+ es dropoff upload -c miniseed -d dest/prefix/ -s mydata/
210
246
 
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'
247
+ Directory (recursive):
248
+ es dropoff upload -c miniseed -d dest/prefix/ -r -s mydata/
214
249
 
215
- This behavior ensures files with the same name from different locations
216
- don't overwrite each other and maintains meaningful organization in S3.
250
+ Multiple files:
251
+ es dropoff upload -c miniseed -d dest/prefix/ -s file1.mseed -s file2.mseed
217
252
  """
218
- # Parse the input path to get a list of files
219
- total_collected = 0
253
+ # lazy import
254
+ from earthscope_sdk.client.dropoff.files import (
255
+ UploadCandidate,
256
+ collect_files,
257
+ validate_files,
258
+ )
259
+
260
+ from earthscope_cli.dropoff.render import (
261
+ ConcurrentUploadProgressDisplay,
262
+ UploadSummary,
263
+ )
264
+
265
+ is_prefix = destination.endswith("/")
266
+
267
+ if not is_prefix and (len(source) > 1 or any(s.is_dir() for s in source)):
268
+ print(
269
+ "[red]Multiple sources require destination to be a prefix (trailing '/')."
270
+ )
271
+ raise typer.Exit(code=2)
272
+
273
+ # Collect all files first (fast filesystem traversal)
274
+ collected = list(collect_files(source, destination, recursive))
275
+ total_collected = len(collected)
220
276
  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}")
277
+
278
+ if not collected:
279
+ src_str = ", ".join(str(s) for s in source)
280
+ print(f"[red]No valid {category.value} files found in sources: {src_str}")
234
281
  UploadSummary(total=total_collected, succeeded=succeeded).print()
235
282
  raise typer.Exit(code=1)
236
283
 
237
- display = ConcurrentUploadProgressDisplay(len(files))
284
+ # Validation runs inline in the generator. The SDK advances this iterator
285
+ # via asyncio.to_thread, so validation runs off the event loop and uploads
286
+ # can proceed concurrently with validation of remaining files.
287
+ validation_errors: list[str] = []
288
+ display = ConcurrentUploadProgressDisplay(num_collected=total_collected)
289
+
290
+ def _validated_files() -> Iterable[UploadCandidate]:
291
+ try:
292
+ for item in validate_files(collected, category):
293
+ if item.ok:
294
+ display.validation_passed()
295
+ yield item
296
+ else:
297
+ validation_errors.append(str(item.error))
298
+ display.validation_failed()
299
+ finally:
300
+ display.finalize_validation()
301
+
238
302
  try:
239
303
  with display:
240
304
  sdk.dropoff.put_objects(
241
- objects=files,
305
+ objects=_validated_files(),
242
306
  category=category,
243
307
  object_concurrency=object_concurrency,
244
308
  part_concurrency=part_concurrency,
@@ -249,5 +313,12 @@ def upload(
249
313
  print()
250
314
  print("[yellow]Upload interrupted. Use the same command to resume the upload.")
251
315
  finally:
316
+ for error in validation_errors:
317
+ print(f"[red]{error}")
252
318
  succeeded = len(display.completed_files)
253
319
  UploadSummary(total=total_collected, succeeded=succeeded).print()
320
+
321
+ if validation_errors and len(validation_errors) == total_collected:
322
+ paths_str = ", ".join(str(p) for p in source)
323
+ print(f"[red]No valid {category.value} files found in paths: {paths_str}")
324
+ raise typer.Exit(code=1)