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.
- {earthscope_cli-1.1.2/src/earthscope_cli.egg-info → earthscope_cli-1.3.0}/PKG-INFO +8 -5
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/README.md +1 -1
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/pyproject.toml +8 -5
- earthscope_cli-1.3.0/src/earthscope_cli/__init__.py +1 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/dropoff/render.py +142 -28
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/dropoff/typer.py +120 -49
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/dropoff/util.py +2 -61
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0/src/earthscope_cli.egg-info}/PKG-INFO +8 -5
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/SOURCES.txt +1 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/requires.txt +2 -1
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/tests/test_dropoff.py +308 -401
- earthscope_cli-1.3.0/tests/test_startup_imports.py +88 -0
- earthscope_cli-1.1.2/src/earthscope_cli/__init__.py +0 -1
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/LICENSE +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/setup.cfg +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/setup.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/common.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/debug.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/login.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/main.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/user.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli/util.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/dependency_links.txt +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/entry_points.txt +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/src/earthscope_cli.egg-info/top_level.txt +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/tests/test_cli.py +0 -0
- {earthscope_cli-1.1.2 → earthscope_cli-1.3.0}/tests/test_login.py +0 -0
- {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.
|
|
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://
|
|
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.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.
|
|
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.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.
|
|
23
|
+
"earthscope-sdk>=1.10.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.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.
|
|
5
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
271
|
+
display = ConcurrentUploadProgressDisplay(num_collected=10)
|
|
240
272
|
with display:
|
|
241
|
-
sdk.dropoff.
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
total=self.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
163
|
+
source: Annotated[
|
|
155
164
|
list[Path],
|
|
156
|
-
typer.
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
238
|
+
Single file to exact key:
|
|
239
|
+
es dropoff upload -c miniseed -d dest/keyname -s file.mseed
|
|
201
240
|
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
250
|
+
Multiple files:
|
|
251
|
+
es dropoff upload -c miniseed -d dest/prefix/ -s file1.mseed -s file2.mseed
|
|
217
252
|
"""
|
|
218
|
-
#
|
|
219
|
-
|
|
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
|
-
|
|
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}")
|
|
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
|
-
|
|
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=
|
|
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)
|