earthscope-cli 1.1.1__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 (29) hide show
  1. {earthscope_cli-1.1.1/src/earthscope_cli.egg-info → earthscope_cli-1.2.0}/PKG-INFO +9 -6
  2. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/README.md +1 -1
  3. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/pyproject.toml +9 -6
  4. earthscope_cli-1.2.0/src/earthscope_cli/__init__.py +1 -0
  5. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli/common.py +1 -4
  6. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli/debug.py +1 -5
  7. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli/dropoff/render.py +167 -25
  8. earthscope_cli-1.2.0/src/earthscope_cli/dropoff/typer.py +311 -0
  9. earthscope_cli-1.2.0/src/earthscope_cli/dropoff/util.py +210 -0
  10. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli/login.py +1 -4
  11. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli/main.py +1 -4
  12. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli/user.py +1 -5
  13. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0/src/earthscope_cli.egg-info}/PKG-INFO +9 -6
  14. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/requires.txt +3 -2
  15. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/tests/test_dropoff.py +623 -307
  16. earthscope_cli-1.1.1/src/earthscope_cli/__init__.py +0 -1
  17. earthscope_cli-1.1.1/src/earthscope_cli/dropoff/typer.py +0 -205
  18. earthscope_cli-1.1.1/src/earthscope_cli/dropoff/util.py +0 -152
  19. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/LICENSE +0 -0
  20. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/setup.cfg +0 -0
  21. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/setup.py +0 -0
  22. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli/util.py +0 -0
  23. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/SOURCES.txt +0 -0
  24. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/dependency_links.txt +0 -0
  25. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/entry_points.txt +0 -0
  26. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/src/earthscope_cli.egg-info/top_level.txt +0 -0
  27. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/tests/test_cli.py +0 -0
  28. {earthscope_cli-1.1.1 → earthscope_cli-1.2.0}/tests/test_login.py +0 -0
  29. {earthscope_cli-1.1.1 → 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.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://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
- Requires-Dist: typer-slim<0.24.0,>=0.17.3
16
+ Requires-Dist: typer<0.24.0
15
17
  Requires-Dist: typer-di>=0.1.3
16
- Requires-Dist: earthscope-sdk>=1.3.0
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.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" }]
@@ -18,18 +18,19 @@ classifiers = [
18
18
 
19
19
  #Suggestion not to pin dependencies since the package should work in many different python environments
20
20
  dependencies = [
21
- "typer-slim>=0.17.3,<0.24.0",
21
+ "typer<0.24.0",
22
22
  "typer-di>=0.1.3",
23
- "earthscope-sdk>=1.3.0",
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.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"
@@ -1,12 +1,9 @@
1
- from contextlib import suppress
2
1
  from typing import Annotated, Optional
3
2
 
4
3
  import typer
4
+ from rich import print
5
5
  from typer_di import Depends
6
6
 
7
- with suppress(ModuleNotFoundError):
8
- from rich import print
9
-
10
7
  GLOBAL_HELP_PANEL = "Global Options"
11
8
 
12
9
 
@@ -1,13 +1,9 @@
1
- from contextlib import suppress
2
-
3
1
  import typer
2
+ from rich import print
4
3
  from typer_di import Depends, TyperDI
5
4
 
6
5
  from earthscope_cli.common import get_sdk
7
6
 
8
- with suppress(ModuleNotFoundError):
9
- from rich import print
10
-
11
7
  app = TyperDI()
12
8
 
13
9
 
@@ -2,7 +2,7 @@ import threading
2
2
  from typing import Dict, List, Optional
3
3
 
4
4
  from earthscope_sdk.client.dropoff._multipart_uploader import UploadStatus
5
- from earthscope_sdk.client.dropoff.models import DropoffObject
5
+ from earthscope_sdk.client.dropoff.models import DropoffObject, DropoffSummary
6
6
  from rich import print
7
7
  from rich.console import Group
8
8
  from rich.live import Live
@@ -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,15 +82,52 @@ 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")
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
+ )
107
+ print()
108
+ print(table)
109
+ print()
72
110
 
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
- )
111
+
112
+ def print_dropoff_summary(summary: DropoffSummary, *, prefix: Optional[str] = None):
113
+ if not summary.status_counts:
114
+ print(f"[yellow]No objects found in prefix '{prefix}'")
115
+ return
116
+
117
+ title = "Dropoff Summary"
118
+ if prefix:
119
+ title += f" for {prefix}"
120
+
121
+ table = Table(
122
+ title=title,
123
+ show_header=True,
124
+ title_style="bold magenta",
125
+ header_style="bold magenta",
126
+ )
127
+ table.add_column("Status")
128
+ table.add_column("Count")
129
+ for status in summary.status_counts:
130
+ table.add_row(status.status, str(status.count))
81
131
 
82
132
  print()
83
133
  print(table)
@@ -181,6 +231,23 @@ class CompositeProgressBarColumn(ProgressColumn):
181
231
  return result
182
232
 
183
233
 
234
+ class UploadSummary:
235
+ def __init__(self, total: int, succeeded: int):
236
+ self.total = total
237
+ self.succeeded = succeeded
238
+ self.failed = total - succeeded
239
+
240
+ def print(self):
241
+ color = (
242
+ "green"
243
+ if self.failed == 0
244
+ else ("red" if self.succeeded == 0 else "yellow")
245
+ )
246
+ print(
247
+ f"[{color}]Summary: {self.succeeded}/{self.total} files succeeded, {self.failed} failed"
248
+ )
249
+
250
+
184
251
  class ConcurrentUploadProgressDisplay:
185
252
  """
186
253
  Rich progress display for concurrent file uploads.
@@ -194,10 +261,10 @@ class ConcurrentUploadProgressDisplay:
194
261
  hidden to keep the display clean and focused on active uploads.
195
262
 
196
263
  Usage:
197
- display = ConcurrentUploadProgressDisplay(num_files=10)
264
+ display = ConcurrentUploadProgressDisplay(num_collected=10)
198
265
  with display:
199
- sdk.dropoff.put_dropoff_files(
200
- files=file_list,
266
+ sdk.dropoff.put_objects(
267
+ objects=validated_files_generator(),
201
268
  category="miniseed",
202
269
  progress_cb=display.callback,
203
270
  )
@@ -205,7 +272,7 @@ class ConcurrentUploadProgressDisplay:
205
272
 
206
273
  def __init__(
207
274
  self,
208
- num_files: int,
275
+ num_collected: int,
209
276
  hide_completed_threshold: int = 10,
210
277
  hide_delay_seconds: float = 1.5,
211
278
  ):
@@ -213,17 +280,29 @@ class ConcurrentUploadProgressDisplay:
213
280
  Initialize the progress display.
214
281
 
215
282
  Args:
216
- num_files: Total number of files to upload
283
+ num_collected: Total number of files collected (to be validated then uploaded)
217
284
  hide_completed_threshold: Threshold for hiding completed progress bars
218
285
  hide_delay_seconds: Delay in seconds before hiding completed progress bars
219
286
  """
220
287
  self.hide_completed_threshold = hide_completed_threshold
221
288
  self.hide_delay_seconds = hide_delay_seconds
222
- 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
223
293
  self.files_completed = 0
224
294
  self.upload_started = False # Track if we've received any bytes yet
225
295
 
226
- # 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)
227
306
  self.overall_progress = Progress(
228
307
  TextColumn("[bold blue]{task.description}"),
229
308
  BarColumn(),
@@ -249,6 +328,7 @@ class ConcurrentUploadProgressDisplay:
249
328
  self.file_tasks: Dict[str, TaskID] = {}
250
329
  self.file_bytes_done: Dict[str, int] = {} # Track current bytes_done per file
251
330
  self.file_bytes_resumed: Dict[str, int] = {} # Track bytes resumed per file
331
+ self.validation_task: Optional[TaskID] = None
252
332
  self.overall_task: Optional[TaskID] = None
253
333
  self.completed_files: set[str] = set()
254
334
  self.live: Optional[Live] = None
@@ -256,12 +336,17 @@ class ConcurrentUploadProgressDisplay:
256
336
 
257
337
  def __enter__(self):
258
338
  """Start the live progress display."""
259
- # Create overall progress task tracking files completed
260
- # (visual_bytes shows total progress, throughput_bytes for speed calculation)
261
- # 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
262
347
  self.overall_task = self.overall_progress.add_task(
263
- f"[bold]Uploading {self.num_files} files...",
264
- total=self.num_files,
348
+ "[bold]Uploading...",
349
+ total=self.num_collected,
265
350
  visual_bytes=0,
266
351
  throughput_bytes=0,
267
352
  start=False,
@@ -269,6 +354,7 @@ class ConcurrentUploadProgressDisplay:
269
354
 
270
355
  # Create progress group
271
356
  progress_group = Group(
357
+ self.validation_progress,
272
358
  self.overall_progress,
273
359
  self.file_progress,
274
360
  )
@@ -287,10 +373,10 @@ class ConcurrentUploadProgressDisplay:
287
373
 
288
374
  if self.live:
289
375
  # Update final status
290
- 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:
291
377
  self.overall_progress.update(
292
378
  self.overall_task,
293
- description=f"[bold green]✓ Completed {self.num_files} uploads",
379
+ description=f"[bold green]✓ Completed {self.num_valid} uploads",
294
380
  )
295
381
 
296
382
  # Hide all individual file progress bars when complete
@@ -301,6 +387,62 @@ class ConcurrentUploadProgressDisplay:
301
387
  self.live.__exit__(exc_type, exc_val, exc_tb)
302
388
  return False
303
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
+
304
446
  def _hide_completed_task(self, task_id: TaskID, key: str):
305
447
  """
306
448
  Hide a completed task from the display.
@@ -384,11 +526,11 @@ class ConcurrentUploadProgressDisplay:
384
526
  self.overall_progress.advance(self.overall_task, 1)
385
527
 
386
528
  # If all files are complete, stop the timer to freeze throughput
387
- if len(self.completed_files) == self.num_files:
529
+ if self.validation_complete and len(self.completed_files) == self.num_valid:
388
530
  self.overall_progress.stop_task(self.overall_task)
389
531
 
390
532
  # For large uploads, hide completed progress bars after a delay
391
- if self.num_files > self.hide_completed_threshold:
533
+ if self.num_collected > self.hide_completed_threshold:
392
534
  # Schedule hiding the task after a delay
393
535
  timer = threading.Timer(
394
536
  self.hide_delay_seconds,