PyGeoFetch 1.0.5__tar.gz → 1.0.7__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 (82) hide show
  1. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/PKG-INFO +1 -1
  2. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/__init__.py +2 -2
  3. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/main.py +2 -2
  4. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/core/downloader.py +44 -27
  5. pygeofetch-1.0.7/pygeofetch/core/logging.py +495 -0
  6. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/core/searcher.py +19 -8
  7. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/models/satellite_data.py +50 -26
  8. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/aws_earth.py +3 -2
  9. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/copernicus.py +14 -2
  10. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/sentinel_hub.py +1 -1
  11. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch.egg-info/PKG-INFO +1 -1
  12. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pyproject.toml +1 -1
  13. pygeofetch-1.0.5/pygeofetch/core/logging.py +0 -199
  14. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/LICENSE +0 -0
  15. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/README.md +0 -0
  16. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/__init__.py +0 -0
  17. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/auth_commands.py +0 -0
  18. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/config_commands.py +0 -0
  19. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/download_commands.py +0 -0
  20. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/index_commands.py +0 -0
  21. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  22. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/postprocess_commands.py +0 -0
  23. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/preprocess_commands.py +0 -0
  24. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/sar_commands.py +0 -0
  25. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/cli/search_commands.py +0 -0
  26. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/config/__init__.py +0 -0
  27. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/config/defaults.yaml +0 -0
  28. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/config/settings.py +0 -0
  29. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/core/__init__.py +0 -0
  30. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/core/authenticator.py +0 -0
  31. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/core/cache_manager.py +0 -0
  32. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/core/engine.py +0 -0
  33. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/core/orbits.py +0 -0
  34. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/core/scheduler.py +0 -0
  35. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/models/__init__.py +0 -0
  36. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/models/download_task.py +0 -0
  37. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/models/search_query.py +0 -0
  38. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/models/user_auth.py +0 -0
  39. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/processing/__init__.py +0 -0
  40. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/processing/base.py +0 -0
  41. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/processing/batch.py +0 -0
  42. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/processing/indices.py +0 -0
  43. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/processing/pipeline.py +0 -0
  44. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/processing/postprocessor.py +0 -0
  45. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/processing/preprocessor.py +0 -0
  46. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/processing/sar.py +0 -0
  47. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/__init__.py +0 -0
  48. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/airbus_oneatlas.py +0 -0
  49. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
  50. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/base.py +0 -0
  51. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/digitalglobe.py +0 -0
  52. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/earth_explorer_additional.py +0 -0
  53. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/element84.py +0 -0
  54. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/esa_scihub.py +0 -0
  55. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/geoserver_generic.py +0 -0
  56. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/google_earth_engine.py +0 -0
  57. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/inpe_cbers.py +0 -0
  58. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/isro_bhuvan.py +0 -0
  59. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/jaxa_earth.py +0 -0
  60. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/maxar_gbdx.py +0 -0
  61. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/nasa_earthdata.py +0 -0
  62. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  63. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/noaa_big_data.py +0 -0
  64. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/opentopography.py +0 -0
  65. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/planet.py +0 -0
  66. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/planetary_computer.py +0 -0
  67. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/terrabotics.py +0 -0
  68. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/providers/usgs.py +0 -0
  69. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/utils/__init__.py +0 -0
  70. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/utils/file_utils.py +0 -0
  71. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/utils/geo_utils.py +0 -0
  72. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/utils/logging_setup.py +0 -0
  73. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/utils/retry_handler.py +0 -0
  74. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch/utils/validators.py +0 -0
  75. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch.egg-info/SOURCES.txt +0 -0
  76. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch.egg-info/dependency_links.txt +0 -0
  77. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch.egg-info/entry_points.txt +0 -0
  78. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch.egg-info/not-zip-safe +0 -0
  79. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch.egg-info/requires.txt +0 -0
  80. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/pygeofetch.egg-info/top_level.txt +0 -0
  81. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/setup.cfg +0 -0
  82. {pygeofetch-1.0.5 → pygeofetch-1.0.7}/tests/test_pgv_integration_contract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 1.0.5
