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.
- {earthscope_cli-1.1.2/src/earthscope_cli.egg-info → earthscope_cli-1.2.0}/PKG-INFO +8 -5
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/README.md +1 -1
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/pyproject.toml +8 -5
- earthscope_cli-1.2.0/src/earthscope_cli/__init__.py +1 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/dropoff/render.py +126 -26
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/dropoff/typer.py +98 -40
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/dropoff/util.py +54 -45
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0/src/earthscope_cli.egg-info}/PKG-INFO +8 -5
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/requires.txt +2 -1
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/tests/test_dropoff.py +457 -304
- earthscope_cli-1.1.2/src/earthscope_cli/__init__.py +0 -1
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/LICENSE +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/setup.cfg +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/setup.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/common.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/debug.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/login.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/main.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/user.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli/util.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/SOURCES.txt +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/dependency_links.txt +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/entry_points.txt +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/top_level.txt +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/tests/test_cli.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.2.0}/tests/test_login.py +0 -0
- {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.
|
|
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://
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
23
|
+
"earthscope-sdk>=1.4.0",
|
|
24
24
|
"rich>=13.8.0",
|
|
25
25
|
]
|
|
26
|
-
requires-python = ">=3.
|
|
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://
|
|
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.
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
264
|
+
display = ConcurrentUploadProgressDisplay(num_collected=10)
|
|
240
265
|
with display:
|
|
241
|
-
sdk.dropoff.
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
total=self.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
162
|
+
source: Annotated[
|
|
155
163
|
list[Path],
|
|
156
|
-
typer.
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
237
|
+
Single file to exact key:
|
|
238
|
+
es dropoff upload -c miniseed -d dest/keyname -s file.mseed
|
|
201
239
|
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
249
|
+
Multiple files:
|
|
250
|
+
es dropoff upload -c miniseed -d dest/prefix/ -s file1.mseed -s file2.mseed
|
|
217
251
|
"""
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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=
|
|
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)
|