From c95024be3063b77dcec131844d566803f72f15ad Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 27 May 2026 07:47:29 -0700 Subject: [PATCH 1/2] Make matplotlib an optional [plot] extra (#2494) matplotlib was in install_requires but no compute path imports it; every use is a lazy import inside the .xrs.plot accessor helpers. Move it to a plot extra so a plain install skips matplotlib and its transitive chain (pillow, fonttools, kiwisolver, contourpy, cycler, pyparsing). Add a _require_matplotlib() guard at the two plot entry points so a missing install raises a clear ImportError pointing at pip install xarray-spatial[plot]. Add matplotlib to the tests extra so the existing plot tests keep running in CI. Update README, install docs, and CHANGELOG. --- CHANGELOG.md | 1 + README.md | 6 ++- docs/source/getting_started/installation.rst | 8 ++- setup.cfg | 7 ++- xrspatial/accessor.py | 19 +++++++ xrspatial/tests/test_accessor.py | 56 ++++++++++++++++++++ 6 files changed, 94 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83acc8386..debc418cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 141b81e29..5f22b2b66 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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 diff --git a/docs/source/getting_started/installation.rst b/docs/source/getting_started/installation.rst index f727b1081..ef5dae68f 100644 --- a/docs/source/getting_started/installation.rst +++ b/docs/source/getting_started/installation.rst @@ -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 \ No newline at end of file + 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. \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 3b87b7bf7..ebf697c36 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,7 +23,6 @@ install_requires = scipy xarray numpy - matplotlib shapely>=2.0 urllib3 zstandard @@ -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 @@ -80,6 +84,7 @@ tests = geopandas hypothesis isort + matplotlib noise >= 1.2.2 dask pyarrow diff --git a/xrspatial/accessor.py b/xrspatial/accessor.py index 3ca28a36a..37705f9af 100644 --- a/xrspatial/accessor.py +++ b/xrspatial/accessor.py @@ -14,6 +14,23 @@ 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``. + """ + try: + import matplotlib # 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). @@ -73,6 +90,7 @@ def plot(self, **kwargs): ------- matplotlib artist (from ``da.plot()``) """ + _require_matplotlib() import matplotlib.pyplot as plt import numpy as np @@ -629,6 +647,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 diff --git a/xrspatial/tests/test_accessor.py b/xrspatial/tests/test_accessor.py index 7fcf6288f..c8669865d 100644 --- a/xrspatial/tests/test_accessor.py +++ b/xrspatial/tests/test_accessor.py @@ -322,3 +322,59 @@ 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(monkeypatch): + """Core compute modules import even when matplotlib is unavailable. + + Blocking ``matplotlib`` in ``sys.modules`` makes ``import matplotlib`` + raise ImportError, simulating an install without the ``plot`` extra. + """ + import importlib + import sys + + monkeypatch.setitem(sys.modules, 'matplotlib', None) + monkeypatch.setitem(sys.modules, 'matplotlib.pyplot', None) + + with pytest.raises(ImportError): + import matplotlib # noqa: F401 + + # Re-importing the compute modules must not touch matplotlib. + for name in ('xrspatial.focal', 'xrspatial.zonal', 'xrspatial.dasymetric'): + importlib.reload(importlib.import_module(name)) + + +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() From 1e1157dcd40f3a223c585c6f82382fa395d03cbb Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 27 May 2026 07:49:42 -0700 Subject: [PATCH 2/2] Address review: guard matplotlib.pyplot; subprocess import test (#2494) - _require_matplotlib now imports matplotlib.pyplot so an installed-but- unusable matplotlib (no backend) also gets the friendly error. - Replace the importlib.reload-based import test with a subprocess that imports the compute modules against a clean cache with matplotlib blocked. --- xrspatial/accessor.py | 6 ++-- xrspatial/tests/test_accessor.py | 48 ++++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/xrspatial/accessor.py b/xrspatial/accessor.py index 37705f9af..6eebb042a 100644 --- a/xrspatial/accessor.py +++ b/xrspatial/accessor.py @@ -20,10 +20,12 @@ def _require_matplotlib(): 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``. + ``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 # noqa: F401 + import matplotlib.pyplot # noqa: F401 except ImportError as e: raise ImportError( "matplotlib is required for plotting but is not installed. " diff --git a/xrspatial/tests/test_accessor.py b/xrspatial/tests/test_accessor.py index c8669865d..a5d669f1f 100644 --- a/xrspatial/tests/test_accessor.py +++ b/xrspatial/tests/test_accessor.py @@ -328,24 +328,42 @@ def test_ds_rasterize_no_valid_var(): # Optional matplotlib (the `plot` extra) — issue #2494 # --------------------------------------------------------------------------- -def test_compute_imports_without_matplotlib(monkeypatch): - """Core compute modules import even when matplotlib is unavailable. +def test_compute_imports_without_matplotlib(): + """Core compute modules import in a fresh interpreter with no matplotlib. - Blocking ``matplotlib`` in ``sys.modules`` makes ``import matplotlib`` - raise ImportError, simulating an install without the ``plot`` extra. + 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 importlib + import subprocess import sys - - monkeypatch.setitem(sys.modules, 'matplotlib', None) - monkeypatch.setitem(sys.modules, 'matplotlib.pyplot', None) - - with pytest.raises(ImportError): - import matplotlib # noqa: F401 - - # Re-importing the compute modules must not touch matplotlib. - for name in ('xrspatial.focal', 'xrspatial.zonal', 'xrspatial.dasymetric'): - importlib.reload(importlib.import_module(name)) + 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):