pygeodata 0.0.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Jasper Roebroek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygeodata
3
+ Version: 0.0.0
4
+ Summary: Lightweight framework for geospatial data access and processing
5
+ Author-email: Jasper Roebroek <roebroek.jasper@gmail.com>
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: rioxarray
12
+ Requires-Dist: geopandas
13
+ Requires-Dist: dask
14
+ Requires-Dist: zarr
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest; extra == "test"
17
+ Requires-Dist: pytest-mock; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # pygeodata
21
+
22
+ **Lightweight framework for geospatial data access and processing**
23
+
24
+ `pygeodata` provides simple, composable tools for working with geospatial raster and vector data. It aims to make spatial data handling consistent, efficient, and compatible with large-scale and cloud-based workflows.
25
+
26
+ ## Features
27
+
28
+ - Unified access to raster and vector datasets
29
+ - CRS-aware reprojection and alignment utilities
30
+ - Clean, composable API designed for scientific workflows
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install pygeodata
36
+ ```
@@ -0,0 +1,17 @@
1
+ # pygeodata
2
+
3
+ **Lightweight framework for geospatial data access and processing**
4
+
5
+ `pygeodata` provides simple, composable tools for working with geospatial raster and vector data. It aims to make spatial data handling consistent, efficient, and compatible with large-scale and cloud-based workflows.
6
+
7
+ ## Features
8
+
9
+ - Unified access to raster and vector datasets
10
+ - CRS-aware reprojection and alignment utilities
11
+ - Clean, composable API designed for scientific workflows
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install pygeodata
17
+ ```
@@ -0,0 +1,12 @@
1
+ from pygeodata.base import load, process
2
+ from pygeodata.config import set_config
3
+ from pygeodata.loader import DataLoader
4
+
5
+ __version__ = '0.0.2'
6
+
7
+ __all__ = [
8
+ 'DataLoader',
9
+ 'load',
10
+ 'process',
11
+ 'set_config',
12
+ ]
@@ -0,0 +1,21 @@
1
+ from typing import Any
2
+
3
+ from pygeodata.config import get_config
4
+ from pygeodata.loader import DataLoader
5
+ from pygeodata.types import SpatialSpec
6
+
7
+
8
+ def load(loader: DataLoader, spec: SpatialSpec | None = None) -> Any:
9
+ spec = spec or get_config().spec
10
+ if spec is None:
11
+ raise ValueError('No spatial specification (spec) provided or present in config')
12
+ return loader(spec)
13
+
14
+
15
+ def process(loader: DataLoader, spec: SpatialSpec | None = None) -> None:
16
+ spec = spec or get_config().spec
17
+ if spec is None:
18
+ raise ValueError('No spatial specification (spec) provided or present in config')
19
+ if loader.is_processed(spec):
20
+ return
21
+ loader.process(spec)
@@ -0,0 +1,40 @@
1
+ from collections.abc import Iterator
2
+ from contextlib import contextmanager
3
+ from dataclasses import dataclass, field, fields
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pygeodata.options import RasterCreationOptions
8
+ from pygeodata.types import SpatialSpec
9
+
10
+
11
+ @dataclass
12
+ class Config:
13
+ path_data_processed: Path = Path('data_processed')
14
+ num_threads: int = 1
15
+ warp_mem_limit: int = 0 # GDAL default, indicates 64 MB
16
+ spec: SpatialSpec | None = None
17
+ raster_creation_options: RasterCreationOptions = field(default_factory=RasterCreationOptions)
18
+
19
+ def update(self, **kwargs):
20
+ for key, value in kwargs.items():
21
+ if not hasattr(self, key):
22
+ raise ValueError(f'Invalid config key: {key}')
23
+ setattr(self, key, value)
24
+
25
+
26
+ CONFIG = Config()
27
+
28
+
29
+ def get_config() -> Config:
30
+ return CONFIG
31
+
32
+
33
+ @contextmanager
34
+ def set_config(**overrides: Any) -> Iterator[Config]:
35
+ old_values = {k.name: getattr(CONFIG, k.name) for k in fields(CONFIG)}
36
+ CONFIG.update(**overrides)
37
+ try:
38
+ yield CONFIG
39
+ finally:
40
+ CONFIG.update(**old_values)
@@ -0,0 +1,3 @@
1
+ from pygeodata.drivers.rioxarray import RioXArrayDriver
2
+
3
+ __all__ = ['RioXArrayDriver']
@@ -0,0 +1,92 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ import rasterio as rio
6
+ import rioxarray as rxr
7
+ import xarray as xr
8
+ from rioxarray.exceptions import TooManyDimensions
9
+
10
+
11
+ @dataclass
12
+ class RioXArrayDriver:
13
+ """Load a raster file using rioxarray.
14
+
15
+ Parameters
16
+ ----------
17
+ parse_coordinates : bool, optional
18
+ Whether to parse coordinates
19
+ mask_and_scale : bool, optional
20
+ Whether to mask and scale
21
+ decode_times : bool, optional
22
+ Whether to decode times, by default False
23
+ cache : bool, optional
24
+ Whether to cache, by default False
25
+ flatten : bool, optional
26
+ By default 2D rasters will be returned as 3D, with a band dimension of size 1. If True, which is the
27
+ default, this dimension is removed.
28
+ boolean : bool, optional
29
+ Whether to convert the data to boolean, by default False
30
+ open_kw : dict, optional
31
+ Additional keyword arguments to pass to rioxarray.open_rasterio
32
+ """
33
+
34
+ parse_coordinates: bool = True
35
+ mask_and_scale: bool = True
36
+ decode_times: bool = False
37
+ cache: bool = False
38
+ flatten: bool = True
39
+ boolean: bool = False
40
+ open_kw: dict = field(default_factory=dict)
41
+
42
+ @staticmethod
43
+ def _subdatasets(path: Path) -> list[str]:
44
+ with rio.open(path) as fp:
45
+ return fp.subdatasets
46
+
47
+ def _has_subdatasets(self, path: Path) -> bool:
48
+ return len(self._subdatasets(path)) > 0
49
+
50
+ @staticmethod
51
+ def _infer_mask_and_scale(path: Path) -> bool:
52
+ with rio.open(path) as fp:
53
+ return np.issubdtype(fp.dtypes[0], np.floating)
54
+
55
+ @staticmethod
56
+ def _assert_exists(path: Path) -> None:
57
+ with rio.open(path):
58
+ pass
59
+
60
+ def __call__(self, path: str | Path) -> xr.DataArray:
61
+ path = Path(path)
62
+
63
+ self._assert_exists(path)
64
+
65
+ if self._has_subdatasets(path):
66
+ subs_str = '\n'.join(self._subdatasets(path))
67
+ raise TooManyDimensions(f'Multiple variables found in {path}. Use one of the subdatasets:\n{subs_str}')
68
+
69
+ try:
70
+ da = rxr.open_rasterio(
71
+ path,
72
+ parse_coordinates=self.parse_coordinates,
73
+ decode_times=self.decode_times,
74
+ cache=self.cache,
75
+ mask_and_scale=self.mask_and_scale,
76
+ **self.open_kw,
77
+ )
78
+ except Exception as e:
79
+ raise type(e)(f'Failed to load {path}: {e}') from e
80
+
81
+ if isinstance(da, xr.Dataset):
82
+ raise TooManyDimensions(f'Dataset found in {path}. Use one of the subdatasets.')
83
+
84
+ if self.flatten and 'band' in da.dims and da.sizes['band'] == 1:
85
+ da = da.isel(band=0).drop_vars('band')
86
+
87
+ if self.boolean:
88
+ da = da.astype(bool)
89
+
90
+ return da
91
+
92
+ default_ext = 'tif'
@@ -0,0 +1,90 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from pygeodata.config import get_config
6
+ from pygeodata.paths import generate_path
7
+ from pygeodata.types import Driver, Processor, SpatialSpec
8
+
9
+
10
+ class DataLoader:
11
+ @property
12
+ def processor(self) -> Processor:
13
+ raise NotImplementedError(f'{self}: Either processor or driver must be implemented')
14
+
15
+ @property
16
+ def driver(self) -> Driver:
17
+ try:
18
+ processor = self.processor
19
+ except NotImplementedError:
20
+ raise NotImplementedError(f'{self}: Either processor or driver must be implemented')
21
+
22
+ if not hasattr(processor, 'default_driver'):
23
+ raise AttributeError(f'Processor {processor} lacks default_driver and no driver is set')
24
+ return getattr(processor, 'default_driver')
25
+
26
+ @property
27
+ def class_name(self) -> str:
28
+ return self.__class__.__name__.split('.')[-1].replace('Loader', '')
29
+
30
+ @property
31
+ def name(self) -> str:
32
+ # Handle acronym → word transitions (e.g. XMLHTTPRequest → XML_Http_Request)
33
+ s1 = re.sub('([A-Z]+)([A-Z][a-z])', r'\1_\2', self.class_name)
34
+ # Handle normal camelCase → camel_Case
35
+ s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
36
+ return s2.lower()
37
+
38
+ @property
39
+ def ext(self) -> str:
40
+ try:
41
+ ext = getattr(self.processor, 'ext', None)
42
+ except NotImplementedError:
43
+ ext = None
44
+ if ext is None:
45
+ ext = self.driver.default_ext
46
+ return ext
47
+
48
+ def get_params(self) -> dict[str, Any]:
49
+ params = {}
50
+ for key in self.__dict__:
51
+ if key in ('name', 'class_name', 'processor', 'driver', 'process', 'load'):
52
+ continue
53
+ if key.startswith('_'):
54
+ continue
55
+ params.update({key: self.__dict__[key]})
56
+ return params
57
+
58
+ def __repr__(self) -> str:
59
+ params = self.get_params()
60
+ parts = [f'{k}={v!r}' for k, v in sorted(params.items())]
61
+ return f'{self.class_name}({", ".join(parts)})'
62
+
63
+ def get_processed_path(self, spec: SpatialSpec, ext: str | None = None) -> Path:
64
+ ext = ext or self.ext
65
+
66
+ path = generate_path(
67
+ spec=spec,
68
+ name=self.class_name,
69
+ filename=self.name,
70
+ base_dir=get_config().path_data_processed,
71
+ ext=ext,
72
+ **self.get_params(),
73
+ )
74
+ path.parent.mkdir(exist_ok=True, parents=True)
75
+ return path
76
+
77
+ def is_processed(self, spec: SpatialSpec) -> bool:
78
+ p = self.get_processed_path(spec)
79
+ return p.exists()
80
+
81
+ def process(self, spec: SpatialSpec) -> None:
82
+ self.processor(self.get_processed_path(spec), spec)
83
+
84
+ def load(self, spec: SpatialSpec) -> Any:
85
+ return self.driver(self.get_processed_path(spec))
86
+
87
+ def __call__(self, spec: SpatialSpec) -> Any:
88
+ if not self.is_processed(spec):
89
+ self.process(spec)
90
+ return self.load(spec)
@@ -0,0 +1,45 @@
1
+ from dataclasses import asdict, dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass
6
+ class RasterCreationOptions:
7
+ """Rasterio creation profile options.
8
+
9
+ Parameters
10
+ ----------
11
+ compress : str, optional
12
+ Compression algorithm ('lzw', 'deflate', 'zstd', 'lzma', 'jpeg', 'webp')
13
+ compress_level : int, optional
14
+ Compression level (1-9 for deflate, 1-12 for zstd)
15
+ tiled : bool, default=False
16
+ Whether to create a tiled raster (False for striped layout)
17
+ blockxsize : int, optional
18
+ Tile width (defaults to 256 if tiled=True, must be multiple of 16)
19
+ blockysize : int, optional
20
+ Tile height (defaults to 256 if tiled=True, must be multiple of 16)
21
+ interleave : str, optional
22
+ Band interleave ('pixel', 'band', 'line')
23
+ photometric : str, optional
24
+ Photometric interpretation ('minisblack', 'rgb', 'ycbcr')
25
+ predictor : int, optional
26
+ Predictor for compression (1=none, 2=horizontal, 3=floating point)
27
+ bigtiff : bool | str, optional
28
+ Create BigTIFF file ('yes', 'no', 'if_needed', 'if_safer')
29
+ sparse_ok : bool, optional
30
+ Allow sparse files
31
+ """
32
+
33
+ compress: str | None = None
34
+ compress_level: int | None = None
35
+ tiled: bool | None = None
36
+ blockxsize: int | None = None
37
+ blockysize: int | None = None
38
+ interleave: str | None = None
39
+ photometric: str | None = None
40
+ predictor: int | None = None
41
+ bigtiff: bool | str | None = None
42
+ sparse_ok: bool | None = None
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return {k: v for k, v in asdict(self).items() if v is not None}
@@ -0,0 +1,33 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ from pygeodata.types import SpatialSpec
5
+ from pygeodata.utils import transform_to_str
6
+
7
+
8
+ def generate_path(
9
+ spec: SpatialSpec,
10
+ base_dir: str | Path,
11
+ filename: str,
12
+ ext: str,
13
+ name: str | None = None,
14
+ **kwargs,
15
+ ) -> Path:
16
+ """Function that converts a path of the data to the processed data."""
17
+ base_dir = Path(base_dir)
18
+
19
+ p = []
20
+ if name is not None:
21
+ p.append(name)
22
+
23
+ for key in sorted(kwargs.keys()):
24
+ p.append(f'{key}={kwargs[key]}')
25
+
26
+ return Path(
27
+ base_dir,
28
+ re.sub(r'[^\w\-]', '_', spec.crs.to_string()),
29
+ transform_to_str(spec.transform),
30
+ f'{spec.shape[0]}-{spec.shape[1]}',
31
+ *p,
32
+ f'{filename}.{ext}',
33
+ )
@@ -0,0 +1,4 @@
1
+ from pygeodata.processors.rasterizer import Rasterizer
2
+ from pygeodata.processors.reprojection import Reprojector
3
+
4
+ __all__ = ['Rasterizer', 'Reprojector']
@@ -0,0 +1,100 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import geopandas as gpd
7
+ import numpy as np
8
+ import rasterio
9
+ from rasterio.features import rasterize
10
+
11
+ from pygeodata.config import get_config
12
+ from pygeodata.drivers import RioXArrayDriver
13
+ from pygeodata.options import RasterCreationOptions
14
+ from pygeodata.types import SpatialSpec
15
+
16
+
17
+ @dataclass
18
+ class Rasterizer:
19
+ """
20
+ Rasterize a vector dataset to a single-band raster.
21
+
22
+ Parameters
23
+ ----------
24
+ path : Path
25
+ Path to the vector dataset (e.g., shapefile, GeoPackage).
26
+ column : str
27
+ Name of the column to use as raster values (must be numeric).
28
+ load_df_func : Callable[[str | Path, SpatialSpec], gpd.GeoDataFrame], optional
29
+ Function to load the vector data. By default, reads with geopandas and reprojects to `spec.crs`.
30
+ all_touched : bool, default=True
31
+ Whether to burn all pixels touched by geometries.
32
+ dtype : np.dtype, optional
33
+ Data type for raster. Defaults to the dtype of `column`.
34
+ fill_value : float, optional
35
+ Nodata value for raster. Defaults to `_NODATA_DTYPE_MAP` based on dtype.
36
+ rasterize_kw : dict, optional
37
+ Additional keyword arguments passed to `rasterio.features.rasterize`.
38
+ raster_creation_options : RasterCreationOptions, optional
39
+ Optional raster creation profile (compression, tiling, etc.).
40
+ """
41
+
42
+ path: Path
43
+ column: str = 'index'
44
+ load_df_func: Callable[[str | Path, SpatialSpec], gpd.GeoDataFrame] = field(
45
+ default_factory=lambda: lambda path, spec: gpd.read_file(path).to_crs(spec.crs).reset_index(),
46
+ )
47
+ all_touched: bool = True
48
+ dtype: np.dtype | None = None
49
+ fill_value: float | None = None
50
+ rasterize_kw: dict[str, Any] = field(default_factory=dict)
51
+ raster_creation_options: RasterCreationOptions | None = None
52
+
53
+ def __call__(self, dst_path: str | Path, spec: SpatialSpec) -> None:
54
+ df = self.load_df_func(self.path, spec)
55
+
56
+ if df.crs != spec.crs:
57
+ raise ValueError(f'GeoDataFrame CRS ({df.crs}) does not match target spec CRS ({spec.crs}).')
58
+
59
+ if self.column not in df.columns:
60
+ raise ValueError(f"Column '{self.column}' not found in GeoDataFrame.")
61
+
62
+ dtype = self.dtype if self.dtype is not None else df[self.column].dtype
63
+
64
+ if not np.issubdtype(dtype, np.number):
65
+ raise TypeError(f"Column '{self.column}' must be numeric, got {dtype}.")
66
+
67
+ default_fill_value = np.nan if np.issubdtype(dtype, np.floating) else 0
68
+ fill_value = self.fill_value if self.fill_value is not None else default_fill_value
69
+
70
+ if fill_value in df[self.column]:
71
+ raise ValueError(f'Fill value {fill_value} is present in the data. Overwrite with a different value.')
72
+
73
+ raster = rasterize(
74
+ ((row.geometry, row[self.column]) for i, row in df.iterrows()),
75
+ out_shape=spec.shape,
76
+ transform=spec.transform,
77
+ fill=fill_value,
78
+ all_touched=self.all_touched,
79
+ dtype=dtype,
80
+ **self.rasterize_kw,
81
+ )
82
+
83
+ raster_creation_options = self.raster_creation_options or get_config().raster_creation_options
84
+
85
+ with rasterio.open(
86
+ dst_path,
87
+ 'w',
88
+ driver='GTiff',
89
+ height=spec.shape[0],
90
+ width=spec.shape[1],
91
+ count=1,
92
+ dtype=dtype,
93
+ crs=spec.crs,
94
+ transform=spec.transform,
95
+ **raster_creation_options.to_dict(),
96
+ ) as dst:
97
+ dst.write(raster, 1)
98
+
99
+ default_driver = RioXArrayDriver()
100
+ ext = 'tif'
@@ -0,0 +1,168 @@
1
+ import shutil
2
+ import tempfile
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass, field
5
+ from numbers import Number
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import rasterio as rio
11
+ import rasterio.warp
12
+ from numpy.typing import DTypeLike
13
+ from rasterio import CRS, RasterioIOError
14
+ from rasterio.enums import Resampling
15
+
16
+ from pygeodata.config import get_config
17
+ from pygeodata.drivers import RioXArrayDriver
18
+ from pygeodata.options import RasterCreationOptions
19
+ from pygeodata.types import SpatialSpec
20
+
21
+
22
+ @dataclass
23
+ class Reprojector:
24
+ """Reprojects raster data to GeoTIFF format.
25
+
26
+ Parameters
27
+ ----------
28
+ src_path : str | Path
29
+ Path to source raster file
30
+ bands : int or tuple of int, optional
31
+ Band indices to reproject (1-indexed). If None, reprojects all bands
32
+ src_crs : CRS, optional
33
+ Override source CRS if not defined in file
34
+ resampling : Resampling, default=Resampling.nearest
35
+ Resampling method
36
+ dst_dtype : DTypeLike, optional
37
+ Output data type. If None, uses source dtype
38
+ dst_nodata : float, optional
39
+ Output nodata value. If None, uses source nodata
40
+ nbits : int, optional
41
+ Number of bits per pixel
42
+ warp_kw : dict, optional
43
+ Additional keyword arguments for rasterio.warp.reproject
44
+ scales : float or sequence of floats, optional
45
+ Scale factor for each band
46
+ offsets : float or sequence of floats, optional
47
+ Offset for each band
48
+ raster_creation_options : RasterCreationOptions, optional
49
+ GeoTIFF creation profile options. If None, uses defaults
50
+ """
51
+
52
+ src_path: str | Path
53
+ src_crs: CRS | None = None
54
+ bands: int | Sequence[int] | None = None
55
+ resampling: Resampling = Resampling.nearest
56
+ dst_dtype: DTypeLike | None = None
57
+ dst_nodata: float | None = None
58
+ nbits: int | None = None
59
+ warp_kw: dict[str, Any] = field(default_factory=dict)
60
+ warp_mem_limit: int | None = None
61
+ num_threads: int | None = None
62
+ scales: float | Sequence[float] | None = None
63
+ offsets: float | Sequence[float] | None = None
64
+ raster_creation_options: RasterCreationOptions | None = None
65
+
66
+ def __post_init__(self):
67
+ if self.dst_dtype == np.bool_:
68
+ self.dst_dtype = 'uint8'
69
+ self.nbits = 1
70
+
71
+ def __call__(self, dst_path: str | Path, spec: SpatialSpec) -> None:
72
+ """Reproject raster to specified spatial configuration.
73
+
74
+ Parameters
75
+ ----------
76
+ dst_path : str | Path
77
+ Destination file path
78
+ spec : SpatialSpec
79
+ Target spatial specification (CRS, transform, shape)
80
+ """
81
+ dst_path = Path(dst_path)
82
+
83
+ if dst_path.exists():
84
+ raise FileExistsError(f'Destination already exists: {dst_path}')
85
+
86
+ print(f'Reprojecting: {self.src_path} -> {dst_path}')
87
+
88
+ with tempfile.TemporaryDirectory() as tmpdir:
89
+ temp_path = Path(tmpdir) / f'~{"_".join(dst_path.parts[1:])}'
90
+
91
+ with rio.open(self.src_path) as src:
92
+ if len(src.subdatasets) > 1:
93
+ sub_str = '\n'.join(src.subdatasets)
94
+ raise RasterioIOError(
95
+ f'Cannot reproject multi-variable dataset: {self.src_path}\nSubdatasets:\n{sub_str}',
96
+ )
97
+
98
+ src_crs = src.crs if src.crs is not None else self.src_crs
99
+
100
+ if src_crs is None:
101
+ raise ValueError(f'Cannot determine CRS for {self.src_path}. Provide src_crs parameter.')
102
+
103
+ src_dtype = src.dtypes[0]
104
+ src_transform = src.transform
105
+
106
+ src_nodata = src.nodata
107
+ if src_nodata is None and np.issubdtype(src_dtype, np.floating):
108
+ src_nodata = np.nan
109
+
110
+ src_bands = src.indexes if self.bands is None else self.bands
111
+ if isinstance(src_bands, Number):
112
+ src_bands = (src_bands,)
113
+ count = len(src_bands)
114
+
115
+ dst_dtype = src_dtype if self.dst_dtype is None else self.dst_dtype
116
+ dst_nodata = src_nodata if self.dst_nodata is None else self.dst_nodata
117
+
118
+ raster_creation_options = self.raster_creation_options or get_config().raster_creation_options
119
+
120
+ rio_profile = {
121
+ 'driver': 'GTiff',
122
+ 'height': spec.shape[0],
123
+ 'width': spec.shape[1],
124
+ 'dtype': dst_dtype,
125
+ 'nodata': dst_nodata,
126
+ 'count': count,
127
+ 'crs': spec.crs,
128
+ 'transform': spec.transform,
129
+ **raster_creation_options.to_dict(),
130
+ }
131
+
132
+ if self.nbits is not None:
133
+ rio_profile['nbits'] = self.nbits
134
+
135
+ warp_mem_limit = self.warp_mem_limit if self.warp_mem_limit is not None else get_config().warp_mem_limit
136
+ num_threads = self.num_threads if self.num_threads is not None else get_config().num_threads
137
+
138
+ with rio.open(temp_path, 'w', **rio_profile) as dst:
139
+ rasterio.warp.reproject(
140
+ source=rio.band(src, src_bands),
141
+ destination=rio.band(dst, dst.indexes),
142
+ src_crs=src_crs,
143
+ dst_crs=spec.crs,
144
+ src_transform=src_transform,
145
+ dst_transform=spec.transform,
146
+ src_nodata=src_nodata,
147
+ dst_nodata=dst_nodata,
148
+ resampling=self.resampling,
149
+ warp_mem_limit=warp_mem_limit,
150
+ num_threads=num_threads,
151
+ **self.warp_kw,
152
+ )
153
+
154
+ scales = self.scales if self.scales is not None else tuple(src.scales[i - 1] for i in src_bands)
155
+ offsets = self.offsets if self.offsets is not None else tuple(src.offsets[i - 1] for i in src_bands)
156
+
157
+ if scales is not None:
158
+ scales = scales if isinstance(scales, Sequence) else [scales] * dst.count
159
+ dst._set_all_scales(scales)
160
+
161
+ if offsets is not None:
162
+ offsets = offsets if isinstance(offsets, Sequence) else [offsets] * dst.count
163
+ dst._set_all_offsets(offsets)
164
+
165
+ shutil.move(temp_path, dst_path)
166
+
167
+ default_driver = RioXArrayDriver()
168
+ ext = 'tif'
@@ -0,0 +1,54 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Any, Protocol
4
+
5
+ from affine import Affine
6
+ from pyproj import CRS
7
+ from rasterio.coords import BoundingBox
8
+
9
+ RasterShape = tuple[int, int]
10
+
11
+
12
+ @dataclass
13
+ class SpatialSpec:
14
+ crs: CRS
15
+ transform: Affine
16
+ shape: RasterShape
17
+
18
+ @property
19
+ def resolution(self) -> tuple[int, int]:
20
+ return (abs(self.transform.a), abs(self.transform.e))
21
+
22
+ @property
23
+ def bounds(self) -> BoundingBox:
24
+ height, width = self.shape
25
+ x0, y0 = self.transform * (0, 0)
26
+ x1, y1 = self.transform * (width, height)
27
+ return BoundingBox(min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
28
+
29
+ @property
30
+ def extent(self) -> tuple[float, float, float, float]:
31
+ bounds = self.bounds
32
+ return (bounds.left, bounds.right, bounds.bottom, bounds.top)
33
+
34
+ def __repr__(self):
35
+ transform_str = (
36
+ f'Affine('
37
+ f'{self.transform.a:.2f}, '
38
+ f'{self.transform.b:.2f}, '
39
+ f'{self.transform.c:.2f}, '
40
+ f'{self.transform.d:.2f}, '
41
+ f'{self.transform.e:.2f}, '
42
+ f'{self.transform.f:.2f})'
43
+ )
44
+ return f'SpatialSpec(crs={self.crs.to_string()}, transform={transform_str}, shape={self.shape})'
45
+
46
+
47
+ class Processor(Protocol):
48
+ def __call__(self, dst_path: str | Path, spec: SpatialSpec) -> None: ...
49
+
50
+
51
+ class Driver(Protocol):
52
+ def __call__(self, path: str | Path) -> Any: ...
53
+
54
+ default_ext: str
@@ -0,0 +1,5 @@
1
+ from affine import Affine
2
+
3
+
4
+ def transform_to_str(t: Affine) -> str:
5
+ return f'affine_{t.a:.4f}_{t.b:.4f}_{t.c:.4f}_{t.d:.4f}_{t.e:.4f}_{t.f:.4f}'
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygeodata
3
+ Version: 0.0.0
4
+ Summary: Lightweight framework for geospatial data access and processing
5
+ Author-email: Jasper Roebroek <roebroek.jasper@gmail.com>
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: rioxarray
12
+ Requires-Dist: geopandas
13
+ Requires-Dist: dask
14
+ Requires-Dist: zarr
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest; extra == "test"
17
+ Requires-Dist: pytest-mock; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # pygeodata
21
+
22
+ **Lightweight framework for geospatial data access and processing**
23
+
24
+ `pygeodata` provides simple, composable tools for working with geospatial raster and vector data. It aims to make spatial data handling consistent, efficient, and compatible with large-scale and cloud-based workflows.
25
+
26
+ ## Features
27
+
28
+ - Unified access to raster and vector datasets
29
+ - CRS-aware reprojection and alignment utilities
30
+ - Clean, composable API designed for scientific workflows
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install pygeodata
36
+ ```
@@ -0,0 +1,23 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ pygeodata/__init__.py
5
+ pygeodata/base.py
6
+ pygeodata/config.py
7
+ pygeodata/loader.py
8
+ pygeodata/options.py
9
+ pygeodata/paths.py
10
+ pygeodata/types.py
11
+ pygeodata/utils.py
12
+ pygeodata.egg-info/PKG-INFO
13
+ pygeodata.egg-info/SOURCES.txt
14
+ pygeodata.egg-info/dependency_links.txt
15
+ pygeodata.egg-info/requires.txt
16
+ pygeodata.egg-info/top_level.txt
17
+ pygeodata/drivers/__init__.py
18
+ pygeodata/drivers/rioxarray.py
19
+ pygeodata/processors/__init__.py
20
+ pygeodata/processors/rasterizer.py
21
+ pygeodata/processors/reprojection.py
22
+ tests/test_config.py
23
+ tests/test_loader.py
@@ -0,0 +1,8 @@
1
+ rioxarray
2
+ geopandas
3
+ dask
4
+ zarr
5
+
6
+ [test]
7
+ pytest
8
+ pytest-mock
@@ -0,0 +1 @@
1
+ pygeodata
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pygeodata"
7
+ description = "Lightweight framework for geospatial data access and processing"
8
+ authors = [
9
+ { name = "Jasper Roebroek", email = "roebroek.jasper@gmail.com" }
10
+ ]
11
+ dynamic = ["version"]
12
+ license = { text = "MIT" }
13
+ readme = "README.md"
14
+ requires-python = ">=3.10"
15
+ keywords = []
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3"
18
+ ]
19
+ dependencies = [
20
+ "rioxarray",
21
+ "geopandas",
22
+ "dask",
23
+ "zarr"
24
+ ]
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["pygeodata*"]
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest", "pytest-mock"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from pygeodata.config import Config, get_config, set_config
6
+
7
+
8
+ def test_default_config():
9
+ cfg = get_config()
10
+ assert cfg.path_data_processed == Path('data_processed')
11
+
12
+
13
+ def test_temporary_override():
14
+ original = get_config().path_data_processed
15
+ with set_config(path_data_processed=Path('/tmp')) as cfg:
16
+ assert cfg.path_data_processed == Path('/tmp')
17
+ assert get_config().path_data_processed == original
18
+
19
+
20
+ def test_multiple_overrides():
21
+ with set_config(path_data_processed=Path('/tmp')) as cfg:
22
+ assert isinstance(cfg, Config)
23
+ assert cfg.path_data_processed == Path('/tmp')
24
+
25
+
26
+ def test_config_invalid_key():
27
+ with pytest.raises(ValueError):
28
+ with set_config(invalid_key='value'):
29
+ pass
@@ -0,0 +1,282 @@
1
+ from dataclasses import asdict
2
+
3
+ from pygeodata import load
4
+ from pygeodata.config import set_config
5
+ from pygeodata.loader import DataLoader
6
+ from pygeodata.types import SpatialSpec
7
+
8
+
9
+ def test_loader_initialization(sample_loader_class):
10
+ """Test DataEntry initialization with default values."""
11
+ loader = sample_loader_class()
12
+ assert loader.name == 'sample'
13
+ assert hasattr(loader, 'processor')
14
+ assert hasattr(loader, 'driver')
15
+ assert loader.class_name == 'Sample'
16
+ assert loader.get_params() == {}
17
+
18
+
19
+ def test_loader_get_processed_path(sample_loader_class, sample_spatial_spec, tmp_path):
20
+ loader = sample_loader_class()
21
+ with set_config(path_data_processed=tmp_path):
22
+ path = loader.get_processed_path(sample_spatial_spec)
23
+
24
+ assert path.parent.exists()
25
+ assert path.suffix == '.tif'
26
+ assert str(tmp_path) in str(path)
27
+
28
+
29
+ def test_is_processed_false(sample_loader_class, sample_spatial_spec, tmp_path):
30
+ """Test is_processed when file doesn't exist."""
31
+ with set_config(path_data_processed=tmp_path):
32
+ assert not sample_loader_class().is_processed(sample_spatial_spec)
33
+
34
+
35
+ def test_is_processed_true(sample_loader_class, sample_spatial_spec, tmp_path):
36
+ """Test is_processed when file exists."""
37
+ loader = sample_loader_class()
38
+ with set_config(path_data_processed=tmp_path):
39
+ path = loader.get_processed_path(sample_spatial_spec)
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ path.touch()
42
+
43
+ assert loader.is_processed(sample_spatial_spec)
44
+
45
+
46
+ def test_process_creates_file(sample_loader_class, sample_spatial_spec, tmp_path):
47
+ """Test that process creates the expected output file."""
48
+ loader = sample_loader_class()
49
+ with set_config(path_data_processed=tmp_path):
50
+ loader.process(sample_spatial_spec)
51
+ path = loader.get_processed_path(sample_spatial_spec)
52
+ assert path.exists()
53
+
54
+
55
+ def test_load_with_params(sample_loader_class_complex, sample_spatial_spec):
56
+ """Test that parameters are included in the generated path."""
57
+ assert set(sample_loader_class_complex(10, 10).get_params()) == {'time', 'resolution'}
58
+
59
+
60
+ def test_loader_repr(sample_loader_class):
61
+ """Test string representation of loader."""
62
+ loader = sample_loader_class()
63
+ repr_str = repr(loader)
64
+ assert repr_str.startswith('Sample(')
65
+ assert repr_str.endswith(')')
66
+
67
+
68
+ def test_loader_repr_with_params(sample_loader_class_complex):
69
+ """Test string representation includes parameters sorted alphabetically."""
70
+ loader = sample_loader_class_complex(time=10, resolution=5)
71
+ repr_str = repr(loader)
72
+ assert 'ComplexSample(' in repr_str
73
+ assert 'resolution=5' in repr_str
74
+ assert 'time=10' in repr_str
75
+ assert repr_str.index('resolution') < repr_str.index('time')
76
+
77
+
78
+ def test_name_conversion_simple(sample_loader_class):
79
+ """Test class name to snake_case conversion for simple names."""
80
+ loader = sample_loader_class()
81
+ assert loader.name == 'sample'
82
+
83
+
84
+ def test_name_conversion_camel_case():
85
+ """Test class name conversion handles camelCase properly."""
86
+
87
+ class MyDataLoader(DataLoader):
88
+ @property
89
+ def processor(self):
90
+ return lambda path, spec: None
91
+
92
+ loader = MyDataLoader()
93
+ assert loader.name == 'my_data'
94
+
95
+
96
+ def test_name_conversion_acronyms():
97
+ """Test class name conversion handles acronyms properly."""
98
+
99
+ class XMLHTTPLoader(DataLoader):
100
+ @property
101
+ def processor(self):
102
+ return lambda path, spec: None
103
+
104
+ loader = XMLHTTPLoader()
105
+ assert loader.name == 'xmlhttp'
106
+
107
+
108
+ def test_name_conversion_mixed():
109
+ """Test class name conversion handles mixed acronyms and camelCase."""
110
+
111
+ class USGSElevationLoader(DataLoader):
112
+ @property
113
+ def processor(self):
114
+ return lambda path, spec: None
115
+
116
+ loader = USGSElevationLoader()
117
+ assert loader.name == 'usgs_elevation'
118
+
119
+
120
+ def test_get_params_excludes_private_attributes(sample_loader_class_complex):
121
+ """Test that get_params excludes private attributes."""
122
+ loader = sample_loader_class_complex(time=10, resolution=5)
123
+ loader._private_attr = 'should_not_appear'
124
+ params = loader.get_params()
125
+ assert '_private_attr' not in params
126
+
127
+
128
+ def test_get_params_multiple_params(sample_loader_class_complex):
129
+ """Test get_params returns all instance attributes."""
130
+ loader = sample_loader_class_complex(time=15, resolution=20)
131
+ params = loader.get_params()
132
+ assert params['time'] == 15
133
+ assert params['resolution'] == 20
134
+ assert len(params) == 2
135
+
136
+
137
+ def test_process_skips_if_already_processed(sample_loader_class, sample_spatial_spec, tmp_path):
138
+ """Test that process doesn't reprocess if file already exists."""
139
+ loader = sample_loader_class()
140
+ with set_config(path_data_processed=tmp_path):
141
+ path = loader.get_processed_path(sample_spatial_spec)
142
+ path.parent.mkdir(parents=True, exist_ok=True)
143
+ path.touch()
144
+
145
+ loader.is_processed(sample_spatial_spec)
146
+
147
+
148
+ def test_process_calls_processor_if_not_processed(sample_loader_class, sample_spatial_spec, tmp_path, mocker):
149
+ """Test that process calls processor when file doesn't exist."""
150
+ loader = sample_loader_class()
151
+ with set_config(path_data_processed=tmp_path):
152
+ # Mock the processor
153
+ mock_processor = mocker.MagicMock()
154
+ mocker.patch.object(loader, 'processor', mock_processor)
155
+
156
+ loader.process(sample_spatial_spec)
157
+
158
+ path = loader.get_processed_path(sample_spatial_spec)
159
+ mock_processor.assert_called_once_with(path, sample_spatial_spec)
160
+
161
+
162
+ def test_load_returns_data(sample_loader_class, sample_spatial_spec, tmp_path, mocker):
163
+ """Test that load returns data from driver."""
164
+ loader = sample_loader_class()
165
+ expected_data = {'test': 'data'}
166
+
167
+ with set_config(path_data_processed=tmp_path):
168
+ # Mock driver.load to return test data
169
+ mock_load = mocker.MagicMock(return_value=expected_data)
170
+ mocker.patch.object(loader, 'load', mock_load)
171
+
172
+ result = loader.load(sample_spatial_spec)
173
+
174
+ assert result == expected_data
175
+ mock_load.assert_called_once()
176
+
177
+
178
+ def test_load_processes_before_loading(sample_loader_class, sample_spatial_spec, tmp_path, mocker):
179
+ """Test that load calls process before loading data."""
180
+ loader = sample_loader_class()
181
+
182
+ with set_config(path_data_processed=tmp_path):
183
+ mock_process = mocker.patch.object(loader, 'process')
184
+ mock_load = mocker.patch.object(loader, 'load', return_value=None)
185
+
186
+ loader(sample_spatial_spec)
187
+
188
+ # Ensure process is called before load
189
+ mock_process.assert_called_once_with(sample_spatial_spec)
190
+ mock_load.assert_called_once()
191
+
192
+
193
+ def test_is_processed_with_valid_symlink(sample_loader_class, sample_spatial_spec, tmp_path):
194
+ """Test is_processed returns True for valid symlink."""
195
+ loader = sample_loader_class()
196
+ with set_config(path_data_processed=tmp_path):
197
+ path = loader.get_processed_path(sample_spatial_spec)
198
+ path.parent.mkdir(parents=True, exist_ok=True)
199
+
200
+ # Create a target file and symlink to it
201
+ target = tmp_path / 'target.tif'
202
+ target.touch()
203
+ path.symlink_to(target)
204
+
205
+ assert loader.is_processed(sample_spatial_spec)
206
+
207
+
208
+ def test_is_processed_with_broken_symlink(sample_loader_class, sample_spatial_spec, tmp_path):
209
+ """Test is_processed behavior with broken symlink."""
210
+ loader = sample_loader_class()
211
+ with set_config(path_data_processed=tmp_path):
212
+ path = loader.get_processed_path(sample_spatial_spec)
213
+ path.parent.mkdir(parents=True, exist_ok=True)
214
+
215
+ # Create a symlink to non-existent target
216
+ target = tmp_path / 'nonexistent.tif'
217
+ path.symlink_to(target)
218
+
219
+ assert not loader.is_processed(sample_spatial_spec)
220
+
221
+
222
+ def test_driver_fallback_to_processor_default(sample_loader_class):
223
+ """Test that driver property falls back to processor's default_driver."""
224
+ loader = sample_loader_class()
225
+ driver = loader.driver
226
+ assert driver is not None
227
+ assert hasattr(driver, '__call__')
228
+
229
+
230
+ def test_get_processed_path_creates_parent_directory(sample_loader_class, sample_spatial_spec, tmp_path):
231
+ """Test that get_processed_path creates parent directories."""
232
+ loader = sample_loader_class()
233
+ with set_config(path_data_processed=tmp_path):
234
+ path = loader.get_processed_path(sample_spatial_spec)
235
+ # Parent should be created by get_processed_path
236
+ assert path.parent.exists()
237
+
238
+
239
+ def test_multiple_specs_different_paths(sample_loader_class, sample_spatial_spec, tmp_path):
240
+ """Test that different spatial specs generate different paths."""
241
+ loader = sample_loader_class()
242
+
243
+ # Create a second spec with different properties
244
+ spec2 = SpatialSpec(**asdict(sample_spatial_spec))
245
+ spec2.shape = (2, 3)
246
+
247
+ with set_config(path_data_processed=tmp_path):
248
+ path1 = loader.get_processed_path(sample_spatial_spec)
249
+ path2 = loader.get_processed_path(spec2)
250
+
251
+ assert path1 != path2
252
+
253
+
254
+ def test_same_spec_same_path(sample_loader_class, sample_spatial_spec, tmp_path):
255
+ """Test that same spec always generates the same path."""
256
+ loader = sample_loader_class()
257
+
258
+ with set_config(path_data_processed=tmp_path):
259
+ path1 = loader.get_processed_path(sample_spatial_spec)
260
+ path2 = loader.get_processed_path(sample_spatial_spec)
261
+
262
+ assert path1 == path2
263
+
264
+
265
+ def test_different_loader_instances_same_behavior(sample_loader_class, sample_spatial_spec, tmp_path):
266
+ """Test that different instances of the same loader class behave identically."""
267
+ loader1 = sample_loader_class()
268
+ loader2 = sample_loader_class()
269
+
270
+ with set_config(path_data_processed=tmp_path):
271
+ path1 = loader1.get_processed_path(sample_spatial_spec)
272
+ path2 = loader2.get_processed_path(sample_spatial_spec)
273
+
274
+ assert path1 == path2
275
+ assert loader1.name == loader2.name
276
+ assert loader1.class_name == loader2.class_name
277
+
278
+
279
+ def test_load(sample_loader_class, sample_spatial_spec, tmp_path):
280
+ with set_config(path_data_processed=tmp_path):
281
+ data = load(sample_loader_class(), spec=sample_spatial_spec)
282
+ assert data.rio.crs == sample_spatial_spec.crs