From a84de4111c5943e3f6984abb486eccdf410efea0 Mon Sep 17 00:00:00 2001 From: Benoit Bovy Date: Fri, 6 Jun 2025 11:59:07 +0200 Subject: [PATCH 1/7] refactor CRS-aware index ProjIndexMixin class: - replace the ``_proj_get_crs`` abstract method by the ``crs`` abstract (public) property. - add ``_proj_crs_equals`` helper method Xproj now identifies an Xarray index as CRS-aware if it has a ``crs`` property that returns either a ``pyproj.CRS`` object or ``None``. A CRS-aware index may have an undefined CRS (``None``). Add ``CRSAwareIndex`` protocol class. --- docs/api-hidden.rst | 3 +- docs/integration.md | 8 ++-- xproj/accessor.py | 38 ++++++++++------ xproj/mixins.py | 67 ++++++++++++++++++--------- xproj/tests/test_3rdparty_index.py | 38 +++++++++++++--- xproj/tests/test_accessor.py | 30 +++++++----- xproj/typing.py | 73 ++++++++++++++++++++++++++++++ 7 files changed, 199 insertions(+), 58 deletions(-) create mode 100644 xproj/typing.py diff --git a/docs/api-hidden.rst b/docs/api-hidden.rst index 032fa39..ba12842 100644 --- a/docs/api-hidden.rst +++ b/docs/api-hidden.rst @@ -12,7 +12,8 @@ :toctree: _api_generated/ ProjAccessorMixin._proj_set_crs - ProjIndexMixin._proj_get_crs + ProjIndexMixin.crs + ProjIndexMixin._proj_crs_equals ProjIndexMixin._proj_set_crs ProjIndexMixin._proj_to_crs format_compact_cf diff --git a/docs/integration.md b/docs/integration.md index 62af775..6db32ad 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -126,7 +126,8 @@ class GeoIndex(xr.indexes.PandasIndex, xproj.ProjIndexMixin): return super().sel(*args, **kwargs) - def _proj_get_crs(self): + @property + def crs(self): return self._crs def _proj_set_crs(self, spatial_ref, crs): @@ -184,8 +185,9 @@ CRS-aware! (just a warning is emitted below). ds_geo_wgs84.sel(lat=70) ``` -Since ``GeoIndex`` also implements the ``_proj_get_crs`` method it is possible -to get the CRS from the "lat" coordinate like so: +``GeoIndex`` has a ``crs`` property (as required by +{class}`~xproj.ProjIndexMixin`), which is possible to access also via the +``proj`` accessor like so: ```{code-cell} ipython3 ds_geo_wgs84.proj("lat").crs diff --git a/xproj/accessor.py b/xproj/accessor.py index 81a9c0f..0aef05b 100644 --- a/xproj/accessor.py +++ b/xproj/accessor.py @@ -10,6 +10,7 @@ from xproj.crs_utils import format_compact_cf from xproj.index import CRSIndex from xproj.mixins import ProjIndexMixin +from xproj.typing import CRSAwareIndex from xproj.utils import Frozen, FrozenDict @@ -94,21 +95,31 @@ class CRSProxy: _obj: xr.Dataset | xr.DataArray _crs_coord_name: Hashable - _crs: pyproj.CRS + _crs: pyproj.CRS | None - def __init__(self, obj: xr.Dataset | xr.DataArray, coord_name: Hashable, crs: pyproj.CRS): + def __init__( + self, obj: xr.Dataset | xr.DataArray, coord_name: Hashable, crs: pyproj.CRS | None + ): self._obj = obj self._crs_coord_name = coord_name self._crs = crs @property - def crs(self) -> pyproj.CRS: - """Return the coordinate reference system as a :class:`pyproj.CRS` object.""" + def crs(self) -> pyproj.CRS | None: + """Return the coordinate reference system as a :class:`pyproj.CRS` object, or + ``None`` if the CRS is undefined. + """ return self._crs def is_crs_aware(index: xr.Index) -> bool: - return isinstance(index, ProjIndexMixin) or hasattr(index, "_proj_get_crs") + if isinstance(index, ProjIndexMixin): + return True + if hasattr(index, "crs"): + crs = getattr(index, "crs") + if isinstance(crs, pyproj.CRS) or crs is None: + return True + return False @xr.register_dataset_accessor("proj") @@ -118,7 +129,7 @@ class ProjAccessor: _obj: xr.Dataset | xr.DataArray _crs_indexes: dict[Hashable, CRSIndex] | None - _crs_aware_indexes: dict[Hashable, xr.Index] | None + _crs_aware_indexes: dict[Hashable, CRSAwareIndex] | None _crs: pyproj.CRS | None | Literal[False] def __init__(self, obj: xr.Dataset | xr.DataArray): @@ -138,7 +149,7 @@ def _cache_all_crs_indexes(self): self._crs_indexes[name] = idx elif is_crs_aware(idx): for name in vars: - self._crs_aware_indexes[name] = idx + self._crs_aware_indexes[name] = cast(CRSAwareIndex, idx) @property def crs_indexes(self) -> Frozen[Hashable, CRSIndex]: @@ -154,13 +165,12 @@ def crs_indexes(self) -> Frozen[Hashable, CRSIndex]: return FrozenDict(self._crs_indexes) @property - def crs_aware_indexes(self) -> Frozen[Hashable, xr.Index]: + def crs_aware_indexes(self) -> Frozen[Hashable, CRSAwareIndex]: """Return an immutable dictionary of coordinate names as keys and xarray Index objects that are CRS-aware. A :term:`CRS-aware index` is an :py:class:`xarray.Index` object that - must at least implements a method like - :py:meth:`~xproj.ProjIndexMixin._proj_get_crs`. + must at least implement a property like :py:meth:`~xproj.ProjIndexMixin.crs`. """ if self._crs_aware_indexes is None: @@ -205,10 +215,10 @@ def __call__(self, coord_name: Hashable): A proxy accessor for a single CRS. """ - crs: pyproj.CRS + crs: pyproj.CRS | None if coord_name in self.crs_aware_indexes: - crs = self.crs_aware_indexes[coord_name]._proj_get_crs() # type: ignore + crs = self.crs_aware_indexes[coord_name].crs else: crs = self._get_crs_index(coord_name).crs @@ -236,7 +246,7 @@ def crs(self) -> pyproj.CRS | None: if self._crs is False: all_crs = {name: idx.crs for name, idx in self.crs_indexes.items()} for name, idx in self.crs_aware_indexes.items(): - crs = idx._proj_get_crs() # type: ignore + crs = idx.crs if crs is not None: all_crs[name] = crs @@ -387,7 +397,7 @@ def map_crs( ) continue - index_crs = index._proj_get_crs() # type: ignore + index_crs = cast(CRSAwareIndex, index).crs if not allow_override: if index_crs is not None and index_crs != crs: diff --git a/xproj/mixins.py b/xproj/mixins.py index 2c64d7e..ceb6a56 100644 --- a/xproj/mixins.py +++ b/xproj/mixins.py @@ -1,23 +1,17 @@ +from __future__ import annotations + import abc -import sys from collections.abc import Hashable -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Generic, TypeVar import pyproj -import xarray as xr +from xarray import DataArray, Dataset + +if TYPE_CHECKING: + from xproj.typing import CRSAwareIndex, Self -try: - if sys.version_info >= (3, 11): - from typing import Self - else: - from typing_extensions import Self -except ImportError: - if TYPE_CHECKING: - raise - else: - Self: Any = None -T_Xarray_Object = TypeVar("T_Xarray_Object", xr.Dataset, xr.DataArray) +T_Xarray_Object = TypeVar("T_Xarray_Object", Dataset, DataArray) class ProjAccessorMixin(abc.ABC, Generic[T_Xarray_Object]): @@ -46,20 +40,49 @@ def _proj_set_crs(self, spatial_ref: Hashable, crs: pyproj.CRS) -> T_Xarray_Obje class ProjIndexMixin(abc.ABC): - """Mixin class that marks XProj support for an Xarray index.""" + """Mixin class that marks XProj support for an Xarray index. - @abc.abstractmethod - def _proj_get_crs(self) -> pyproj.CRS | None: - """XProj access to the CRS of the index. + An :py:class:`xarray.Index` that inherits from this mixin class is + identified by XProj as a :term:`CRS-aware index` (note that an Xarray index + that simply has a ``crs`` property may also be identified as such, although + it may lack some XProj support). - Returns - ------- - pyproj.crs.CRS or None - The CRS of the index or None if not (yet) defined. + """ + @property + @abc.abstractmethod + def crs(self) -> pyproj.CRS | None: + """Returns the coordinate reference system (CRS) of the index as a + :class:`pyproj.crs.CRS` object, or ``None`` if CRS is undefined. """ ... + def _proj_crs_equals(self, other: CRSAwareIndex, allow_none: bool = False) -> bool: + """Helper method to check if this CRS-aware index has the same CRS than the + other given CRS-aware index. + + This method is usually called internally within the index's ``equals()``, + ``join()`` and ``reindex_like()`` methods. + + Parameters + ---------- + other : xarray.Index + The other CRS-aware index to compare with this index. + allow_none : bool, optional + If True, any undefined CRS is treated as the same (default: False). + + """ + # code taken from geopandas (BSD-3 Licence) + + other_crs = other.crs + + if allow_none: + if self.crs is None or other_crs is None: + return True + if not self.crs == other_crs: + return False + return True + def _proj_set_crs( self: Self, spatial_ref: Hashable, diff --git a/xproj/tests/test_3rdparty_index.py b/xproj/tests/test_3rdparty_index.py index 0b82680..337a3e3 100644 --- a/xproj/tests/test_3rdparty_index.py +++ b/xproj/tests/test_3rdparty_index.py @@ -6,13 +6,16 @@ import xproj -class CRSAwareIndex(PandasIndex): +class IndexWithCRS(PandasIndex, xproj.ProjIndexMixin): + _crs: pyproj.CRS | None + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._crs = None self.transformed = False - def _proj_get_crs(self): + @property + def crs(self) -> pyproj.CRS | None: return self._crs def _proj_set_crs(self, spatial_ref, crs): @@ -25,6 +28,13 @@ def _proj_to_crs(self, spatial_ref, crs): new_index.transformed = True return new_index + def equals(self, other, exclude=None) -> bool: + if not isinstance(other, IndexWithCRS): + return False + if not self._proj_crs_equals(other, allow_none=True): + return False + return super().equals(other) + def _copy(self, deep=True, memo=None): # bug in PandasIndex? subclass attribute not copied obj = super()._copy(deep=deep, memo=memo) @@ -45,7 +55,7 @@ def __init__(self, *args, **kwargs): def test_map_crs() -> None: ds = ( xr.Dataset(coords={"foo": ("x", [1, 2])}) - .set_xindex("foo", CRSAwareIndex) + .set_xindex("foo", IndexWithCRS) .proj.assign_crs(spatial_ref=pyproj.CRS.from_epsg(4326)) ) @@ -77,13 +87,14 @@ def test_map_crs_read_only(epsg, crs_match) -> None: # try mapping the CRS a spatial ref coordinate to a CRS-aware index # that has read-only CRS access - class ImmutableCRSAwareIndex(PandasIndex, xproj.ProjIndexMixin): - def _proj_get_crs(self): + class IndexWithImmutableCRS(PandasIndex, xproj.ProjIndexMixin): + @property + def crs(self) -> pyproj.CRS | None: return pyproj.CRS.from_epsg(4326) ds = ( xr.Dataset(coords={"foo": ("x", [1, 2])}) - .set_xindex("foo", ImmutableCRSAwareIndex) + .set_xindex("foo", IndexWithImmutableCRS) .proj.assign_crs(spatial_ref=pyproj.CRS.from_epsg(epsg)) ) @@ -99,3 +110,18 @@ def _proj_get_crs(self): with pytest.raises(NotImplementedError): ds.proj.map_crs(spatial_ref=["foo"], allow_override=True, transform=True) + + +def test_index_crs_equals() -> None: + ds_base = xr.Dataset(coords={"foo": ("x", [1, 2])}).set_xindex("foo", IndexWithCRS) + + ds_crs_undef = ds_base.copy() + ds_crs1 = ds_base.proj.assign_crs(spatial_ref=pyproj.CRS.from_epsg(4326)).proj.map_crs( + spatial_ref=["foo"] + ) + ds_crs2 = ds_base.proj.assign_crs(spatial_ref=pyproj.CRS.from_epsg(4978)).proj.map_crs( + spatial_ref=["foo"] + ) + + assert ds_crs_undef.xindexes["foo"].equals(ds_crs1.xindexes["foo"]) + assert not ds_crs1.xindexes["foo"].equals(ds_crs2.xindexes["foo"]) diff --git a/xproj/tests/test_accessor.py b/xproj/tests/test_accessor.py index d80803e..3744b49 100644 --- a/xproj/tests/test_accessor.py +++ b/xproj/tests/test_accessor.py @@ -1,9 +1,12 @@ +from typing import cast + import pyproj import pytest import xarray as xr from xarray.indexes import Index, PandasIndex import xproj +from xproj.typing import CRSAwareIndex @pytest.fixture @@ -28,17 +31,19 @@ def spatial_xr_obj(request, spatial_dataset, spatial_dataarray): yield spatial_dataarray -class ImmutableCRSIndex(PandasIndex): - def _proj_get_crs(self): +class IndexWithImmutableCRS(PandasIndex): + @property + def crs(self): return pyproj.CRS.from_epsg(4326) -class MutableCRSIndex(PandasIndex): +class IndexWithMutableCRS(PandasIndex): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._crs = None - def _proj_get_crs(self): + @property + def crs(self): return self._crs def _proj_set_crs(self, crs_coord_name, crs): @@ -69,7 +74,7 @@ def test_accessor_crs_indexes(spatial_xr_obj) -> None: def test_accessor_crs_aware_indexes() -> None: - ds = xr.Dataset(coords={"foo": ("x", [1, 2])}).set_xindex("foo", ImmutableCRSIndex) + ds = xr.Dataset(coords={"foo": ("x", [1, 2])}).set_xindex("foo", IndexWithImmutableCRS) assert ds.proj.crs_aware_indexes["foo"] is ds.xindexes["foo"] @@ -78,7 +83,7 @@ def test_accessor_crs_aware_indexes() -> None: # frozen dict with pytest.raises(TypeError, match="not support item assignment"): - ds.proj.crs_aware_indexes["new"] = ImmutableCRSIndex([2, 3], "x") + ds.proj.crs_aware_indexes["new"] = IndexWithImmutableCRS([2, 3], "x") with pytest.raises(TypeError, match="not support item deletion"): del ds.proj.crs_aware_indexes["foo"] @@ -96,9 +101,9 @@ def test_accessor_callable(spatial_xr_obj) -> None: def test_accessor_callable_crs_aware_index() -> None: - ds = xr.Dataset(coords={"foo": ("x", [1, 2])}).set_xindex("foo", ImmutableCRSIndex) + ds = xr.Dataset(coords={"foo": ("x", [1, 2])}).set_xindex("foo", IndexWithImmutableCRS) - assert ds.proj("foo").crs == ds.xindexes["foo"]._proj_get_crs() # type: ignore + assert ds.proj("foo").crs == cast(CRSAwareIndex, ds.xindexes["foo"]).crs def test_accessor_callable_error(spatial_xr_obj) -> None: @@ -149,7 +154,7 @@ def _proj_get_crs(self): ds = ds.assign_coords(foo=("x", [1, 2])).set_xindex("foo", NoCRSIndex) assert ds.proj.crs is None - ds = ds.drop_indexes("foo").set_xindex("foo", ImmutableCRSIndex) + ds = ds.drop_indexes("foo").set_xindex("foo", IndexWithImmutableCRS) assert ds.proj.crs == pyproj.CRS.from_epsg(4326) ds = ds.drop_vars("foo") @@ -196,7 +201,7 @@ def test_accessor_map_crs(spatial_xr_obj) -> None: # nothing happens but should return a copy assert spatial_xr_obj.proj.map_crs() is not spatial_xr_obj - obj = spatial_xr_obj.assign_coords(foo=("x", [1, 2])).set_xindex("foo", MutableCRSIndex) + obj = spatial_xr_obj.assign_coords(foo=("x", [1, 2])).set_xindex("foo", IndexWithMutableCRS) actual = obj.proj.map_crs(spatial_ref=["foo"]) actual2 = obj.proj.map_crs({"spatial_ref": ["foo"]}) assert actual.proj("spatial_ref").crs == actual.proj("foo").crs @@ -214,7 +219,7 @@ def test_accessor_map_crs(spatial_xr_obj) -> None: with pytest.raises(KeyError, match="no index found"): obj.proj.map_crs(spatial_ref=["foo"]) - obj = spatial_xr_obj.assign_coords(foo=("x", [1, 2])).set_xindex("foo", MutableCRSIndex) + obj = spatial_xr_obj.assign_coords(foo=("x", [1, 2])).set_xindex("foo", IndexWithMutableCRS) with pytest.raises(KeyError, match="no coordinate 'a' found"): obj.proj.map_crs(a=["foo"]) @@ -233,7 +238,8 @@ def from_variables(cls, variables, *, options): } return cls(xy_indexes) - def _proj_get_crs(self): + @property + def crs(self): return self._crs def _proj_set_crs(self, spatial_ref, crs): diff --git a/xproj/typing.py b/xproj/typing.py new file mode 100644 index 0000000..7480b03 --- /dev/null +++ b/xproj/typing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import sys +from collections.abc import Hashable, Iterable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Protocol, overload + +import numpy as np +import pandas as pd +from pyproj import CRS +from xarray import Index, Variable + +if TYPE_CHECKING: + from xarray.core.indexing import IndexSelResult + from xarray.core.types import JoinOptions + +try: + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self +except ImportError: + if TYPE_CHECKING: + raise + else: + Self: Any = None + + +class CRSAwareIndex(Protocol): + """Protocol class that defines a CRS-aware Xarray index.""" + + @property + def crs(self) -> CRS | None: ... + + # TODO: eventually we won't need to copy the xarray.Index interface here? + # (https://github.com/python/typing/issues/213) + + @classmethod + def from_variables( + cls, + variables: Mapping[Any, Variable], + *, + options: Mapping[str, Any], + ) -> Index: ... + + @classmethod + def concat( + cls, + indexes: Sequence[Self], + dim: Hashable, + positions: Iterable[Iterable[int]] | None = None, + ) -> Index: ... + + def create_variables( + self, variables: Mapping[Any, Variable] | None = None + ) -> dict[Hashable, Variable]: ... + + def to_pandas_index(self) -> pd.Index: ... + + def isel(self, indexers: Mapping[Any, int | slice | np.ndarray | Variable]) -> Index | None: ... + + def sel(self, labels: dict[Any, Any]) -> IndexSelResult: ... + + def join(self, other: Self, how: JoinOptions = "inner") -> Self: ... + + def reindex_like(self, other: Self) -> dict[Hashable, Any]: ... + + @overload + def equals(self, other: Index) -> bool: ... + + @overload + def equals(self, other: Index, *, exclude: frozenset[Hashable] | None = None) -> bool: ... + + def equals(self, other: Index, **kwargs) -> bool: ... From 5468e69b3ccab1ddf012f940e43b0c1686a21748 Mon Sep 17 00:00:00 2001 From: Benoit Bovy Date: Fri, 6 Jun 2025 12:05:12 +0200 Subject: [PATCH 2/7] add a couple of CRS utility functions Copied and adapted from xvec. --- docs/api.rst | 9 ++++ xproj/__init__.py | 4 +- xproj/crs_utils.py | 91 +++++++++++++++++++++++++++++++++-- xproj/tests/test_crs_utils.py | 31 ++++++++++++ 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 xproj/tests/test_crs_utils.py diff --git a/docs/api.rst b/docs/api.rst index f2fddd7..0a75e0b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -74,6 +74,15 @@ To enable it, be sure to import ``xproj`` after ``xarray``: .. currentmodule:: xproj +CRS utility functions +--------------------- + +.. autosummary:: + :toctree: _api_generated/ + + format_crs + get_common_crs + 3rd-party Xarray extensions --------------------------- diff --git a/xproj/__init__.py b/xproj/__init__.py index a60aa08..a4cf386 100644 --- a/xproj/__init__.py +++ b/xproj/__init__.py @@ -2,7 +2,7 @@ from .accessor import ProjAccessor as _ProjAccessor # noqa: F401 from .accessor import register_accessor -from .crs_utils import format_compact_cf, format_full_cf_gdal +from .crs_utils import format_compact_cf, format_crs, format_full_cf_gdal, get_common_crs from .index import CRSIndex # noqa: F401 from .mixins import ProjAccessorMixin, ProjIndexMixin @@ -12,7 +12,9 @@ "ProjAccessorMixin", "ProjIndexMixin", "format_compact_cf", + "format_crs", "format_full_cf_gdal", + "get_common_crs", "register_accessor", ] diff --git a/xproj/crs_utils.py b/xproj/crs_utils.py index 8d97b3c..55a2aca 100644 --- a/xproj/crs_utils.py +++ b/xproj/crs_utils.py @@ -1,9 +1,31 @@ -from typing import Any +import warnings +from collections.abc import Sequence +from typing import Any, Literal -import pyproj +from pyproj import CRS -def format_compact_cf(crs: pyproj.CRS) -> dict[str, Any]: +def format_crs(crs: CRS | None, max_width: int = 50) -> str: + """Format CRS as a string. + + Parameters + ---------- + crs : pyproj.crs.CRS + The input CRS object to format. + max_width : int, optional + Maximum number of characters beyond which the formatted CRS + will be truncated (default: 50). + + """ + if crs is not None: + srs = crs.to_string() + else: + srs = "None" + + return srs if len(srs) <= max_width else " ".join([srs[:max_width], "..."]) + + +def format_compact_cf(crs: CRS) -> dict[str, Any]: """Format CRS as a dictionary for minimal compatibility with CF conventions. @@ -30,7 +52,7 @@ def format_compact_cf(crs: pyproj.CRS) -> dict[str, Any]: return {"crs_wkt": crs.to_wkt()} -def format_full_cf_gdal(crs: pyproj.CRS) -> dict[str, Any]: +def format_full_cf_gdal(crs: CRS) -> dict[str, Any]: """Format CRS as a dictionary for full compatibility with CF conventions and GDAL. @@ -61,3 +83,64 @@ def format_full_cf_gdal(crs: pyproj.CRS) -> dict[str, Any]: output = crs.to_cf() output["spatial_ref"] = crs.to_wkt() return output + + +def get_common_crs( + crs_objs: Sequence[CRS | None] | set[CRS | None], + on_undefined_crs: Literal["raise", "warn", "ignore"] = "warn", + stacklevel: int = 3, +) -> CRS | None: + """Try getting a common, unique CRS from an input sequence of (possibly + undefined) CRS objects. + + Parameters + ---------- + crs_objs : sequence or set + Sequence of either CRS objects or ``None`` (undefined CRS). + on_undefined_crs : {"raise", "warn", "ignore"}, optional + If 'raise', raises a ValueError if a non-null CRS is found but + one or more inputs have undefined CRS. If 'warn' (default), emits a + UserWarning instead. If 'ignore' do nothing instead. + stacklevel : int, optional + Stack level value used for the emitted warning (default: 3). + + Returns + ------- + pyproj.crs.CRS or None + The common (possibly undefined) CRS. + + Raises + ------ + ValueError + If multiple conflicting CRS objects are found. + + Warns + ----- + UserWarning + If a common, unique CRS is found but one or more of the + inputs have undefined CRS. + + """ + # code taken from geopandas (BSD-3 Licence) + + crs_objs = set(crs_objs) + + crs_not_none = [crs for crs in crs_objs if crs is not None] + names = [crs.name for crs in crs_not_none] + + if len(crs_not_none) == 0: + return None + if len(crs_not_none) == 1: + if len(crs_objs) != 1: + if on_undefined_crs == "raise": + raise ValueError("one or more inputs have undefined CRS.") + elif on_undefined_crs == "warn": + warnings.warn( # noqa: B028 + "CRS is undefined for some of the inputs. " + f"Setting output's CRS as {names[0]} " + "(the single non-null CRS provided).", + stacklevel=stacklevel, + ) + return crs_not_none[0] + + raise ValueError(f"cannot determine common CRS from inputs CRSes {names}. ") diff --git a/xproj/tests/test_crs_utils.py b/xproj/tests/test_crs_utils.py new file mode 100644 index 0000000..859632c --- /dev/null +++ b/xproj/tests/test_crs_utils.py @@ -0,0 +1,31 @@ +import pytest +from pyproj import CRS + +from xproj import format_crs, get_common_crs + + +def test_format_crs() -> None: + crs = CRS.from_epsg(4326) + assert format_crs(crs) == "EPSG:4326" + assert format_crs(crs, max_width=4) == "EPSG ..." + + +def test_get_common_crs() -> None: + objs = [ + CRS.from_epsg(4326), + None, + CRS.from_epsg(4326), + ] + + with pytest.warns(UserWarning, match="CRS is undefined for some of the inputs"): + get_common_crs(objs) + + assert get_common_crs(objs, on_undefined_crs="ignore") == CRS.from_epsg(4326) + + with pytest.raises(ValueError, match="one or more inputs have undefined CRS"): + get_common_crs(objs, on_undefined_crs="raise") + + assert get_common_crs([None, None]) is None + + with pytest.raises(ValueError, match="cannot determine common CRS"): + get_common_crs([CRS.from_epsg(4326), CRS.from_epsg(4978)]) From e6d60a50387223058f94f749d8582852afd341fe Mon Sep 17 00:00:00 2001 From: Benoit Bovy Date: Fri, 6 Jun 2025 12:31:31 +0200 Subject: [PATCH 3/7] CI: mypy install additional types --- .github/workflows/ci-additional.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-additional.yml b/.github/workflows/ci-additional.yml index 470a196..075ee50 100644 --- a/.github/workflows/ci-additional.yml +++ b/.github/workflows/ci-additional.yml @@ -45,4 +45,4 @@ jobs: - name: Run mypy run: | - python -m mypy + python -m mypy --install-types --non-interactive From 56020b22064bcfea78a000d900253f04d49e1ce9 Mon Sep 17 00:00:00 2001 From: Benoit Bovy Date: Wed, 11 Jun 2025 14:14:50 +0200 Subject: [PATCH 4/7] doc: add crs property in mixin class API --- docs/_templates/autosummary/mixin.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_templates/autosummary/mixin.rst b/docs/_templates/autosummary/mixin.rst index 805ccbf..d8e0ddf 100644 --- a/docs/_templates/autosummary/mixin.rst +++ b/docs/_templates/autosummary/mixin.rst @@ -8,7 +8,7 @@ .. autosummary:: {% for item in members %} - {% if item.startswith('_proj') %} + {% if item.startswith('_proj') or item == "crs" %} ~{{ name }}.{{ item }} {% endif %} {%- endfor %} From e0dabade39684677fe4636f160ff7bfdefdc2e3c Mon Sep 17 00:00:00 2001 From: Benoit Bovy Date: Wed, 11 Jun 2025 14:31:17 +0200 Subject: [PATCH 5/7] docstring tweaks --- xproj/crs_utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/xproj/crs_utils.py b/xproj/crs_utils.py index 55a2aca..9d3511a 100644 --- a/xproj/crs_utils.py +++ b/xproj/crs_utils.py @@ -96,11 +96,12 @@ def get_common_crs( Parameters ---------- crs_objs : sequence or set - Sequence of either CRS objects or ``None`` (undefined CRS). + Sequence of either :py:class:`pyproj.CRS` objects or ``None`` + (undefined CRS). on_undefined_crs : {"raise", "warn", "ignore"}, optional - If 'raise', raises a ValueError if a non-null CRS is found but - one or more inputs have undefined CRS. If 'warn' (default), emits a - UserWarning instead. If 'ignore' do nothing instead. + If "raise", raises a ValueError if a non-null CRS is found but + one or more inputs have undefined CRS. If "warn" (default), emits a + UserWarning instead. If "ignore", do nothing. stacklevel : int, optional Stack level value used for the emitted warning (default: 3). From 92ce24fdfe33d9cbcfaf7b814bf13851a44659b9 Mon Sep 17 00:00:00 2001 From: Benoit Bovy Date: Wed, 11 Jun 2025 14:32:17 +0200 Subject: [PATCH 6/7] misc. typing fixes --- xproj/index.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/xproj/index.py b/xproj/index.py index 5b93896..43cdc95 100644 --- a/xproj/index.py +++ b/xproj/index.py @@ -1,10 +1,11 @@ from __future__ import annotations -from _collections_abc import Mapping +from collections.abc import Hashable, Mapping from typing import Any import pyproj import xarray as xr +from pyproj.exceptions import CRSError from xarray.indexes import Index @@ -69,7 +70,7 @@ def from_variables( crs = pyproj.CRS.from_user_input(options["crs"]) else: crs = pyproj.CRS.from_cf(var.attrs) - except pyproj.crs.CRSError: + except CRSError: raise ValueError( f"CRS could not be constructed from attrs on provided variable {varname!r}" f"Either add appropriate attributes to {varname!r} or pass a `crs` kwarg." @@ -77,7 +78,7 @@ def from_variables( return cls(crs) - def equals(self, other: Index) -> bool: + def equals(self, other: Index, *, exclude: frozenset[Hashable] | None = None) -> bool: if not isinstance(other, CRSIndex): return False if not self.crs == other.crs: From 9c772f0248302dddf667051d5af329fd628b7e77 Mon Sep 17 00:00:00 2001 From: Benoit Bovy Date: Wed, 11 Jun 2025 14:39:22 +0200 Subject: [PATCH 7/7] ci: temp fix (xarray upstream) --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6ee57df..f6c127a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,8 @@ jobs: - name: Install xproj run: | python -m pip install -e .[test] + # TODO: remove in next xarray release (https://github.com/pydata/xarray/pull/10413) + python -m pip install typing_extensions python -m pip install pytest-cov - name: Run tests