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.
- pygeofetch-1.0.8/CHANGELOG.md +42 -0
- pygeofetch-1.0.8/MANIFEST.in +11 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/PKG-INFO +1 -1
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/__init__.py +2 -2
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/main.py +2 -2
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/downloader.py +76 -37
- pygeofetch-1.0.8/pygeofetch/meta.yaml +62 -0
- pygeofetch-1.0.8/pygeofetch/processing/base.py +315 -0
- pygeofetch-1.0.8/pygeofetch/processing/indices.py +685 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/preprocessor.py +23 -7
- pygeofetch-1.0.8/pygeofetch/processing/sar.py +336 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/PKG-INFO +1 -1
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/SOURCES.txt +3 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pyproject.toml +1 -1
- pygeofetch-1.0.7/pygeofetch/processing/base.py +0 -102
- pygeofetch-1.0.7/pygeofetch/processing/indices.py +0 -777
- pygeofetch-1.0.7/pygeofetch/processing/sar.py +0 -315
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/LICENSE +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/README.md +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/__init__.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/auth_commands.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/config_commands.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/download_commands.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/index_commands.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/pipeline_process_commands.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/postprocess_commands.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/preprocess_commands.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/sar_commands.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/cli/search_commands.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/config/__init__.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/config/defaults.yaml +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/config/settings.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/__init__.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/authenticator.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/cache_manager.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/engine.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/logging.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/orbits.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/scheduler.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/core/searcher.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/__init__.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/download_task.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/satellite_data.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/search_query.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/models/user_auth.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/__init__.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/batch.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/pipeline.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/processing/postprocessor.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/__init__.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/airbus_oneatlas.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/aws_earth.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/base.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/copernicus.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/digitalglobe.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/earth_explorer_additional.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/element84.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/esa_scihub.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/geoserver_generic.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/google_earth_engine.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/inpe_cbers.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/isro_bhuvan.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/jaxa_earth.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/maxar_gbdx.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/nasa_earthdata.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/noaa_big_data.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/opentopography.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/planet.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/planetary_computer.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/sentinel_hub.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/terrabotics.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/providers/usgs.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/__init__.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/file_utils.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/geo_utils.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/logging_setup.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/retry_handler.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch/utils/validators.py +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/dependency_links.txt +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/entry_points.txt +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/not-zip-safe +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/requires.txt +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/pygeofetch.egg-info/top_level.txt +0 -0
- {pygeofetch-1.0.7 → pygeofetch-1.0.8}/setup.cfg +0 -0
- {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,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
PyGeoFetch v1.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
539
|
+
Validate a downloaded file.
|
|
540
540
|
|
|
541
|
-
|
|
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
|
-
|
|
548
|
-
|
|
553
|
+
path_str = str(path)
|
|
554
|
+
path_obj = Path(path_str)
|
|
555
|
+
suffix = path_obj.suffix.lower()
|
|
549
556
|
|
|
550
|
-
#
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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)
|