PyGeoFetch 1.0.7__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 (87) hide show
  1. pygeofetch-1.0.8/CHANGELOG.md +42 -0
  2. pygeofetch-1.0.8/MANIFEST.in +11 -0
  3. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/PKG-INFO +1 -1
  4. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/__init__.py +2 -2
  5. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/main.py +2 -2
  6. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/downloader.py +76 -37
  7. pygeofetch-1.0.8/pygeofetch/meta.yaml +62 -0
  8. pygeofetch-1.0.8/pygeofetch/processing/base.py +315 -0
  9. pygeofetch-1.0.8/pygeofetch/processing/indices.py +685 -0
  10. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/preprocessor.py +23 -7
  11. pygeofetch-1.0.8/pygeofetch/processing/sar.py +336 -0
  12. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/PKG-INFO +1 -1
  13. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/SOURCES.txt +3 -0
  14. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pyproject.toml +1 -1
  15. pygeofetch-1.0.7/pygeofetch/processing/base.py +0 -102
  16. pygeofetch-1.0.7/pygeofetch/processing/indices.py +0 -777
  17. pygeofetch-1.0.7/pygeofetch/processing/sar.py +0 -315
  18. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/LICENSE +0 -0
  19. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/README.md +0 -0
  20. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/__init__.py +0 -0
  21. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/auth_commands.py +0 -0
  22. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/config_commands.py +0 -0
  23. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/download_commands.py +0 -0
  24. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/index_commands.py +0 -0
  25. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  26. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/postprocess_commands.py +0 -0
  27. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/preprocess_commands.py +0 -0
  28. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/sar_commands.py +0 -0
  29. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/search_commands.py +0 -0
  30. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/config/__init__.py +0 -0
  31. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/config/defaults.yaml +0 -0
  32. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/config/settings.py +0 -0
  33. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/__init__.py +0 -0
  34. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/authenticator.py +0 -0
  35. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/cache_manager.py +0 -0
  36. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/engine.py +0 -0
  37. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/logging.py +0 -0
  38. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/orbits.py +0 -0
  39. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/scheduler.py +0 -0
  40. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/searcher.py +0 -0
  41. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/__init__.py +0 -0
  42. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/download_task.py +0 -0
  43. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/satellite_data.py +0 -0
  44. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/search_query.py +0 -0
  45. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/user_auth.py +0 -0
  46. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/__init__.py +0 -0
  47. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/batch.py +0 -0
  48. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/pipeline.py +0 -0
  49. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/postprocessor.py +0 -0
  50. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/__init__.py +0 -0
  51. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/airbus_oneatlas.py +0 -0
  52. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
  53. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/aws_earth.py +0 -0
  54. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/base.py +0 -0
  55. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/copernicus.py +0 -0
  56. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/digitalglobe.py +0 -0
  57. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/earth_explorer_additional.py +0 -0
  58. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/element84.py +0 -0
  59. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/esa_scihub.py +0 -0
  60. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/geoserver_generic.py +0 -0
  61. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/google_earth_engine.py +0 -0
  62. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/inpe_cbers.py +0 -0
  63. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/isro_bhuvan.py +0 -0
  64. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/jaxa_earth.py +0 -0
  65. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/maxar_gbdx.py +0 -0
  66. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/nasa_earthdata.py +0 -0
  67. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  68. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/noaa_big_data.py +0 -0
  69. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/opentopography.py +0 -0
  70. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/planet.py +0 -0
  71. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/planetary_computer.py +0 -0
  72. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/sentinel_hub.py +0 -0
  73. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/terrabotics.py +0 -0
  74. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/usgs.py +0 -0
  75. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/__init__.py +0 -0
  76. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/file_utils.py +0 -0
  77. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/geo_utils.py +0 -0
  78. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/logging_setup.py +0 -0
  79. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/retry_handler.py +0 -0
  80. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/validators.py +0 -0
  81. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/dependency_links.txt +0 -0
  82. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/entry_points.txt +0 -0
  83. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/not-zip-safe +0 -0
  84. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/requires.txt +0 -0
  85. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/top_level.txt +0 -0
  86. {pygeofetch-1.0.7 → pygeofetch-1.0.8}/setup.cfg +0 -0
  87. {pygeofetch-1.0.7 → 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.7
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.7 — 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.7"
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.7 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.7 — 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
 
@@ -536,55 +536,94 @@ class AdaptiveDownloader:
536
536
 
537
537
  def _validate_downloaded_file(self, path) -> tuple:
538
538
  """
539
- Validate a downloaded raster file by reading a sample tile.
539
+ Validate a downloaded file.
540
540
 
541
- 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)
542
548
 
543
549
  Returns:
544
550
  (is_valid: bool, error_message: str)
545
551
  """
546
552
  import os
547
- path = str(path)
548
- 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()
549
556
 
550
- # Non-raster: just check readable and non-empty
551
- NON_RASTER = {"json", "xml", "csv", "txt", "html", "md", "yaml", "yml"}
552
- 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"):
553
567
  try:
554
- size = os.path.getsize(path)
555
- if size == 0:
556
- 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
557
578
  return True, ""
558
- except OSError as exc:
559
- return False, f"Cannot stat file: {exc}"
560
579
 
561
- # Raster: try rasterio
562
- try:
563
- import rasterio
564
- with rasterio.open(path) as src:
565
- if src.count == 0:
566
- return False, "File has zero bands"
567
- if src.width == 0 or src.height == 0:
568
- return False, f"File has zero dimensions: {src.width}x{src.height}"
569
- windows = list(src.block_windows(1))
570
- if not windows:
571
- return False, "File has no readable blocks"
572
- _, window = windows[0]
573
- sample = src.read(1, window=window)
574
- if sample is None or sample.size == 0:
575
- return False, "First tile returned empty array"
576
- return True, ""
577
- except ImportError:
578
- # 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:
579
604
  try:
580
- size = os.path.getsize(path)
581
- if size == 0:
582
- 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"
583
618
  return True, ""
584
- except OSError as exc:
585
- return False, f"Cannot stat file: {exc}"
586
- except Exception as exc:
587
- return False, f"File read validation failed: {exc}"
619
+ except ImportError:
620
+ # rasterio not installed — size check already passed
621
+ return True, ""
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, ""
588
627
 
589
628
  def _has_identity_transform(self, path) -> bool:
590
629
  """
@@ -0,0 +1,62 @@
1
+ {% set version = "1.0.8" %}
2
+
3
+ package:
4
+ name: pygeofetch
5
+ version: {{ version }}
6
+
7
+ source:
8
+ url: https://pypi.org/packages/source/p/pygeofetch/pygeofetch-{{ version }}.tar.gz
9
+ sha256: 0307001e4a585f035c709ccc61c7b23217280a7ddf2775229600340d878b5cf2
10
+
11
+ build:
12
+ entry_points:
13
+ - pygeofetch = pygeofetch.cli.main:cli
14
+ noarch: python
15
+ script: {{ PYTHON }} -m pip install . -vv --no-deps --no-build-isolation
16
+ number: 0
17
+
18
+ requirements:
19
+ host:
20
+ - python >=3.9
21
+ - setuptools >=68
22
+ - wheel
23
+ - pip
24
+ run:
25
+ - python >=3.9
26
+ - httpx >=0.27
27
+ - click >=8.1
28
+ - pydantic >=2.5
29
+ - pydantic-settings >=2.1
30
+ - rich >=13.7
31
+ - pyyaml >=6.0
32
+ - cryptography >=42.0
33
+ - keyring >=24.3
34
+ - aiofiles >=23.2
35
+ - tenacity >=8.2
36
+ - python-dateutil >=2.9
37
+ - tqdm >=4.66
38
+ - requests >=2.31
39
+ - anyio >=4.2
40
+ - boto3 >=1.34
41
+ - click-completion >=0.5
42
+
43
+ test:
44
+ imports:
45
+ - pygeofetch
46
+ commands:
47
+ - pip check
48
+ - pygeofetch --help
49
+ requires:
50
+ - pip
51
+ - python
52
+
53
+ about:
54
+ home: https://github.com/appiahkubis14/pygeofetch
55
+ summary: Universal satellite data pipeline — unified access to 22+ providers
56
+ doc_url: https://pygeofetch.readthedocs.io
57
+ license: MIT
58
+ license_file: LICENSE
59
+
60
+ extra:
61
+ recipe-maintainers:
62
+ - appiahkubis14
@@ -0,0 +1,315 @@
1
+ """Base classes, result types, and shared I/O helpers for the processing engine."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Tuple, Union
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ # ══════════════════════════════════════════════════════════════════════════════
14
+ # Result type
15
+ # ══════════════════════════════════════════════════════════════════════════════
16
+
17
+ @dataclass
18
+ class ProcessingResult:
19
+ """Outcome of a single processing step."""
20
+ success: bool
21
+ output_path: Optional[Path] = None
22
+ input_path: Optional[Path] = None
23
+ operation: str = ""
24
+ duration_seconds: float = 0.0
25
+ metadata: Dict[str, Any] = field(default_factory=dict)
26
+ error: Optional[str] = None
27
+
28
+ @property
29
+ def path(self) -> Optional[Path]:
30
+ return self.output_path
31
+
32
+ def __str__(self) -> str:
33
+ status = "✓" if self.success else "✗"
34
+ out = self.output_path.name if self.output_path else "none"
35
+ return f"{status} {self.operation} → {out} ({self.duration_seconds:.2f}s)"
36
+
37
+
38
+ # ══════════════════════════════════════════════════════════════════════════════
39
+ # Dependency guards
40
+ # ══════════════════════════════════════════════════════════════════════════════
41
+
42
+ def _require_rasterio():
43
+ try:
44
+ import rasterio
45
+ return rasterio
46
+ except ImportError:
47
+ raise ImportError(
48
+ "rasterio is required for raster processing.\n"
49
+ "Install with: pip install rasterio\n"
50
+ "Or: pip install \"PyGeoFetch[geo]\""
51
+ )
52
+
53
+
54
+ def _require_numpy():
55
+ try:
56
+ import numpy as np
57
+ return np
58
+ except ImportError:
59
+ raise ImportError("numpy is required: pip install numpy")
60
+
61
+
62
+ def _require_scipy():
63
+ try:
64
+ from scipy import ndimage
65
+ return ndimage
66
+ except ImportError:
67
+ raise ImportError("scipy is required: pip install scipy")
68
+
69
+
70
+ def _require_geopandas():
71
+ try:
72
+ import geopandas as gpd
73
+ return gpd
74
+ except ImportError:
75
+ raise ImportError(
76
+ "geopandas is required for vector operations.\n"
77
+ "Install with: pip install geopandas"
78
+ )
79
+
80
+
81
+ def _require_shapely():
82
+ try:
83
+ import shapely
84
+ return shapely
85
+ except ImportError:
86
+ raise ImportError("shapely is required: pip install shapely")
87
+
88
+
89
+ # ══════════════════════════════════════════════════════════════════════════════
90
+ # _timed decorator — catches exceptions → returns ProcessingResult(success=False)
91
+ # ══════════════════════════════════════════════════════════════════════════════
92
+
93
+ def _timed(func):
94
+ """
95
+ Measure execution time.
96
+
97
+ On success: sets result.duration_seconds and returns result.
98
+ On exception: logs the error and returns ProcessingResult(success=False, error=...)
99
+ so callers always get a result object, never a raw exception.
100
+ """
101
+ from functools import wraps
102
+
103
+ @wraps(func)
104
+ def wrapper(*args, **kwargs):
105
+ t0 = time.time()
106
+ try:
107
+ result = func(*args, **kwargs)
108
+ if isinstance(result, ProcessingResult):
109
+ result.duration_seconds = time.time() - t0
110
+ return result
111
+ except Exception as exc:
112
+ elapsed = time.time() - t0
113
+ op = func.__name__.replace("_cmd", "")
114
+ logger.error("%s failed after %.2fs: %s", op, elapsed, exc, exc_info=True)
115
+ return ProcessingResult(
116
+ success=False,
117
+ operation=op,
118
+ duration_seconds=elapsed,
119
+ error=str(exc),
120
+ )
121
+
122
+ return wrapper
123
+
124
+
125
+ # ══════════════════════════════════════════════════════════════════════════════
126
+ # Output path resolution
127
+ # ══════════════════════════════════════════════════════════════════════════════
128
+
129
+ def _resolve_output(
130
+ input_path: Optional[Path],
131
+ output: Optional[str],
132
+ suffix: str,
133
+ ) -> Path:
134
+ """Build an output path from input + suffix when output is not specified."""
135
+ if output:
136
+ out = Path(output)
137
+ if out.is_dir() or str(output).endswith("/"):
138
+ out.mkdir(parents=True, exist_ok=True)
139
+ stem = input_path.stem if input_path else "output"
140
+ return out / f"{stem}_{suffix}.tif"
141
+ out.parent.mkdir(parents=True, exist_ok=True)
142
+ return out
143
+ p = input_path or Path("output.tif")
144
+ return p.parent / f"{p.stem}_{suffix}.tif"
145
+
146
+
147
+ # ══════════════════════════════════════════════════════════════════════════════
148
+ # Shared raster I/O — robust to tiled / COG / compressed GeoTIFFs
149
+ # ══════════════════════════════════════════════════════════════════════════════
150
+
151
+ def _safe_read_band(
152
+ path: Union[str, Path],
153
+ band: int = 1,
154
+ out_shape: Optional[Tuple[int, int]] = None,
155
+ ) -> Tuple["Any", dict, Any]:
156
+ """
157
+ Read a single band from any GeoTIFF, including tiled and COG formats.
158
+
159
+ Strategy:
160
+ 1. Try the fast path: ``src.read(band)`` — works for stripped TIFFs.
161
+ 2. If that fails (tiled COG, DEFLATE-encoded, JPEG-compressed tiles),
162
+ fall back to block-by-block reading which rasterio handles tile by tile.
163
+ 3. Apply optional resampling to ``out_shape`` after reading.
164
+
165
+ Args:
166
+ path: Path to a raster file.
167
+ band: 1-based band index to read.
168
+ out_shape: Optional (height, width) to resample to after reading.
169
+
170
+ Returns:
171
+ (data, profile, nodata)
172
+ data — float32 numpy array, NaN where nodata
173
+ profile — rasterio profile dict for writing output
174
+ nodata — original nodata value (may be None)
175
+ """
176
+ rasterio = _require_rasterio()
177
+ np = _require_numpy()
178
+
179
+ p = Path(path)
180
+ if not p.exists():
181
+ raise FileNotFoundError(f"Raster not found: {p}")
182
+
183
+ with rasterio.open(p) as src:
184
+ if band > src.count:
185
+ raise ValueError(
186
+ f"Band {band} requested but file has only {src.count} band(s): {p.name}"
187
+ )
188
+
189
+ nodata = src.nodata
190
+ profile = src.profile.copy()
191
+ h, w = src.height, src.width
192
+
193
+ # ── attempt 1: fast whole-array read ──────────────────────────────
194
+ try:
195
+ data = src.read(band).astype(np.float32)
196
+ except Exception as fast_exc:
197
+ logger.debug(
198
+ "Fast read failed for %s (band %d): %s — using block-by-block fallback",
199
+ p.name, band, fast_exc,
200
+ )
201
+ # ── attempt 2: block-by-block read ────────────────────────────
202
+ data = np.empty((h, w), dtype=np.float32)
203
+ data[:] = np.nan
204
+
205
+ try:
206
+ for _, window in src.block_windows(band):
207
+ row_off = window.row_off
208
+ col_off = window.col_off
209
+ row_end = row_off + window.height
210
+ col_end = col_off + window.width
211
+ try:
212
+ block = src.read(band, window=window).astype(np.float32)
213
+ data[row_off:row_end, col_off:col_end] = block
214
+ except Exception as block_exc:
215
+ # Fill failed blocks with nodata/NaN — don't crash
216
+ logger.warning(
217
+ "Block read failed at (%d,%d) in %s: %s — filling NaN",
218
+ row_off, col_off, p.name, block_exc,
219
+ )
220
+ fill = float(nodata) if nodata is not None else np.nan
221
+ data[row_off:row_end, col_off:col_end] = fill
222
+
223
+ except Exception as block_exc2:
224
+ raise RuntimeError(
225
+ f"Both fast-read and block-read failed for {p.name} band {band}.\n"
226
+ f"Fast error: {fast_exc}\n"
227
+ f"Block error: {block_exc2}\n"
228
+ "The file may be corrupt. Try: gdalinfo -checksum <file>"
229
+ ) from block_exc2
230
+
231
+ # ── nodata → NaN ──────────────────────────────────────────────────────
232
+ if nodata is not None:
233
+ nd = float(nodata)
234
+ if np.isnan(nd):
235
+ # nodata is NaN — already NaN in float32, nothing to do
236
+ pass
237
+ else:
238
+ data = np.where(data == nd, np.nan, data)
239
+
240
+ # ── optional resampling ───────────────────────────────────────────────
241
+ if out_shape is not None and out_shape != (h, w):
242
+ from scipy.ndimage import zoom
243
+ zoom_factors = (out_shape[0] / h, out_shape[1] / w)
244
+ data = zoom(data, zoom_factors, order=1, mode="nearest").astype(np.float32)
245
+
246
+ return data, profile, nodata
247
+
248
+
249
+ def _safe_write_band(
250
+ data: "Any",
251
+ profile: dict,
252
+ out_path: Path,
253
+ nodata: float = -9999.0,
254
+ compress: str = "deflate",
255
+ tiled: bool = True,
256
+ blocksize: int = 256,
257
+ ) -> None:
258
+ """
259
+ Write a float32 array to a GeoTIFF, always with valid compression settings.
260
+
261
+ Cleans the inherited profile to remove settings incompatible with float32
262
+ (e.g. JPEG compression, photometric=RGB, etc.).
263
+
264
+ Args:
265
+ data: 2-D or 3-D (bands, h, w) float32 numpy array.
266
+ profile: Base profile (from source raster). Will be updated safely.
267
+ out_path: Destination path.
268
+ nodata: Nodata value for NaN pixels.
269
+ compress: Compression: ``"deflate"`` (default), ``"lzw"``, ``"zstd"``, ``None``.
270
+ tiled: Write as tiled (COG-ready) GeoTIFF.
271
+ blocksize: Tile size in pixels (must be power of 2).
272
+ """
273
+ np = _require_numpy()
274
+ rasterio = _require_rasterio()
275
+
276
+ if data.ndim == 2:
277
+ data_3d = data[np.newaxis, :, :]
278
+ n_bands = 1
279
+ else:
280
+ data_3d = data
281
+ n_bands = data.shape[0]
282
+
283
+ h, w = data_3d.shape[1], data_3d.shape[2]
284
+
285
+ # Replace NaN with nodata value
286
+ data_3d = np.where(np.isnan(data_3d), nodata, data_3d).astype(np.float32)
287
+
288
+ # Build a clean profile — remove anything incompatible with float32
289
+ clean = {
290
+ "driver": "GTiff",
291
+ "dtype": "float32",
292
+ "count": n_bands,
293
+ "height": h,
294
+ "width": w,
295
+ "nodata": nodata,
296
+ "crs": profile.get("crs"),
297
+ "transform": profile.get("transform"),
298
+ }
299
+
300
+ # Only add compression if specified
301
+ if compress:
302
+ clean["compress"] = compress
303
+ if compress in ("deflate", "zstd"):
304
+ clean["predictor"] = 2 # horizontal differencing for float data
305
+
306
+ # Tiling for COG-ready output
307
+ if tiled and blocksize:
308
+ clean["tiled"] = True
309
+ clean["blockxsize"] = blocksize
310
+ clean["blockysize"] = blocksize
311
+
312
+ out_path.parent.mkdir(parents=True, exist_ok=True)
313
+
314
+ with rasterio.open(out_path, "w", **clean) as dst:
315
+ dst.write(data_3d)