Skip to content

Commit 0a51f50

Browse files
authored
Make shapely an optional [vector] extra (#2496) (#2497)
* Make shapely an optional [vector] extra (#2496) shapely (and GEOS) loaded on every import xrspatial because rasterize.py imported it at module top level. Import it lazily via a cached _require_shapely() helper, bound locally in each function that uses the shapely array API (so dask tile workers, which call the helpers directly, also get the friendly error). rasterize() calls it up front for an early, clear failure. Remove shapely>=2.0 from install_requires; add a vector extra and keep shapely in the tests extra. polygonize already imports shapely lazily and only on its geopandas return path, so it needs no change. Update README, install docs, and CHANGELOG. * Address review nit: also assert polygonize imports without shapely (#2496)
1 parent e89904b commit 0a51f50

6 files changed

Lines changed: 137 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ for the per-tier semantics and the audit trail.
4545

4646

4747
#### Bug fixes and improvements
48+
- Move shapely from a required dependency to an optional `vector` extra. shapely (and GEOS) used to load on every `import xrspatial` because `rasterize.py` imported it at module top level. It is now imported lazily, so a plain `pip install xarray-spatial` neither installs nor loads shapely. The only paths that need it are the vector-to-raster functions `rasterize` and `polygonize`; install `pip install xarray-spatial[vector]` to use them. Calling `rasterize` without shapely raises a clear ImportError pointing at the extra. Follows the same pattern as the matplotlib change (#2494). (#2496)
4849
- Move matplotlib from a required dependency to an optional `plot` extra. A plain `pip install xarray-spatial` no longer pulls in matplotlib (and its pillow / fonttools / kiwisolver / contourpy / cycler / pyparsing chain), since no compute path imports it -- every matplotlib use is a lazy import inside the `.xrs.plot` accessor helpers. Install `pip install xarray-spatial[plot]` to use plotting; calling `.xrs.plot()` without it now raises a clear ImportError pointing at the extra instead of a bare `ModuleNotFoundError`. pandas stays required because xarray depends on it and several core modules import it directly. (#2494)
4950
- Reconcile `xrspatial.geotiff.SUPPORTED_FEATURES` with the GeoTIFF release-contract tiering proposed in epic #2340. Adds `reader.windowed` at `stable` (covered by the existing window-read suite) and `reader.dask` at `stable` (covered by the cross-backend parity matrix in `test_backend_parity_matrix.py` and `test_backend_full_parity_2211.py`). Demotes `reader.allow_rotated` and `reader.allow_unparseable_crs` from `advanced` to `experimental` to match the epic's placement of permissive read-side escape hatches in the Experimental tier. A new shape test (`test_supported_features_shape_2348.py`) pins the structural invariants of the mapping (every entry carries a tier label; the tier set is closed at `{stable, advanced, experimental, internal_only}`; the dict literal contains no duplicate keys) so future drift fails CI. Runtime behaviour of every read and write path is unchanged; this is metadata-only. Callers gating on the exact string value of these two demoted entries will need to update their checks. (#2348)
5051
- Promote the local COG read and write paths to the `stable` tier in `xrspatial.geotiff.SUPPORTED_FEATURES`. `SUPPORTED_FEATURES['writer.cog']` and `SUPPORTED_FEATURES['reader.local_cog']` now report `stable`; `reader.http_cog` stays `advanced` while the HTTP transport surface is contracted separately. The stable COG contract covers axis-aligned 2D / 3D rasters, the CPU writer and CPU reader, the lossless codecs (`none`, `deflate`, `lzw`, `zstd`, `packbits`), internal overviews, and normal CRS / transform / dtype / nodata / band / pixel-is-area / pixel-is-point round-trip. GPU COG paths, experimental codecs, rotated transforms, external `.tif.ovr` sidecars, file-like destinations with `cog=True`, BigTIFF COG, and HTTP COG remain outside the contract. Backed by the writer compliance suite (#2292), the cross-backend parity gate (#2293), and the per-tile byte-budget contract (#2294 / #2298). The reference docs (`docs/source/reference/geotiff.rst`) and the COG overview notebook spell out the full contract. (#2300)

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ pip install xarray-spatial
104104
# with plotting helpers (matplotlib)
105105
pip install xarray-spatial[plot]
106106

107+
# with vector rasterization (shapely): rasterize, polygonize
108+
pip install xarray-spatial[vector]
109+
107110
# via conda
108111
conda install -c conda-forge xarray-spatial
109112
```
@@ -617,6 +620,7 @@ Check out the user guide [here](/examples/user_guide/).
617620

618621
**Optional:**
619622
- `matplotlib` — the `.xrs.plot` accessor helpers (`pip install xarray-spatial[plot]`)
623+
- `shapely` — the vector-to-raster paths, `rasterize` and `polygonize` (`pip install xarray-spatial[vector]`)
620624
- `pyproj` — WKT/PROJ CRS resolution
621625
- `cupy` — GPU acceleration
622626
- `dask` — out-of-core processing

docs/source/getting_started/installation.rst

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ Installation
1212
# with plotting helpers (matplotlib)
1313
pip install xarray-spatial[plot]
1414
15+
# with vector rasterization (shapely): rasterize, polygonize
16+
pip install xarray-spatial[vector]
17+
1518
# via conda
1619
conda install -c conda-forge xarray-spatial
1720
18-
matplotlib is an optional dependency. The compute functions work without it;
19-
install the ``plot`` extra to use the ``.xrs.plot`` accessor helpers.
21+
matplotlib and shapely are optional dependencies. The compute functions work
22+
without either; install the ``plot`` extra for the ``.xrs.plot`` accessor
23+
helpers, and the ``vector`` extra for the ``rasterize`` and ``polygonize``
24+
vector-to-raster paths.

setup.cfg

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ install_requires =
2323
scipy
2424
xarray
2525
numpy
26-
shapely>=2.0
2726
urllib3
2827
zstandard
2928
packages = find:
@@ -57,6 +56,10 @@ plot =
5756
# import in the package is lazy, so the compute functions work
5857
# without this extra installed.
5958
matplotlib
59+
vector =
60+
# Optional for the vector-to-raster paths (rasterize, polygonize).
61+
# shapely is imported lazily so `import xrspatial` does not load it.
62+
shapely>=2.0
6063
optional =
6164
# Optional for polygonize return types.
6265
awkward>=1.4
@@ -92,6 +95,7 @@ tests =
9295
pytest
9396
pytest-cov
9497
scipy
98+
shapely>=2.0
9599

96100

97101
[flake8]

xrspatial/rasterize.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from typing import Any, Callable, NamedTuple, Optional, Sequence, Tuple, Union
1717

1818
import numpy as np
19-
import shapely
2019
import xarray as xr
2120

2221
from xrspatial.utils import ngjit
@@ -31,6 +30,34 @@
3130
except ImportError:
3231
cuspatial = None
3332

33+
#: Cached shapely module, populated by :func:`_require_shapely` on first use.
34+
#: shapely is an optional dependency (the ``vector`` extra). It is imported
35+
#: lazily so ``import xrspatial`` does not pull in shapely (and GEOS) for users
36+
#: who never rasterize vector geometry.
37+
_shapely = None
38+
39+
40+
def _require_shapely():
41+
"""Import shapely or raise a helpful error, caching the module.
42+
43+
rasterize and polygonize are the only paths that need shapely. Every
44+
function here that touches the shapely array API calls this and binds the
45+
return value to a local ``shapely`` name, so the error surfaces clearly
46+
(including inside dask workers, which call the tile helpers directly rather
47+
than through :func:`rasterize`).
48+
"""
49+
global _shapely
50+
if _shapely is None:
51+
try:
52+
import shapely as _s
53+
except ImportError as e:
54+
raise ImportError(
55+
"shapely is required for rasterize/polygonize but is not "
56+
"installed. Install it with: pip install xarray-spatial[vector]"
57+
) from e
58+
_shapely = _s
59+
return _shapely
60+
3461

3562
# ---------------------------------------------------------------------------
3663
# Allocation guard: reject output dimensions that would exhaust memory
@@ -234,6 +261,7 @@ def _classify_geometries(geometries, props_array):
234261
([], empty_props.copy(), empty_idx.copy()),
235262
([], empty_props.copy(), empty_idx.copy()))
236263

264+
shapely = _require_shapely()
237265
type_ids = shapely.get_type_id(geom_arr)
238266
empty = shapely.is_empty(geom_arr)
239267
valid = ~empty
@@ -348,6 +376,7 @@ def _extract_edges(geometries, geom_ids, bounds, height, width,
348376
def _extract_edges_vectorized(geometries, geom_ids, bounds,
349377
height, width, all_touched):
350378
"""Vectorized edge extraction using shapely 2.0 array ops."""
379+
shapely = _require_shapely()
351380
xmin, ymin, xmax, ymax = bounds
352381
px = (xmax - xmin) / width
353382
py = (ymax - ymin) / height
@@ -472,6 +501,7 @@ def _extract_points(geometries, bounds, height, width):
472501

473502
def _extract_points_vectorized(geometries, bounds, height, width):
474503
"""Vectorized point extraction using shapely 2.0 array ops."""
504+
shapely = _require_shapely()
475505
xmin, ymin, xmax, ymax = bounds
476506
px = (xmax - xmin) / width
477507
py = (ymax - ymin) / height
@@ -532,6 +562,7 @@ def _extract_line_segments(geometries, bounds, height, width):
532562

533563
def _extract_lines_vectorized(geometries, bounds, height, width):
534564
"""Vectorized line extraction with Liang-Barsky clipping."""
565+
shapely = _require_shapely()
535566
xmin, ymin, xmax, ymax = bounds
536567
px = (xmax - xmin) / width
537568
py = (ymax - ymin) / height
@@ -2131,6 +2162,7 @@ def _geometry_bboxes(geometries):
21312162
"""Return (N, 4) float64 array of [xmin, ymin, xmax, ymax] per geometry."""
21322163
if len(geometries) == 0:
21332164
return np.empty((0, 4), dtype=np.float64)
2165+
shapely = _require_shapely()
21342166
return shapely.bounds(np.asarray(geometries, dtype=object))
21352167

21362168

@@ -2298,11 +2330,13 @@ def _polys_to_wkb(geoms):
22982330
"""Pre-serialize polygon geometries to WKB for cheap pickling."""
22992331
if not geoms:
23002332
return []
2333+
shapely = _require_shapely()
23012334
return shapely.to_wkb(np.asarray(geoms, dtype=object)).tolist()
23022335

23032336

23042337
def _polys_from_wkb(wkb_list):
23052338
"""Deserialize WKB back to shapely geometries."""
2339+
shapely = _require_shapely()
23062340
geoms = shapely.from_wkb(wkb_list)
23072341
if not isinstance(geoms, (list, np.ndarray)):
23082342
geoms = [geoms]
@@ -3048,6 +3082,10 @@ def rasterize(
30483082
>>> density = rasterize(gdf, width=100, height=100,
30493083
... column='pop', merge='sum', fill=0)
30503084
"""
3085+
# Fail early with a clear message if the optional ``vector`` extra
3086+
# (shapely) is not installed, rather than deep inside a helper.
3087+
_require_shapely()
3088+
30513089
if column is not None and columns is not None:
30523090
raise ValueError(
30533091
"'column' and 'columns' are mutually exclusive; use one or "
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Tests for shapely being an optional dependency (the `vector` extra).
2+
3+
shapely backs the vector-to-raster paths (rasterize, polygonize) but is
4+
imported lazily, so `import xrspatial` works without it. See issue #2496.
5+
6+
These tests run whether or not shapely is installed: they either spawn a
7+
fresh interpreter with shapely blocked, or block it in ``sys.modules`` and
8+
reset the cached module in ``xrspatial.rasterize``.
9+
"""
10+
import sys
11+
12+
import numpy as np
13+
import pytest
14+
import xarray as xr
15+
16+
17+
def test_import_xrspatial_without_shapely():
18+
"""`import xrspatial` and the compute modules work with no shapely.
19+
20+
Runs in a subprocess so the import happens against a clean module cache
21+
with shapely blocked.
22+
"""
23+
import subprocess
24+
import textwrap
25+
26+
code = textwrap.dedent(
27+
"""
28+
import sys
29+
sys.modules['shapely'] = None
30+
31+
import xrspatial # noqa: F401
32+
import xrspatial.focal # noqa: F401
33+
import xrspatial.rasterize # noqa: F401
34+
import xrspatial.polygonize # noqa: F401
35+
36+
# shapely must not have been imported as a side effect.
37+
if 'shapely' in sys.modules and sys.modules['shapely'] is not None:
38+
raise SystemExit('shapely was imported on import xrspatial')
39+
40+
try:
41+
import shapely # noqa: F401
42+
except ImportError:
43+
pass
44+
else:
45+
raise SystemExit('shapely was unexpectedly importable')
46+
"""
47+
)
48+
result = subprocess.run(
49+
[sys.executable, '-c', code],
50+
capture_output=True,
51+
text=True,
52+
)
53+
assert result.returncode == 0, result.stderr
54+
55+
56+
def test_require_shapely_message(monkeypatch):
57+
"""The helper points users at the `vector` extra when shapely is gone."""
58+
import importlib
59+
rasterize_mod = importlib.import_module('xrspatial.rasterize')
60+
61+
monkeypatch.setattr(rasterize_mod, '_shapely', None)
62+
monkeypatch.setitem(sys.modules, 'shapely', None)
63+
with pytest.raises(ImportError, match=r"xarray-spatial\[vector\]"):
64+
rasterize_mod._require_shapely()
65+
66+
67+
def test_rasterize_without_shapely_raises(monkeypatch):
68+
"""`rasterize()` raises the friendly error up front when shapely is absent."""
69+
import importlib
70+
rasterize_mod = importlib.import_module('xrspatial.rasterize')
71+
72+
monkeypatch.setattr(rasterize_mod, '_shapely', None)
73+
monkeypatch.setitem(sys.modules, 'shapely', None)
74+
75+
template = xr.DataArray(
76+
np.zeros((4, 4)),
77+
dims=['y', 'x'],
78+
coords={'x': np.arange(4.0), 'y': np.arange(4.0)},
79+
)
80+
with pytest.raises(ImportError, match=r"xarray-spatial\[vector\]"):
81+
rasterize_mod.rasterize([], like=template)

0 commit comments

Comments
 (0)