3
+ Version: 1.0.7
4
4
  Summary: Universal satellite data pipeline — unified access to 22+ providers
5
5
  Author: PyGeoFetch Contributors
6
6
  License: MIT License
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch v1.0.5 — Universal Satellite Data Pipeline.
2
+ PyGeoFetch v1.0.7 — Universal Satellite Data Pipeline.
3
3
 
4
4
  Unified access to 22+ satellite data providers with a consistent
5
5
  Python API and CLI. Supports authentication management, federated search,
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "1.0.5"
33
+ __version__ = "1.0.7"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch CLI v1.0.5 main entry point.
2
+ PyGeoFetch CLI v1.0.7 main entry point.
3
3
 
4
4
  Full command reference:
5
5
  pygeofetch auth — credential management
@@ -59,7 +59,7 @@ def cli(ctx: click.Context, log_level: str, log_file: str, log_format: str,
59
59
  config_file: str) -> None:
60
60
  """
61
61
  \b
62
- PyGeoFetch v1.0.5 — Universal Satellite Data Pipeline
62
+ PyGeoFetch v1.0.7 — Universal Satellite Data Pipeline
63
63
  ===========================================================
64
64
  Unified access to 22+ satellite data providers.
65
65
 
@@ -30,13 +30,14 @@ from uuid import uuid4
30
30
 
31
31
  from pygeofetch.models.download_task import (
32
32
  DownloadOptions,
33
- DownloadProgress,
33
+ DownloadProgress as _ModelDownloadProgress,
34
34
  DownloadResult,
35
35
  DownloadStatus,
36
36
  DownloadTask,
37
37
  )
38
38
  from pygeofetch.models.satellite_data import SatelliteData
39
- from pygeofetch.core.logging import get_logger, _render_progress_bar
39
+ from pygeofetch.core.logging import (get_logger, _render_progress_bar,
40
+ DownloadProgress)
40
41
 
41
42
  logger = get_logger(__name__)
42
43
 
@@ -91,7 +92,12 @@ class AdaptiveDownloader:
91
92
  destination.mkdir(parents=True, exist_ok=True)
92
93
 
93
94
  provider = self._get_provider(data.provider)
94
- logger.info(f"Downloading {data.id!r} from {data.provider!r} → {destination}")
95
+ _item_start = __import__("time").time()
96
+ _dl_progress = globals().get("_active_progress")
97
+ if _dl_progress:
98
+ _dl_progress.start_item(str(data.id))
99
+ else:
100
+ logger.info("Downloading %s", str(data.id)[:60])
95
101
 
96
102
  for attempt in range(options.retry_attempts + 1):
97
103
  try:
@@ -179,29 +185,43 @@ class AdaptiveDownloader:
179
185
  completed_count = 0
180
186
  total = len(data_list)
181
187
 
182
- logger.info("Downloading %d scene(s) to: %s", total, str(destination))
188
+ dp = DownloadProgress(total=total, destination=str(destination))
189
+ dp.__enter__()
190
+
191
+ def _run_one(data: SatelliteData, idx: int) -> tuple:
192
+ """Download one item and return (idx, result, bytes, duration)."""
193
+ import time as _it
194
+ t0 = _it.time()
195
+ dp.start_item(str(data.id))
196
+ try:
197
+ result = self.download(data, destination / data.provider, options)
198
+ dur = _it.time() - t0
199
+ dp.complete_item(
200
+ success=result.success,
201
+ bytes_total=result.bytes_downloaded or 0,
202
+ duration=dur,
203
+ )
204
+ return idx, result
205
+ except Exception as exc:
206
+ dur = _it.time() - t0
207
+ result = DownloadResult(
208
+ status=DownloadStatus.FAILED,
209
+ data_id=data.id,
210
+ provider=data.provider,
211
+ error=str(exc),
212
+ )
213
+ dp.complete_item(success=False, duration=dur)
214
+ return idx, result
183
215
 
184
216
  with ThreadPoolExecutor(max_workers=options.parallel) as executor:
185
- future_to_idx = {
186
- executor.submit(self.download, data, destination / data.provider, options): i
217
+ futures = {
218
+ executor.submit(_run_one, data, i): i
187
219
  for i, data in enumerate(data_list)
188
220
  }
189
- for future in as_completed(future_to_idx):
190
- idx = future_to_idx[future]
191
- try:
192
- result = future.result()
193
- results[idx] = result
194
- except Exception as exc:
195
- result = DownloadResult(
196
- status=DownloadStatus.FAILED,
197
- data_id=data_list[idx].id,
198
- provider=data_list[idx].provider,
199
- error=str(exc),
200
- )
201
- results[idx] = result
202
-
221
+ for future in as_completed(futures):
222
+ idx, result = future.result()
223
+ results[idx] = result
203
224
  completed_count += 1
204
- self._emit_progress(data_list, results, options)
205
225
  if item_done_callback:
206
226
  item_done_callback(completed_count, total, result)
207
227
 
@@ -218,6 +238,7 @@ class AdaptiveDownloader:
218
238
 
219
239
  completed = sum(1 for r in results if r and r.success)
220
240
  failed = sum(1 for r in results if r and not r.success)
241
+ dp.__exit__(None, None, None)
221
242
  assert len(results) == len(data_list), (
222
243
  f"BUG: result count {len(results)} != input count {len(data_list)}"
223
244
  )
@@ -225,11 +246,7 @@ class AdaptiveDownloader:
225
246
  r.bytes_downloaded / (1024*1024)
226
247
  for r in results if r and r.success and r.bytes_downloaded
227
248
  )
228
- import time as _t
229
- logger.info(
230
- "Download complete: %d/%d succeeded %.0f MB total",
231
- completed, len(data_list), total_mb
232
- )
249
+ # summary printed by DownloadProgress footer
233
250
  return results # type: ignore — all slots filled, never None
234
251
 
235
252
  def _get_provider(self, provider_id: str) -> Any:
@@ -673,4 +690,4 @@ class AdaptiveDownloader:
673
690
  @property
674
691
  def _result_total_size_mb(self) -> float:
675
692
  """Property used by result objects."""
676
- return 0.0
693
+ return 0.0
@@ -0,0 +1,495 @@
1
+ """
2
+ PyGeoFetch logging & display layer.
3
+
4
+ Usage::
5
+
6
+ from pygeofetch.core.logging import configure_logging, get_logger
7
+ from pygeofetch.core.logging import print_search_results, DownloadProgress
8
+
9
+ configure_logging(level="INFO")
10
+ logger = get_logger(__name__)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ import sys
18
+ import time
19
+ import threading
20
+ from typing import Optional, List, Any
21
+
22
+ # ── ANSI codes ────────────────────────────────────────────────────────────────
23
+
24
+ _C = {
25
+ "reset": "\033[0m",
26
+ "bold": "\033[1m",
27
+ "dim": "\033[2m",
28
+ "cyan": "\033[36m",
29
+ "green": "\033[32m",
30
+ "yellow": "\033[33m",
31
+ "red": "\033[31m",
32
+ "magenta": "\033[35m",
33
+ "blue": "\033[34m",
34
+ "white": "\033[97m",
35
+ "grey": "\033[90m",
36
+ "bg_blue": "\033[44m",
37
+ "bg_green":"\033[42m",
38
+ }
39
+
40
+ _TTY = sys.stdout.isatty()
41
+
42
+ def _c(code: str, text: str) -> str:
43
+ """Apply an ANSI code only when writing to a real terminal."""
44
+ if not _TTY:
45
+ return text
46
+ return f"{_C.get(code, '')}{text}{_C['reset']}"
47
+
48
+ def _cb(text: str) -> str:
49
+ return _c("bold", text)
50
+
51
+ def _cg(text: str) -> str:
52
+ return _c("green", text)
53
+
54
+ def _cy(text: str) -> str:
55
+ return _c("yellow", text)
56
+
57
+ def _cd(text: str) -> str:
58
+ return _c("dim", text)
59
+
60
+ def _cr(text: str) -> str:
61
+ return _c("red", text)
62
+
63
+ # ── Credential redaction ──────────────────────────────────────────────────────
64
+
65
+ _SENSITIVE_KEYS = frozenset({
66
+ "password", "passwd", "secret", "api_key", "apikey", "token",
67
+ "access_key", "secret_key", "client_secret", "credentials",
68
+ "authorization", "auth",
69
+ })
70
+
71
+ def _redact(msg: str) -> str:
72
+ for key in _SENSITIVE_KEYS:
73
+ msg = re.sub(
74
+ r"(?i)(" + re.escape(key) + r"[\s]*[=:][\s]*)[^\s,}\]\n\"']+",
75
+ r"\1***REDACTED***",
76
+ msg,
77
+ )
78
+ return msg
79
+
80
+
81
+ # ── Log formatter ─────────────────────────────────────────────────────────────
82
+
83
+ class _PGFFormatter(logging.Formatter):
84
+ _LEVEL_STYLE = {
85
+ "DEBUG": ("dim", "DEBG"),
86
+ "INFO": ("cyan", "INFO"),
87
+ "WARNING": ("yellow", "WARN"),
88
+ "ERROR": ("red", "ERRR"),
89
+ "CRITICAL": ("magenta", "CRIT"),
90
+ }
91
+
92
+ def __init__(self, use_colour: bool = True, show_module: bool = True):
93
+ super().__init__()
94
+ self._use_colour = use_colour and _TTY
95
+ self._show_module = show_module
96
+
97
+ def format(self, record: logging.LogRecord) -> str:
98
+ ts = self.formatTime(record, datefmt="%H:%M:%S")
99
+ style, badge = self._LEVEL_STYLE.get(record.levelname, ("", record.levelname[:4]))
100
+ if self._use_colour:
101
+ badge = _c(style, badge)
102
+ ts = _cd(ts)
103
+
104
+ module = record.name.split(".")[-1] if self._show_module else ""
105
+ mod_str = _cd(f"[{module:>12}]") if (module and self._use_colour) else \
106
+ (f"[{module:>12}]" if module else "")
107
+
108
+ msg = _redact(record.getMessage())
109
+ if record.exc_info:
110
+ msg = f"{msg}\n{self.formatException(record.exc_info)}"
111
+
112
+ return f"{ts} {badge} {mod_str} {msg}"
113
+
114
+
115
+ # ── configure_logging / get_logger ───────────────────────────────────────────
116
+
117
+ def configure_logging(
118
+ level: str = "INFO",
119
+ log_file: Optional[str] = None,
120
+ use_colour: bool = True,
121
+ show_module: bool = True,
122
+ ) -> None:
123
+ """Configure PyGeoFetch logging. Call once at startup."""
124
+ root = logging.getLogger("pygeofetch")
125
+ root.setLevel(getattr(logging, level.upper(), logging.INFO))
126
+ root.handlers.clear()
127
+
128
+ ch = logging.StreamHandler(sys.stdout)
129
+ ch.setFormatter(_PGFFormatter(use_colour=use_colour, show_module=show_module))
130
+ root.addHandler(ch)
131
+
132
+ if log_file:
133
+ fh = logging.FileHandler(log_file, mode="a", encoding="utf-8")
134
+ fh.setFormatter(logging.Formatter(
135
+ "%(asctime)s %(levelname)-8s [%(name)s] %(message)s",
136
+ datefmt="%Y-%m-%d %H:%M:%S",
137
+ ))
138
+ root.addHandler(fh)
139
+
140
+ for lib in ("urllib3", "requests", "httpx", "botocore", "azure",
141
+ "pystac", "planetary_computer"):
142
+ logging.getLogger(lib).setLevel(logging.WARNING)
143
+
144
+
145
+ def get_logger(name: str) -> logging.Logger:
146
+ if not name.startswith("pygeofetch"):
147
+ name = f"pygeofetch.{name}"
148
+ return logging.getLogger(name)
149
+
150
+
151
+ # ══════════════════════════════════════════════════════════════════════════════
152
+ # SEARCH RESULTS TABLE
153
+ # ══════════════════════════════════════════════════════════════════════════════
154
+
155
+ def print_search_header(
156
+ providers: List[str],
157
+ bbox: Any,
158
+ start_date: Any,
159
+ end_date: Any,
160
+ cloud_max: float,
161
+ product: str = "any",
162
+ ) -> None:
163
+ """Print a clean search header box."""
164
+ w = 72
165
+ bbox_str = (f"[{bbox.min_lon:.3f}, {bbox.min_lat:.3f}, "
166
+ f"{bbox.max_lon:.3f}, {bbox.max_lat:.3f}]") if bbox else "—"
167
+ prov_str = ", ".join(providers)
168
+
169
+ print()
170
+ print(_cb(_cg("┌" + "─" * (w - 2) + "┐")))
171
+ print(_cb(_cg("│")) + _cb(f" 🛰 PYGEOFETCH SEARCH".center(w - 2)) + _cb(_cg("│")))
172
+ print(_cb(_cg("├" + "─" * (w - 2) + "┤")))
173
+
174
+ def row(label: str, value: str) -> None:
175
+ val_w = w - 18
176
+ print(_cb(_cg("│")) + f" {_cb(label):<22} {value[:val_w]:<{val_w}}" + _cg(_cb("│")))
177
+
178
+ row("Providers", prov_str)
179
+ row("BBox", bbox_str)
180
+ row("Date range", f"{start_date} → {end_date}")
181
+ row("Cloud max", f"{cloud_max}%")
182
+ row("Product", product)
183
+ print(_cb(_cg("└" + "─" * (w - 2) + "┘")))
184
+ print()
185
+
186
+
187
+ def print_provider_progress(provider: str, status: str, count: int = 0,
188
+ duration: float = 0.0, error: str = "") -> None:
189
+ """Print a single provider search result line."""
190
+ if error:
191
+ icon = _cr("✗")
192
+ line = f" {icon} {provider:<28} {_cr(error[:45])}"
193
+ elif count == 0:
194
+ icon = _cy("○")
195
+ line = f" {icon} {provider:<28} {_cy('no results')}"
196
+ else:
197
+ icon = _cg("✓")
198
+ dur = _cd(f"{duration:.1f}s")
199
+ cnt = _cb(_cg(f"{count:>4} scenes"))
200
+ line = f" {icon} {provider:<28} {cnt} {dur}"
201
+ print(line)
202
+
203
+
204
+ def _pad(text: str, width: int, align: str = "<") -> str:
205
+ """
206
+ Pad *text* to *width* using visible character count (strips ANSI codes first).
207
+ This ensures columns align correctly even when text contains colour escapes.
208
+ """
209
+ ansi_re = re.compile(r"\033\[[\d;]*m")
210
+ visible = len(ansi_re.sub("", text))
211
+ padding = max(0, width - visible)
212
+ if align == "<":
213
+ return text + " " * padding
214
+ if align == ">":
215
+ return " " * padding + text
216
+ # centre
217
+ left = padding // 2
218
+ right = padding - left
219
+ return " " * left + text + " " * right
220
+
221
+
222
+ def print_search_results(results: List[Any], elapsed: float = 0.0) -> None:
223
+ """Print a perfectly aligned table of search results."""
224
+ if not results:
225
+ print(f"\n {_cy('No scenes found.')} Try a wider date range or higher cloud cover.\n")
226
+ return
227
+
228
+ n = len(results)
229
+
230
+ # ── column definitions: (header, data_width) ─────────────────────────────
231
+ # data_width = max visible chars for that column's data
232
+ COLS = [
233
+ ("SCENE ID", 42),
234
+ ("DATE", 10),
235
+ ("SATELLITE", 14),
236
+ ("CLOUD", 6),
237
+ ("PRODUCT", 7),
238
+ ("POLARISATION", 11),
239
+ ("PASS", 11),
240
+ ("ORBIT", 5),
241
+ ("SCORE", 5),
242
+ ("PROVIDER", 22),
243
+ ]
244
+
245
+ # ── total table width ─────────────────────────────────────────────────────
246
+ # 2 leading spaces per col separator + 2 prefix for │
247
+ inner = sum(w for _, w in COLS) + len(COLS) * 2
248
+ bar = "─" * inner
249
+
250
+ # ── header ───────────────────────────────────────────────────────────────
251
+ print()
252
+ print(_cb(_cg(f" ┌{bar}┐")))
253
+ hdr_cells = "".join(
254
+ " " + _pad(_cb(h), w)
255
+ for h, w in COLS
256
+ )
257
+ print(_cb(_cg(" │")) + hdr_cells + _cb(_cg("│")))
258
+ print(_cb(_cg(f" ├{bar}┤")))
259
+
260
+ # ── rows ─────────────────────────────────────────────────────────────────
261
+ for r in results[:30]:
262
+ scene_id = (r.id or "—")[:COLS[0][1]]
263
+ date = (str(r.datetime)[:10] if r.datetime else "—")
264
+ sat = (r.satellite or "—")[:COLS[2][1]]
265
+ ptype = (r.product_type or "—")[:COLS[4][1]]
266
+ pol = (r.polarisation or "—")[:COLS[5][1]]
267
+ pdir = (r.pass_direction or "—")[:COLS[6][1]]
268
+ orbit = str(r.relative_orbit or "—")[:COLS[7][1]]
269
+ score = f"{r.score:.2f}" if r.score else "—"
270
+ provider = (r.provider or "—")[:COLS[9][1]]
271
+
272
+ # Cloud cover with colour
273
+ if r.cloud_cover is not None:
274
+ raw = f"{r.cloud_cover:.0f}%"
275
+ if r.cloud_cover <= 10:
276
+ cloud = _cg(raw)
277
+ elif r.cloud_cover <= 30:
278
+ cloud = _cy(raw)
279
+ else:
280
+ cloud = _cr(raw)
281
+ else:
282
+ cloud = "—"
283
+
284
+ # Build each cell using _pad (ANSI-aware)
285
+ cells = [
286
+ _pad(_cd(scene_id), COLS[0][1]),
287
+ _pad(date, COLS[1][1]),
288
+ _pad(_cb(sat), COLS[2][1]),
289
+ _pad(cloud, COLS[3][1]),
290
+ _pad(ptype, COLS[4][1]),
291
+ _pad(pol, COLS[5][1]),
292
+ _pad(pdir, COLS[6][1]),
293
+ _pad(orbit, COLS[7][1]),
294
+ _pad(score, COLS[8][1]),
295
+ _pad(_cd(provider), COLS[9][1]),
296
+ ]
297
+ row = "".join(" " + c for c in cells)
298
+ print(_cb(_cg(" │")) + row + _cb(_cg("│")))
299
+
300
+ # ── footer ────────────────────────────────────────────────────────────────
301
+ overflow = f" (+{n - 30} more)" if n > 30 else ""
302
+ elapsed_str = f" · {elapsed:.1f}s" if elapsed else ""
303
+ print(_cb(_cg(f" ├{bar}┤")))
304
+ summary = (f" {_cb(_cg(str(n)))} scene{'s' if n != 1 else ''} found"
305
+ f"{elapsed_str}{overflow}")
306
+ print(_cb(_cg(" │")) + summary)
307
+ print(_cb(_cg(f" └{bar}┘")))
308
+ print()
309
+
310
+
311
+ # ══════════════════════════════════════════════════════════════════════════════
312
+ # DOWNLOAD PROGRESS
313
+ # ══════════════════════════════════════════════════════════════════════════════
314
+
315
+ _SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
316
+
317
+
318
+ class DownloadProgress:
319
+ """
320
+ Live download progress display.
321
+
322
+ Usage::
323
+
324
+ with DownloadProgress(total=5) as dp:
325
+ for item in items:
326
+ with dp.item(item.id, expected_bytes=2_000_000_000):
327
+ # do download …
328
+ dp.update(bytes_done=500_000_000, speed_bps=18_000_000)
329
+ dp.complete_item(success=True, bytes_total=2_000_000_000, duration=17.8)
330
+ """
331
+
332
+ def __init__(self, total: int, destination: str = "") -> None:
333
+ self.total = total
334
+ self.destination = destination
335
+ self._completed = 0
336
+ self._failed = 0
337
+ self._start = time.time()
338
+ self._lock = threading.Lock()
339
+ self._spin_i = 0
340
+ self._current_id = ""
341
+ self._bytes_done = 0
342
+ self._bytes_total= 0
343
+ self._speed = 0.0
344
+ self._lines = 0 # lines printed so far in current block
345
+
346
+ # ── context manager ──────────────────────────────────────────────────────
347
+
348
+ def __enter__(self):
349
+ self._print_header()
350
+ return self
351
+
352
+ def __exit__(self, *_):
353
+ self._print_footer()
354
+
355
+ # ── public API ───────────────────────────────────────────────────────────
356
+
357
+ def start_item(self, scene_id: str, expected_bytes: int = 0) -> None:
358
+ with self._lock:
359
+ self._current_id = scene_id
360
+ self._bytes_done = 0
361
+ self._bytes_total = expected_bytes
362
+ self._speed = 0.0
363
+ self._spin_i = 0
364
+ self._render()
365
+
366
+ def update(self, bytes_done: int, speed_bps: float = 0.0) -> None:
367
+ with self._lock:
368
+ self._bytes_done = bytes_done
369
+ self._speed = speed_bps
370
+ self._spin_i = (self._spin_i + 1) % len(_SPINNER)
371
+ self._render()
372
+
373
+ def complete_item(self, success: bool, bytes_total: int = 0,
374
+ duration: float = 0.0) -> None:
375
+ with self._lock:
376
+ if success:
377
+ self._completed += 1
378
+ else:
379
+ self._failed += 1
380
+ self._print_item_done(success, bytes_total, duration)
381
+ self._current_id = ""
382
+
383
+ # ── internal rendering ───────────────────────────────────────────────────
384
+
385
+ def _fmt_size(self, b: int) -> str:
386
+ if b <= 0: return "—"
387
+ if b < 1024: return f"{b} B"
388
+ if b < 1 << 20: return f"{b/1024:.0f} KB"
389
+ if b < 1 << 30: return f"{b/1048576:.1f} MB"
390
+ return f"{b/1073741824:.2f} GB"
391
+
392
+ def _fmt_speed(self, bps: float) -> str:
393
+ if bps <= 0: return ""
394
+ if bps < 1 << 20: return f"{bps/1024:.0f} KB/s"
395
+ return f"{bps/1048576:.1f} MB/s"
396
+
397
+ def _bar(self, filled_pct: float, width: int = 28) -> str:
398
+ done = int(width * max(0.0, min(1.0, filled_pct)))
399
+ empty = width - done
400
+ return _cg("█" * done) + _cd("░" * empty)
401
+
402
+ def _print_header(self) -> None:
403
+ dest = f" → {self.destination}" if self.destination else ""
404
+ print()
405
+ print(_cb(_cg(f" ┌{'─'*70}┐")))
406
+ print(_cb(_cg(" │")) + _cb(f" ⬇ DOWNLOADING {self.total} scene{'s' if self.total != 1 else ''}{dest}".ljust(70)) + _cb(_cg("│")))
407
+ print(_cb(_cg(f" └{'─'*70}┘")))
408
+ print()
409
+
410
+ def _render(self) -> None:
411
+ """Overwrite the current line with live progress."""
412
+ if not _TTY:
413
+ return
414
+
415
+ spin = _cg(_SPINNER[self._spin_i])
416
+ idx = self._completed + self._failed + 1
417
+ id_ = (self._current_id or "")[:42]
418
+
419
+ pct = (self._bytes_done / self._bytes_total) if self._bytes_total > 0 else 0.0
420
+ bar = self._bar(pct)
421
+ done_ = self._fmt_size(self._bytes_done)
422
+ tot_ = self._fmt_size(self._bytes_total)
423
+ spd_ = self._fmt_speed(self._speed)
424
+
425
+ size_str = f"{done_} / {tot_}" if self._bytes_total > 0 else done_
426
+ spd_str = f" {_cy(spd_)}" if spd_ else ""
427
+
428
+ line = (
429
+ f" {spin} [{_cd(f'{idx}/{self.total}')}] "
430
+ f"{_cb(id_):<52} "
431
+ f"[{bar}] "
432
+ f"{_cd(size_str):<20}"
433
+ f"{spd_str}"
434
+ )
435
+ sys.stdout.write(f"\r{line} ")
436
+ sys.stdout.flush()
437
+
438
+ def _print_item_done(self, success: bool, bytes_total: int, duration: float) -> None:
439
+ """Print a completed item line (replaces the spinner line)."""
440
+ # Clear spinner line
441
+ if _TTY:
442
+ sys.stdout.write("\r" + " " * 120 + "\r")
443
+ sys.stdout.flush()
444
+
445
+ idx = self._completed + self._failed
446
+ id_ = (self._current_id or "")[:42]
447
+ size_str = self._fmt_size(bytes_total)
448
+ dur_str = f"{duration:.1f}s"
449
+
450
+ if success:
451
+ icon = _cg("✓")
452
+ line = (
453
+ f" {icon} [{_cd(f'{idx}/{self.total}')}] "
454
+ f"{_cb(id_):<52} "
455
+ f"{_cg(size_str):<12} "
456
+ f"{_cd(dur_str)}"
457
+ )
458
+ else:
459
+ icon = _cr("✗")
460
+ line = (
461
+ f" {icon} [{_cd(f'{idx}/{self.total}')}] "
462
+ f"{_cr(id_):<52} "
463
+ f"{_cr('FAILED')}"
464
+ )
465
+ print(line)
466
+
467
+ def _print_footer(self) -> None:
468
+ elapsed = time.time() - self._start
469
+ total_done = self._completed + self._failed
470
+ print()
471
+ if self._failed == 0:
472
+ status = _cg(_cb(f"✓ All {self._completed} scenes downloaded"))
473
+ else:
474
+ status = (_cb(_cg(f"✓ {self._completed} succeeded")) +
475
+ " " + _cb(_cr(f"✗ {self._failed} failed")))
476
+
477
+ print(f" {status} {_cd(f'total time: {elapsed:.1f}s')}")
478
+ print()
479
+
480
+
481
+ # ── Backward compat ───────────────────────────────────────────────────────────
482
+
483
+ def _render_progress_bar(completed, total, filename, bytes_done,
484
+ bytes_total, speed_bps, bar_width=20) -> str:
485
+ """Legacy helper kept for backward compatibility."""
486
+ filled = int(bar_width * min(completed, total) / max(total, 1))
487
+ bar = "█" * filled + "░" * (bar_width - filled)
488
+ done_gb = bytes_done / 1e9
489
+ tot_gb = bytes_total / 1e9 if bytes_total else 0.0
490
+ spd_mbs = speed_bps / 1e6
491
+ name = filename[:40] + "…" if len(filename) > 40 else filename
492
+ size_str = (f"{done_gb:.1f} GB / {tot_gb:.1f} GB"
493
+ if bytes_total > 0 else f"{done_gb:.1f} GB")
494
+ return (f"\r [{bar}] {completed}/{total} "
495
+ f"{name:<42} {size_str} {spd_mbs:.1f} MB/s")