PyGeoFetch 1.0.6__tar.gz → 1.0.8__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 (88) hide show
  1. pygeofetch-1.0.8/CHANGELOG.md +42 -0
  2. pygeofetch-1.0.8/MANIFEST.in +11 -0
  3. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/PKG-INFO +1 -1
  4. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/__init__.py +2 -2
  5. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/main.py +2 -2
  6. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/core/downloader.py +120 -64
  7. pygeofetch-1.0.8/pygeofetch/core/logging.py +495 -0
  8. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/core/searcher.py +19 -8
  9. pygeofetch-1.0.8/pygeofetch/meta.yaml +62 -0
  10. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/models/satellite_data.py +50 -26
  11. pygeofetch-1.0.8/pygeofetch/processing/base.py +315 -0
  12. pygeofetch-1.0.8/pygeofetch/processing/indices.py +685 -0
  13. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/processing/preprocessor.py +23 -7
  14. pygeofetch-1.0.8/pygeofetch/processing/sar.py +336 -0
  15. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/aws_earth.py +3 -2
  16. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch.egg-info/PKG-INFO +1 -1
  17. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch.egg-info/SOURCES.txt +3 -0
  18. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pyproject.toml +1 -1
  19. pygeofetch-1.0.6/pygeofetch/core/logging.py +0 -199
  20. pygeofetch-1.0.6/pygeofetch/processing/base.py +0 -102
  21. pygeofetch-1.0.6/pygeofetch/processing/indices.py +0 -777
  22. pygeofetch-1.0.6/pygeofetch/processing/sar.py +0 -315
  23. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/LICENSE +0 -0
  24. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/README.md +0 -0
  25. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/__init__.py +0 -0
  26. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/auth_commands.py +0 -0
  27. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/config_commands.py +0 -0
  28. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/download_commands.py +0 -0
  29. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/index_commands.py +0 -0
  30. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  31. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/postprocess_commands.py +0 -0
  32. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/preprocess_commands.py +0 -0
  33. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/sar_commands.py +0 -0
  34. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/cli/search_commands.py +0 -0
  35. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/config/__init__.py +0 -0
  36. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/config/defaults.yaml +0 -0
  37. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/config/settings.py +0 -0
  38. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/core/__init__.py +0 -0
  39. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/core/authenticator.py +0 -0
  40. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/core/cache_manager.py +0 -0
  41. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/core/engine.py +0 -0
  42. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/core/orbits.py +0 -0
  43. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/core/scheduler.py +0 -0
  44. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/models/__init__.py +0 -0
  45. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/models/download_task.py +0 -0
  46. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/models/search_query.py +0 -0
  47. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/models/user_auth.py +0 -0
  48. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/processing/__init__.py +0 -0
  49. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/processing/batch.py +0 -0
  50. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/processing/pipeline.py +0 -0
  51. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/processing/postprocessor.py +0 -0
  52. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/__init__.py +0 -0
  53. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/airbus_oneatlas.py +0 -0
  54. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
  55. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/base.py +0 -0
  56. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/copernicus.py +0 -0
  57. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/digitalglobe.py +0 -0
  58. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/earth_explorer_additional.py +0 -0
  59. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/element84.py +0 -0
  60. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/esa_scihub.py +0 -0
  61. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/geoserver_generic.py +0 -0
  62. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/google_earth_engine.py +0 -0
  63. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/inpe_cbers.py +0 -0
  64. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/isro_bhuvan.py +0 -0
  65. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/jaxa_earth.py +0 -0
  66. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/maxar_gbdx.py +0 -0
  67. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/nasa_earthdata.py +0 -0
  68. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  69. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/noaa_big_data.py +0 -0
  70. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/opentopography.py +0 -0
  71. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/planet.py +0 -0
  72. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/planetary_computer.py +0 -0
  73. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/sentinel_hub.py +0 -0
  74. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/terrabotics.py +0 -0
  75. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/providers/usgs.py +0 -0
  76. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/utils/__init__.py +0 -0
  77. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/utils/file_utils.py +0 -0
  78. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/utils/geo_utils.py +0 -0
  79. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/utils/logging_setup.py +0 -0
  80. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/utils/retry_handler.py +0 -0
  81. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch/utils/validators.py +0 -0
  82. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch.egg-info/dependency_links.txt +0 -0
  83. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch.egg-info/entry_points.txt +0 -0
  84. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch.egg-info/not-zip-safe +0 -0
  85. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch.egg-info/requires.txt +0 -0
  86. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/pygeofetch.egg-info/top_level.txt +0 -0
  87. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/setup.cfg +0 -0
  88. {pygeofetch-1.0.6 → pygeofetch-1.0.8}/tests/test_pgv_integration_contract.py +0 -0
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to PyGeoFetch are documented here.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.8/).
5
+
6
+ ## [1.0.8] — 2026-07-12
7
+
8
+ ### Added
9
+ - Federated search across 22+ satellite data providers
10
+ - Sentinel-1C and Sentinel-1D constellation support (active since May/April 2026)
11
+ - SLC product type search with automatic provider routing
12
+ - Precise orbit file management (POEORB/RESORB) for InSAR workflows
13
+ - 17 spectral indices: NDVI, EVI, SAVI, NDWI, MNDWI, NDBI, NDSI, NDMI,
14
+ NBR, dNBR, TCT, PCA, Texture, LST, Albedo, Band Math, Stack
15
+ - SAR processing: despeckle, calibrate, flood mapping, coherence
16
+ - 41-step chainable processing pipeline builder
17
+ - YAML pipeline definitions with cron scheduling
18
+ - Cloud Optimized GeoTIFF output
19
+ - Clean search result tables with ANSI-aware column alignment
20
+ - Live download progress with Jupyter notebook HTML widget support
21
+ - Credential redaction in all log output
22
+
23
+ ### Fixed
24
+ - AuthManager.add_credentials() now accepts dict form (BUG 1)
25
+ - download() length/order contract guaranteed (BUG 2)
26
+ - Partial downloads detected via file validation (BUG 3)
27
+ - CRS identity transform detection after reprojection (BUG 4)
28
+ - resolve_band_keys missing import in aws_earth provider
29
+ - Band alias conflict between Sentinel-2 and Landsat naming conventions
30
+ - Copernicus product_type OData filter now correctly applied
31
+ - USGS M2M API authentication payload (authType, catalogId fields)
32
+ - ZIP archive validation no longer attempts rasterio open on archives
33
+
34
+ ### Providers
35
+ - Copernicus Dataspace (Sentinel-1/2/3/5P, SLC + GRD)
36
+ - AWS Earth (Sentinel-2, Landsat via STAC)
37
+ - Planetary Computer (Sentinel-2, Landsat, MODIS)
38
+ - Element84 Earth Search (STAC)
39
+ - USGS Earth Explorer (Landsat 1-9, ASTER, SRTM)
40
+ - NASA Earthdata (MODIS, ICESat-2, GEDI)
41
+ - Alaska SAR Facility (Sentinel-1 SLC, ALOS PALSAR)
42
+ - Planet Labs, Sentinel Hub, Maxar, Airbus, OpenTopography and more
@@ -0,0 +1,11 @@
1
+ include README.md
2
+ include LICENSE
3
+ include CHANGELOG.md
4
+ include pyproject.toml
5
+ recursive-include pygeofetch *.py *.yaml *.typed
6
+ recursive-exclude * __pycache__
7
+ recursive-exclude * *.pyc
8
+ recursive-exclude * *.pyo
9
+ exclude tests
10
+ exclude notebooks
11
+ exclude docs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 1.0.6
3
+ Version: 1.0.8
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.6 — Universal Satellite Data Pipeline.
2
+ PyGeoFetch v1.0.8 — 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.6"
33
+ __version__ = "1.0.8"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch CLI v1.0.6 main entry point.
2
+ PyGeoFetch CLI v1.0.8 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.6 — Universal Satellite Data Pipeline
62
+ PyGeoFetch v1.0.8 — 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:
@@ -519,55 +536,94 @@ class AdaptiveDownloader:
519
536
 
