PyGeoFetch 2.6.2.1__py3-none-any.whl → 2.6.2.2__py3-none-any.whl
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/__init__.py +1 -1
- pygeofetch/cli/download_commands.py +62 -1
- pygeofetch/cli/search_commands.py +61 -1
- pygeofetch/core/authenticator.py +117 -16
- pygeofetch/core/engine.py +235 -0
- pygeofetch/core/scheduler.py +239 -8
- pygeofetch/core/searcher.py +26 -2
- pygeofetch/insar/preflight.py +8 -2
- pygeofetch/meta.yaml +1 -1
- pygeofetch/providers/airbus_oneatlas.py +296 -108
- pygeofetch/providers/base.py +2 -0
- pygeofetch/providers/esa_scihub.py +31 -145
- pygeofetch/providers/google_earth_engine.py +36 -160
- pygeofetch/providers/noaa_big_data.py +205 -126
- pygeofetch/validation/__init__.py +24 -0
- pygeofetch/validation/optical_validator.py +918 -0
- pygeofetch/viz/plot.py +29 -21
- {pygeofetch-2.6.2.1.dist-info → pygeofetch-2.6.2.2.dist-info}/METADATA +25 -19
- {pygeofetch-2.6.2.1.dist-info → pygeofetch-2.6.2.2.dist-info}/RECORD +23 -21
- {pygeofetch-2.6.2.1.dist-info → pygeofetch-2.6.2.2.dist-info}/WHEEL +0 -0
- {pygeofetch-2.6.2.1.dist-info → pygeofetch-2.6.2.2.dist-info}/entry_points.txt +0 -0
- {pygeofetch-2.6.2.1.dist-info → pygeofetch-2.6.2.2.dist-info}/licenses/LICENSE +0 -0
- {pygeofetch-2.6.2.1.dist-info → pygeofetch-2.6.2.2.dist-info}/top_level.txt +0 -0
pygeofetch/__init__.py
CHANGED
|
@@ -114,6 +114,36 @@ def download() -> None:
|
|
|
114
114
|
help='Comma-separated band names to download e.g. "B02,B03,B04" (default: all data assets).',
|
|
115
115
|
)
|
|
116
116
|
@click.option("--json", "as_json", is_flag=True, help="Output results summary as JSON.")
|
|
117
|
+
@click.option(
|
|
118
|
+
"--validate-optical",
|
|
119
|
+
is_flag=True,
|
|
120
|
+
default=False,
|
|
121
|
+
help=(
|
|
122
|
+
"Run optical preflight validation as a final gate before "
|
|
123
|
+
"downloading (AOI coverage, cloud cover, required bands, "
|
|
124
|
+
"processing level, temporal bounds). Items that fail are "
|
|
125
|
+
"never attempted -- a failed result explaining why is "
|
|
126
|
+
"returned in their place, same position, so output length "
|
|
127
|
+
"always matches input."
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
@click.option(
|
|
131
|
+
"--optical-max-cloud-cover",
|
|
132
|
+
default=None,
|
|
133
|
+
type=float,
|
|
134
|
+
help="Override the max cloud cover threshold used by --validate-optical (default 20.0).",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--optical-min-coverage",
|
|
138
|
+
default=None,
|
|
139
|
+
type=float,
|
|
140
|
+
help="Override the min AOI coverage ratio used by --validate-optical, 0-1 (default 0.8).",
|
|
141
|
+
)
|
|
142
|
+
@click.option(
|
|
143
|
+
"--optical-required-bands",
|
|
144
|
+
default=None,
|
|
145
|
+
help="Comma-separated required bands for --validate-optical (default B02,B03,B04,B08,SCL).",
|
|
146
|
+
)
|
|
117
147
|
def download_run(
|
|
118
148
|
from_search,
|
|
119
149
|
scene_ids,
|
|
@@ -132,6 +162,10 @@ def download_run(
|
|
|
132
162
|
overwrite,
|
|
133
163
|
bands,
|
|
134
164
|
as_json,
|
|
165
|
+
validate_optical,
|
|
166
|
+
optical_max_cloud_cover,
|
|
167
|
+
optical_min_coverage,
|
|
168
|
+
optical_required_bands,
|
|
135
169
|
) -> None:
|
|
136
170
|
"""
|
|
137
171
|
Download satellite data products from a search results file or scene IDs.
|
|
@@ -151,6 +185,11 @@ def download_run(
|
|
|
151
185
|
--notify webhook:https://hooks.slack.com/T01/B01/XYZ \\
|
|
152
186
|
--notify email:user@example.com \\
|
|
153
187
|
--on-failure skip
|
|
188
|
+
|
|
189
|
+
# With optical preflight validation as a final gate before downloading
|
|
190
|
+
pygeofetch download run \\
|
|
191
|
+
--from-search results.geojson --output ./data/ \\
|
|
192
|
+
--validate-optical --optical-max-cloud-cover 10
|
|
154
193
|
"""
|
|
155
194
|
from pygeofetch.core.engine import PyGeoFetch
|
|
156
195
|
from pygeofetch.core.searcher import FederatedSearcher
|
|
@@ -218,6 +257,24 @@ def download_run(
|
|
|
218
257
|
|
|
219
258
|
sb = PyGeoFetch()
|
|
220
259
|
|
|
260
|
+
if validate_optical and (
|
|
261
|
+
optical_max_cloud_cover,
|
|
262
|
+
optical_min_coverage,
|
|
263
|
+
optical_required_bands,
|
|
264
|
+
) != (None, None, None):
|
|
265
|
+
from pygeofetch.validation import OpticalValidationConfig
|
|
266
|
+
|
|
267
|
+
overrides = {}
|
|
268
|
+
if optical_max_cloud_cover is not None:
|
|
269
|
+
overrides["max_cloud_cover_pct"] = optical_max_cloud_cover
|
|
270
|
+
if optical_min_coverage is not None:
|
|
271
|
+
overrides["min_coverage_ratio"] = optical_min_coverage
|
|
272
|
+
if optical_required_bands is not None:
|
|
273
|
+
overrides["required_bands"] = [
|
|
274
|
+
b.strip() for b in optical_required_bands.split(",")
|
|
275
|
+
]
|
|
276
|
+
sb.optical_validation_config = OpticalValidationConfig(**overrides)
|
|
277
|
+
|
|
221
278
|
# Load data
|
|
222
279
|
if from_search:
|
|
223
280
|
data_list = FederatedSearcher.load_results(Path(from_search))
|
|
@@ -273,7 +330,11 @@ def download_run(
|
|
|
273
330
|
|
|
274
331
|
# Use sb.download() with the already-sliced data_list (respects --max-items)
|
|
275
332
|
results = sb.download(
|
|
276
|
-
data_list,
|
|
333
|
+
data_list,
|
|
334
|
+
Path(output),
|
|
335
|
+
options,
|
|
336
|
+
item_done_callback=on_item_done,
|
|
337
|
+
validate_optical=validate_optical,
|
|
277
338
|
)
|
|
278
339
|
|
|
279
340
|
succeeded = [r for r in results if r.success]
|
|
@@ -104,6 +104,34 @@ def search() -> None:
|
|
|
104
104
|
"--timeout", default=60, show_default=True, help="Per-provider timeout in seconds."
|
|
105
105
|
)
|
|
106
106
|
@click.option("--no-cache", is_flag=True, default=False, help="Bypass result cache.")
|
|
107
|
+
@click.option(
|
|
108
|
+
"--validate-optical",
|
|
109
|
+
is_flag=True,
|
|
110
|
+
default=False,
|
|
111
|
+
help=(
|
|
112
|
+
"Run optical preflight validation (AOI coverage, cloud cover, "
|
|
113
|
+
"required bands, processing level, temporal bounds) on results "
|
|
114
|
+
"before returning them. Not appropriate for SAR/InSAR searches "
|
|
115
|
+
"-- the default required bands are optical band names."
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
@click.option(
|
|
119
|
+
"--optical-max-cloud-cover",
|
|
120
|
+
default=None,
|
|
121
|
+
type=float,
|
|
122
|
+
help="Override the max cloud cover threshold used by --validate-optical (default 20.0).",
|
|
123
|
+
)
|
|
124
|
+
@click.option(
|
|
125
|
+
"--optical-min-coverage",
|
|
126
|
+
default=None,
|
|
127
|
+
type=float,
|
|
128
|
+
help="Override the min AOI coverage ratio used by --validate-optical, 0-1 (default 0.8).",
|
|
129
|
+
)
|
|
130
|
+
@click.option(
|
|
131
|
+
"--optical-required-bands",
|
|
132
|
+
default=None,
|
|
133
|
+
help="Comma-separated required bands for --validate-optical (default B02,B03,B04,B08,SCL).",
|
|
134
|
+
)
|
|
107
135
|
def search_run(
|
|
108
136
|
bbox,
|
|
109
137
|
geometry_file,
|
|
@@ -125,6 +153,10 @@ def search_run(
|
|
|
125
153
|
on_provider_failure,
|
|
126
154
|
timeout,
|
|
127
155
|
no_cache,
|
|
156
|
+
validate_optical,
|
|
157
|
+
optical_max_cloud_cover,
|
|
158
|
+
optical_min_coverage,
|
|
159
|
+
optical_required_bands,
|
|
128
160
|
) -> None:
|
|
129
161
|
"""
|
|
130
162
|
Search for satellite imagery across one or more providers.
|
|
@@ -146,6 +178,11 @@ def search_run(
|
|
|
146
178
|
--geometry-file area.geojson \\
|
|
147
179
|
--cql2 "eo:cloud_cover < 5 AND platform = 'sentinel-2b'" \\
|
|
148
180
|
--format geoparquet --output results.parquet
|
|
181
|
+
|
|
182
|
+
# With optical preflight validation (skip cloudy/off-target/wrong-level scenes)
|
|
183
|
+
pygeofetch search run \\
|
|
184
|
+
--bbox "-74,40,-73,41" --cloud-cover 0-100 \\
|
|
185
|
+
--validate-optical --optical-max-cloud-cover 10 --optical-min-coverage 0.9
|
|
149
186
|
"""
|
|
150
187
|
from pygeofetch.core.engine import PyGeoFetch
|
|
151
188
|
from pygeofetch.models.search_query import BoundingBox, SearchQuery
|
|
@@ -245,10 +282,33 @@ def search_run(
|
|
|
245
282
|
|
|
246
283
|
sb = PyGeoFetch()
|
|
247
284
|
|
|
285
|
+
if validate_optical and (
|
|
286
|
+
optical_max_cloud_cover,
|
|
287
|
+
optical_min_coverage,
|
|
288
|
+
optical_required_bands,
|
|
289
|
+
) != (None, None, None):
|
|
290
|
+
from pygeofetch.validation import OpticalValidationConfig
|
|
291
|
+
|
|
292
|
+
overrides = {}
|
|
293
|
+
if optical_max_cloud_cover is not None:
|
|
294
|
+
overrides["max_cloud_cover_pct"] = optical_max_cloud_cover
|
|
295
|
+
if optical_min_coverage is not None:
|
|
296
|
+
overrides["min_coverage_ratio"] = optical_min_coverage
|
|
297
|
+
if optical_required_bands is not None:
|
|
298
|
+
overrides["required_bands"] = [
|
|
299
|
+
b.strip() for b in optical_required_bands.split(",")
|
|
300
|
+
]
|
|
301
|
+
sb.optical_validation_config = OpticalValidationConfig(**overrides)
|
|
302
|
+
|
|
248
303
|
with console.status(
|
|
249
304
|
f"[cyan]Searching {len(provider_list or [])} provider(s)...[/]"
|
|
250
305
|
):
|
|
251
|
-
results = sb.search(
|
|
306
|
+
results = sb.search(
|
|
307
|
+
query,
|
|
308
|
+
providers=provider_list,
|
|
309
|
+
use_cache=not no_cache,
|
|
310
|
+
validate_optical=validate_optical,
|
|
311
|
+
)
|
|
252
312
|
|
|
253
313
|
if not results:
|
|
254
314
|
console.print("[yellow]No results found.[/]")
|
pygeofetch/core/authenticator.py
CHANGED
|
@@ -30,6 +30,8 @@ from pygeofetch.models.user_auth import AuthSession, AuthType, Credentials
|
|
|
30
30
|
if TYPE_CHECKING:
|
|
31
31
|
import builtins
|
|
32
32
|
|
|
33
|
+
from cryptography.fernet import Fernet
|
|
34
|
+
|
|
33
35
|
logger = get_logger(__name__)
|
|
34
36
|
|
|
35
37
|
|
|
@@ -45,8 +47,12 @@ class CredentialStore:
|
|
|
45
47
|
def __init__(self, storage_backend: str = "keyring") -> None:
|
|
46
48
|
self.storage_backend = storage_backend
|
|
47
49
|
self._config_dir = Path.home() / ".pygeofetch"
|
|
48
|
-
self._cred_file = self._config_dir / "credentials.
|
|
50
|
+
self._cred_file = self._config_dir / "credentials.enc"
|
|
51
|
+
self._legacy_cred_file = self._config_dir / "credentials.json"
|
|
52
|
+
self._key_file = self._config_dir / "credentials.key"
|
|
49
53
|
self._in_memory: dict[str, dict[str, Any]] = {}
|
|
54
|
+
self._fernet: Fernet | None = None # lazily initialized — see _get_fernet()
|
|
55
|
+
self._migrating = False # re-entrancy guard for _migrate_legacy_file_if_needed
|
|
50
56
|
|
|
51
57
|
def save(self, provider: str, credentials: dict[str, Any]) -> None:
|
|
52
58
|
"""Persist credentials for a provider."""
|
|
@@ -126,7 +132,88 @@ class CredentialStore:
|
|
|
126
132
|
except Exception:
|
|
127
133
|
return self._delete_file(provider)
|
|
128
134
|
|
|
135
|
+
_SENSITIVE_FIELDS = ("password", "api_key", "token", "secret_key", "client_secret")
|
|
136
|
+
|
|
137
|
+
def _get_fernet(self) -> Fernet:
|
|
138
|
+
"""
|
|
139
|
+
Return a cached Fernet instance, generating and persisting a new
|
|
140
|
+
local encryption key on first use.
|
|
141
|
+
|
|
142
|
+
REAL BUG FIXED: this file backend previously used
|
|
143
|
+
``base64.b64encode`` on sensitive fields with a comment stating
|
|
144
|
+
outright "Basic obfuscation (not encryption)" -- base64 is
|
|
145
|
+
trivially reversible by anyone who can read the file, with no
|
|
146
|
+
key required, and the docs previously (and inaccurately) claimed
|
|
147
|
+
this path used Fernet AES-128-CBC. It now genuinely does.
|
|
148
|
+
"""
|
|
149
|
+
if self._fernet is not None:
|
|
150
|
+
return self._fernet
|
|
151
|
+
from cryptography.fernet import Fernet
|
|
152
|
+
|
|
153
|
+
self._config_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
if self._key_file.exists():
|
|
155
|
+
key = self._key_file.read_bytes()
|
|
156
|
+
else:
|
|
157
|
+
key = Fernet.generate_key()
|
|
158
|
+
self._key_file.write_bytes(key)
|
|
159
|
+
self._key_file.chmod(0o600)
|
|
160
|
+
self._fernet = Fernet(key)
|
|
161
|
+
return self._fernet
|
|
162
|
+
|
|
163
|
+
def _migrate_legacy_file_if_needed(self) -> None:
|
|
164
|
+
"""
|
|
165
|
+
One-time transparent migration from the old base64-"obfuscated"
|
|
166
|
+
``credentials.json`` to the new Fernet-encrypted
|
|
167
|
+
``credentials.enc``, so existing users don't lose stored
|
|
168
|
+
credentials when upgrading. Runs at most once: after migration,
|
|
169
|
+
the legacy file is left in place but unused (never read again
|
|
170
|
+
once ``credentials.enc`` exists) rather than deleted, in case a
|
|
171
|
+
user needs to roll back.
|
|
172
|
+
"""
|
|
173
|
+
if (
|
|
174
|
+
self._cred_file.exists()
|
|
175
|
+
or not self._legacy_cred_file.exists()
|
|
176
|
+
or self._migrating
|
|
177
|
+
):
|
|
178
|
+
return
|
|
179
|
+
self._migrating = True
|
|
180
|
+
try:
|
|
181
|
+
try:
|
|
182
|
+
legacy_data = json.loads(self._legacy_cred_file.read_text())
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
logger.warning(
|
|
185
|
+
f"Could not read legacy credentials file for migration: {exc}"
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
migrated = 0
|
|
190
|
+
for provider, creds in legacy_data.items():
|
|
191
|
+
if not isinstance(creds, dict):
|
|
192
|
+
continue
|
|
193
|
+
plain = dict(creds)
|
|
194
|
+
if plain.pop("__obfuscated", False):
|
|
195
|
+
for k in self._SENSITIVE_FIELDS:
|
|
196
|
+
if k in plain:
|
|
197
|
+
try:
|
|
198
|
+
plain[k] = base64.b64decode(
|
|
199
|
+
str(plain[k]).encode()
|
|
200
|
+
).decode()
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
self._save_file(provider, plain)
|
|
204
|
+
migrated += 1
|
|
205
|
+
if migrated:
|
|
206
|
+
logger.info(
|
|
207
|
+
f"Migrated {migrated} provider credential(s) from the old "
|
|
208
|
+
f"{self._legacy_cred_file.name} (base64) to the new "
|
|
209
|
+
f"{self._cred_file.name} (Fernet-encrypted). The old file "
|
|
210
|
+
"was left in place, unused, and can be deleted manually."
|
|
211
|
+
)
|
|
212
|
+
finally:
|
|
213
|
+
self._migrating = False
|
|
214
|
+
|
|
129
215
|
def _save_file(self, provider: str, credentials: dict[str, Any]) -> None:
|
|
216
|
+
self._migrate_legacy_file_if_needed()
|
|
130
217
|
self._config_dir.mkdir(parents=True, exist_ok=True)
|
|
131
218
|
existing: dict[str, Any] = {}
|
|
132
219
|
if self._cred_file.exists():
|
|
@@ -134,19 +221,23 @@ class CredentialStore:
|
|
|
134
221
|
existing = json.loads(self._cred_file.read_text())
|
|
135
222
|
except Exception:
|
|
136
223
|
pass
|
|
137
|
-
|
|
138
|
-
|
|
224
|
+
|
|
225
|
+
fernet = self._get_fernet()
|
|
226
|
+
encrypted = {}
|
|
139
227
|
for k, v in credentials.items():
|
|
140
|
-
if k in
|
|
141
|
-
|
|
228
|
+
if k in self._SENSITIVE_FIELDS:
|
|
229
|
+
encrypted[k] = fernet.encrypt(str(v).encode()).decode()
|
|
142
230
|
else:
|
|
143
|
-
|
|
144
|
-
existing[provider] = {"
|
|
231
|
+
encrypted[k] = v
|
|
232
|
+
existing[provider] = {"__encrypted": True, **encrypted}
|
|
145
233
|
self._cred_file.write_text(json.dumps(existing, indent=2))
|
|
146
234
|
self._cred_file.chmod(0o600)
|
|
147
|
-
logger.debug(
|
|
235
|
+
logger.debug(
|
|
236
|
+
f"Credentials for {provider!r} saved to {self._cred_file} (encrypted)"
|
|
237
|
+
)
|
|
148
238
|
|
|
149
239
|
def _load_file(self, provider: str) -> dict[str, Any] | None:
|
|
240
|
+
self._migrate_legacy_file_if_needed()
|
|
150
241
|
if not self._cred_file.exists():
|
|
151
242
|
return None
|
|
152
243
|
try:
|
|
@@ -154,19 +245,29 @@ class CredentialStore:
|
|
|
154
245
|
creds = data.get(provider)
|
|
155
246
|
if not creds:
|
|
156
247
|
return None
|
|
248
|
+
if creds.get("__encrypted"):
|
|
249
|
+
creds = {k: v for k, v in creds.items() if k != "__encrypted"}
|
|
250
|
+
fernet = self._get_fernet()
|
|
251
|
+
decoded = {}
|
|
252
|
+
for k, v in creds.items():
|
|
253
|
+
if k in self._SENSITIVE_FIELDS:
|
|
254
|
+
try:
|
|
255
|
+
decoded[k] = fernet.decrypt(str(v).encode()).decode()
|
|
256
|
+
except Exception:
|
|
257
|
+
decoded[k] = v
|
|
258
|
+
else:
|
|
259
|
+
decoded[k] = v
|
|
260
|
+
return decoded
|
|
261
|
+
# Legacy plain/base64 entries encountered directly in
|
|
262
|
+
# credentials.enc (shouldn't normally happen, but handled
|
|
263
|
+
# rather than silently failing).
|
|
157
264
|
if creds.get("__obfuscated"):
|
|
158
265
|
creds = {k: v for k, v in creds.items() if k != "__obfuscated"}
|
|
159
266
|
decoded = {}
|
|
160
267
|
for k, v in creds.items():
|
|
161
|
-
if k in
|
|
162
|
-
"password",
|
|
163
|
-
"api_key",
|
|
164
|
-
"token",
|
|
165
|
-
"secret_key",
|
|
166
|
-
"client_secret",
|
|
167
|
-
):
|
|
268
|
+
if k in self._SENSITIVE_FIELDS:
|
|
168
269
|
try:
|
|
169
|
-
decoded[k] = base64.b64decode(v.encode()).decode()
|
|
270
|
+
decoded[k] = base64.b64decode(str(v).encode()).decode()
|
|
170
271
|
except Exception:
|
|
171
272
|
decoded[k] = v
|
|
172
273
|
else:
|