Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ for the per-tier semantics and the audit trail.


#### Bug fixes and improvements
- 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)
- 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)
- 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)
- Resolve the GeoTIFF writer's `GeographicTypeGeoKey` / `ProjectedCSTypeGeoKey` decision via pyproj instead of an EPSG number range. The legacy heuristic (4326 + 4000-4999 -> geographic, else projected) silently mis-tagged geographic CRSes registered outside the 4000-4999 block (NAD83(2011) = 6318, GDA2020 = 7844, WGS 84 (G2139) = 9057, etc.) as projected and projected codes inside the block (4087 / 4088 / 4499) as geographic, corrupting the CRS at write time. The writer now calls `pyproj.CRS.from_epsg(...).is_geographic`. When pyproj can't classify a code (uninstalled, or installed but the local PROJ database lacks the entry), the writer raises the new `UnknownCRSModelTypeError` rather than guessing -- a small vetted allowlist (4326, 4269, 4267, 4258, 4283, 4322, 4230, 4019, 4047) is still honoured for the pyproj-missing case. `pyproj` is now listed under the `geotiff` extra. (#2277)
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ Xarray-Spatial is a Python library for raster analysis built on xarray. It has 1
# via pip
pip install xarray-spatial

# with plotting helpers (matplotlib)
pip install xarray-spatial[plot]

# via conda
conda install -c conda-forge xarray-spatial
```
Expand Down Expand Up @@ -610,9 +613,10 @@ Check out the user guide [here](/examples/user_guide/).

#### Dependencies

**Core:** numpy, numba, scipy, xarray, matplotlib, zstandard
**Core:** numpy, numba, scipy, xarray, zstandard

**Optional:**
- `matplotlib` — the `.xrs.plot` accessor helpers (`pip install xarray-spatial[plot]`)
- `pyproj` — WKT/PROJ CRS resolution
- `cupy` — GPU acceleration
- `dask` — out-of-core processing
Expand Down
8 changes: 7 additions & 1 deletion docs/source/getting_started/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,11 @@ Installation
# via pip
pip install xarray-spatial

# with plotting helpers (matplotlib)
pip install xarray-spatial[plot]

# via conda
conda install -c conda-forge xarray-spatial
conda install -c conda-forge xarray-spatial

matplotlib is an optional dependency. The compute functions work without it;
install the ``plot`` extra to use the ``.xrs.plot`` accessor helpers.
7 changes: 6 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ install_requires =
scipy
xarray
numpy
matplotlib
shapely>=2.0
urllib3
zstandard
Expand Down Expand Up @@ -53,6 +52,11 @@ doc =
sphinx_rtd_theme
examples =
datashader >= 0.15.0
plot =
# Optional for the .xrs.plot accessor helpers. Every matplotlib
# import in the package is lazy, so the compute functions work
# without this extra installed.
matplotlib
optional =
# Optional for polygonize return types.
awkward>=1.4
Expand Down Expand Up @@ -80,6 +84,7 @@ tests =
geopandas
hypothesis
isort
matplotlib
noise >= 1.2.2
dask
pyarrow
Expand Down
21 changes: 21 additions & 0 deletions xrspatial/accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@
import xarray as xr


def _require_matplotlib():
"""Import matplotlib or raise a helpful error.

matplotlib is an optional dependency (the ``plot`` extra). The
plotting helpers call this before importing ``pyplot`` so a missing
install produces a clear message instead of a bare
``ModuleNotFoundError``. Importing ``pyplot`` (not just the top-level
package) also catches the case where matplotlib is installed but has
no usable backend.
"""
try:
import matplotlib.pyplot # noqa: F401
except ImportError as e:
raise ImportError(
"matplotlib is required for plotting but is not installed. "
"Install it with: pip install xarray-spatial[plot]"
) from e


def _listed_colormap_from_attrs(attrs):
"""Build a :class:`matplotlib.colors.ListedColormap` from
``attrs['colormap']`` (raw uint16 RGB triples from TIFF tag 320).
Expand Down Expand Up @@ -73,6 +92,7 @@ def plot(self, **kwargs):
-------
matplotlib artist (from ``da.plot()``)
"""
_require_matplotlib()
import matplotlib.pyplot as plt
import numpy as np

Expand Down Expand Up @@ -629,6 +649,7 @@ def plot(self, vars=None, cols=3, **kwargs):
-------
numpy.ndarray of matplotlib.axes.Axes
"""
_require_matplotlib()
import math
import matplotlib.pyplot as plt
import numpy as np
Expand Down
74 changes: 74 additions & 0 deletions xrspatial/tests/test_accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,77 @@ def test_ds_rasterize_no_valid_var():
ds = xr.Dataset({'a': xr.DataArray(np.zeros(5), dims=['z'])})
with pytest.raises(ValueError, match="no 2D variable"):
ds.xrs.rasterize([])


# ---------------------------------------------------------------------------
# Optional matplotlib (the `plot` extra) — issue #2494
# ---------------------------------------------------------------------------

def test_compute_imports_without_matplotlib():
"""Core compute modules import in a fresh interpreter with no matplotlib.

Runs in a subprocess so the import happens against a clean module cache
with matplotlib blocked, rather than reloading already-imported modules
in the test session.
"""
import subprocess
import sys
import textwrap

code = textwrap.dedent(
"""
import sys
sys.modules['matplotlib'] = None
sys.modules['matplotlib.pyplot'] = None

import xrspatial # noqa: F401
import xrspatial.focal # noqa: F401
import xrspatial.zonal # noqa: F401
import xrspatial.dasymetric # noqa: F401

try:
import matplotlib # noqa: F401
except ImportError:
pass
else:
raise SystemExit('matplotlib was unexpectedly importable')
"""
)
result = subprocess.run(
[sys.executable, '-c', code],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr


def test_require_matplotlib_message(monkeypatch):
"""The helper points users at the `plot` extra when matplotlib is gone."""
import sys

from xrspatial.accessor import _require_matplotlib

monkeypatch.setitem(sys.modules, 'matplotlib', None)
with pytest.raises(ImportError, match=r"xarray-spatial\[plot\]"):
_require_matplotlib()


def test_da_plot_without_matplotlib_raises(monkeypatch, elevation):
"""``.xrs.plot()`` raises the friendly error when matplotlib is absent."""
import sys

monkeypatch.setitem(sys.modules, 'matplotlib', None)
monkeypatch.setitem(sys.modules, 'matplotlib.pyplot', None)
with pytest.raises(ImportError, match=r"xarray-spatial\[plot\]"):
elevation.xrs.plot()


def test_ds_plot_without_matplotlib_raises(monkeypatch, elevation):
"""Dataset ``.xrs.plot()`` raises the friendly error too."""
import sys

ds = xr.Dataset({'elev': elevation})
monkeypatch.setitem(sys.modules, 'matplotlib', None)
monkeypatch.setitem(sys.modules, 'matplotlib.pyplot', None)
with pytest.raises(ImportError, match=r"xarray-spatial\[plot\]"):
ds.xrs.plot()
Loading