520
537
  def _validate_downloaded_file(self, path) -> tuple:
521
538
  """
522
- Validate a downloaded raster file by reading a sample tile.
539
+ Validate a downloaded file.
523
540
 
524
- For non-raster formats (JSON, XML, CSV), checks file size > 0.
541
+ Strategy by file type:
542
+ .zip / .tar.gz — check it's a valid archive (not truncated)
543
+ .tif / .tiff — open with rasterio and read one tile
544
+ .jp2 — open with rasterio (GDAL driver)
545
+ .nc — check size > 0 (netCDF, GDAL can open but slow)
546
+ .json / .xml / .csv / .txt / .parquet — check size > 0
547
+ anything else — check size > 0 (safe fallback, never fail good data)
525
548
 
526
549
  Returns:
527
550
  (is_valid: bool, error_message: str)
528
551
  """
529
552
  import os
530
- path = str(path)
531
- suffix = path.rsplit(".", 1)[-1].lower() if "." in path else ""
553
+ path_str = str(path)
554
+ path_obj = Path(path_str)
555
+ suffix = path_obj.suffix.lower()
532
556
 
533
- # Non-raster: just check readable and non-empty
534
- NON_RASTER = {"json", "xml", "csv", "txt", "html", "md", "yaml", "yml"}
535
- if suffix in NON_RASTER:
557
+ # ── 0. File must exist and be non-empty ───────────────────────────
558
+ try:
559
+ size = os.path.getsize(path_str)
560
+ except OSError as exc:
561
+ return False, f"Cannot stat file: {exc}"
562
+ if size == 0:
563
+ return False, "File is empty (0 bytes)"
564
+
565
+ # ── 1. ZIP / TAR archives — validate archive structure ─────────────
566
+ if suffix == ".zip" or path_str.endswith(".zip"):
536
567
  try:
537
- size = os.path.getsize(path)
538
- if size == 0:
539
- return False, "File is empty (0 bytes)"
568
+ import zipfile
569
+ with zipfile.ZipFile(path_str) as zf:
570
+ bad = zf.testzip()
571
+ if bad:
572
+ return False, f"ZIP is corrupt — bad entry: {bad}"
573
+ return True, ""
574
+ except zipfile.BadZipFile as exc:
575
+ return False, f"Invalid ZIP file: {exc}"
576
+ except Exception as exc:
577
+ # If we can't import zipfile or something weird — just trust size
540
578
  return True, ""
541
- except OSError as exc:
542
- return False, f"Cannot stat file: {exc}"
543
579
 
544
- # Raster: try rasterio
545
- try:
546
- import rasterio
547
- with rasterio.open(path) as src:
548
- if src.count == 0:
549
- return False, "File has zero bands"
550
- if src.width == 0 or src.height == 0:
551
- return False, f"File has zero dimensions: {src.width}x{src.height}"
552
- windows = list(src.block_windows(1))
553
- if not windows:
554
- return False, "File has no readable blocks"
555
- _, window = windows[0]
556
- sample = src.read(1, window=window)
557
- if sample is None or sample.size == 0:
558
- return False, "First tile returned empty array"
559
- return True, ""
560
- except ImportError:
561
- # rasterio not installed — fall back to size check
580
+ if suffix in (".gz", ".tgz") or path_str.endswith(".tar.gz"):
581
+ try:
582
+ import tarfile
583
+ with tarfile.open(path_str) as tf:
584
+ tf.getmembers()
585
+ return True, ""
586
+ except tarfile.TarError as exc:
587
+ return False, f"Invalid TAR file: {exc}"
588
+ except Exception:
589
+ return True, ""
590
+
591
+ # ── 2. Non-raster text/data formats — size check only ─────────────
592
+ NON_RASTER = {
593
+ ".json", ".geojson", ".xml", ".csv", ".txt",
594
+ ".html", ".md", ".yaml", ".yml", ".parquet", ".nc",
595
+ ".EOF", # orbit files
596
+ }
597
+ if suffix in NON_RASTER or not suffix:
598
+ return True, "" # already passed size > 0 above
599
+
600
+ # ── 3. Raster formats — open with rasterio ────────────────────────
601
+ RASTER_SUFFIXES = {".tif", ".tiff", ".jp2", ".img", ".vrt",
602
+ ".hdf", ".h4", ".h5", ".hdf5"}
603
+ if suffix in RASTER_SUFFIXES:
562
604
  try:
563
- size = os.path.getsize(path)
564
- if size == 0:
565
- return False, "File is empty (0 bytes)"
605
+ import rasterio
606
+ with rasterio.open(path_str) as src:
607
+ if src.count == 0:
608
+ return False, "Raster has zero bands"
609
+ if src.width == 0 or src.height == 0:
610
+ return False, f"Raster has zero dimensions: {src.width}x{src.height}"
611
+ # Read one small tile to confirm data is accessible
612
+ windows = list(src.block_windows(1))
613
+ if windows:
614
+ _, window = windows[0]
615
+ sample = src.read(1, window=window)
616
+ if sample is None or sample.size == 0:
617
+ return False, "First tile read returned empty array"
618
+ return True, ""
619
+ except ImportError:
620
+ # rasterio not installed — size check already passed
566
621
  return True, ""
567
- except OSError as exc:
568
- return False, f"Cannot stat file: {exc}"
569
- except Exception as exc:
570
- return False, f"File read validation failed: {exc}"
622
+ except Exception as exc:
623
+ return False, f"Raster read failed: {exc}"
624
+
625
+ # ── 4. Unknown extension — trust size check (already passed) ──────
626
+ return True, ""
571
627
 
572
628
  def _has_identity_transform(self, path) -> bool:
573
629
  """
@@ -673,4 +729,4 @@ class AdaptiveDownloader:
673
729
  @property
674
730
  def _result_total_size_mb(self) -> float:
675
731
  """Property used by result objects."""
676
- return 0.0
732
+ return 0.0