PyGeoFetch 1.0.6__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.
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/PKG-INFO +1 -1
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/__init__.py +2 -2
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/main.py +2 -2
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/core/downloader.py +44 -27
- pygeofetch-1.0.7/pygeofetch/core/logging.py +495 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/core/searcher.py +19 -8
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/models/satellite_data.py +50 -26
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/aws_earth.py +3 -2
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch.egg-info/PKG-INFO +1 -1
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pyproject.toml +1 -1
- pygeofetch-1.0.6/pygeofetch/core/logging.py +0 -199
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/LICENSE +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/README.md +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/__init__.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/auth_commands.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/config_commands.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/download_commands.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/index_commands.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/pipeline_process_commands.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/postprocess_commands.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/preprocess_commands.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/sar_commands.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/cli/search_commands.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/config/__init__.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/config/defaults.yaml +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/config/settings.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/core/__init__.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/core/authenticator.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/core/cache_manager.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/core/engine.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/core/orbits.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/core/scheduler.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/models/__init__.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/models/download_task.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/models/search_query.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/models/user_auth.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/processing/__init__.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/processing/base.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/processing/batch.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/processing/indices.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/processing/pipeline.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/processing/postprocessor.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/processing/preprocessor.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/processing/sar.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/__init__.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/airbus_oneatlas.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/base.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/copernicus.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/digitalglobe.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/earth_explorer_additional.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/element84.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/esa_scihub.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/geoserver_generic.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/google_earth_engine.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/inpe_cbers.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/isro_bhuvan.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/jaxa_earth.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/maxar_gbdx.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/nasa_earthdata.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/noaa_big_data.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/opentopography.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/planet.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/planetary_computer.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/sentinel_hub.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/terrabotics.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/providers/usgs.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/utils/__init__.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/utils/file_utils.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/utils/geo_utils.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/utils/logging_setup.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/utils/retry_handler.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch/utils/validators.py +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch.egg-info/SOURCES.txt +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch.egg-info/dependency_links.txt +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch.egg-info/entry_points.txt +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch.egg-info/not-zip-safe +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch.egg-info/requires.txt +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/pygeofetch.egg-info/top_level.txt +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/setup.cfg +0 -0
- {pygeofetch-1.0.6 → pygeofetch-1.0.7}/tests/test_pgv_integration_contract.py +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
PyGeoFetch v1.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
executor.submit(
|
|
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(
|
|
190
|
-
idx =
|
|
191
|
-
|
|
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
|
-
|
|
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")
|