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 CHANGED
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "2.6.2.1"
33
+ __version__ = "2.6.2.2"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -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, Path(output), options, item_done_callback=on_item_done
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(query, providers=provider_list, use_cache=not no_cache)
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.[/]")
@@ -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.json"
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
- # Basic obfuscation (not encryption) - passwords stored as b64
138
- obfuscated = {}
224
+
225
+ fernet = self._get_fernet()
226
+ encrypted = {}
139
227
  for k, v in credentials.items():
140
- if k in ("password", "api_key", "token", "secret_key", "client_secret"):
141
- obfuscated[k] = base64.b64encode(str(v).encode()).decode()
228
+ if k in self._SENSITIVE_FIELDS:
229
+ encrypted[k] = fernet.encrypt(str(v).encode()).decode()
142
230
  else:
143
- obfuscated[k] = v
144
- existing[provider] = {"__obfuscated": True, **obfuscated}
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(f"Credentials for {provider!r} saved to {self._cred_file}")
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: