From aa4c5b1a185551c93480320a7fee2b8ad32e3b24 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:29:21 +0100 Subject: [PATCH 01/11] Add common accessors --- pyproject.toml | 5 +++ xarray/core/dataarray.py | 84 ++++++++++++++++++++++++++++++++++++++++ xarray/core/dataset.py | 84 ++++++++++++++++++++++++++++++++++++++++ xarray/core/datatree.py | 37 ++++++++++++++++++ 4 files changed, 210 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c8fd153dd52..917f202da1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,6 +138,7 @@ module = [ "bottleneck.*", "cartopy.*", "cf_units.*", + "cf_xarray.*", "cfgrib.*", "cftime.*", "cloudpickle.*", @@ -146,6 +147,7 @@ module = [ "fsspec.*", "h5netcdf.*", "h5py.*", + "hvplot.*", "iris.*", "mpl_toolkits.*", "nc_time_axis.*", @@ -154,14 +156,17 @@ module = [ "numcodecs.*", "opt_einsum.*", "pint.*", + "pint_xarray.*", "pooch.*", "pyarrow.*", "pydap.*", + "rioxarray.*", "scipy.*", "seaborn.*", "setuptools", "sparse.*", "toolz.*", + "xarray_plotly.*", "zarr.*", "numpy.exceptions.*", # remove once support for `numpy<2.0` has been dropped "array_api_strict.*", diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py index fcfa0317131..0ab552ab459 100644 --- a/xarray/core/dataarray.py +++ b/xarray/core/dataarray.py @@ -85,10 +85,16 @@ from xarray.util.deprecation_helpers import _deprecate_positional_args, deprecate_dims if TYPE_CHECKING: + # External accessor types (for IDE support) + from cf_xarray.accessor import CFAccessor from dask.dataframe import DataFrame as DaskDataFrame from dask.delayed import Delayed + from hvplot.xarray import hvPlotAccessor from iris.cube import Cube as iris_Cube from numpy.typing import ArrayLike + from pint_xarray import PintDataArrayAccessor + from rioxarray import RasterArray + from xarray_plotly import DataArrayPlotlyAccessor from xarray.backends import ZarrStore from xarray.backends.api import T_NetcdfEngine, T_NetcdfTypes @@ -412,6 +418,84 @@ class DataArray( dt = utils.UncachedAccessor(CombinedDatetimelikeAccessor["DataArray"]) + # External accessor properties (for IDE support) + # These provide full autocompletion when packages are installed + + @property + def hvplot(self) -> hvPlotAccessor: + """ + hvPlot accessor for interactive plotting. + + Requires: ``pip install hvplot`` + + See Also + -------- + hvplot : https://hvplot.holoviz.org/ + """ + from xarray.accessors import ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("hvplot", self, ACCESSORS) + + @property + def cf(self) -> CFAccessor: + """ + CF conventions accessor. + + Requires: ``pip install cf-xarray`` + + See Also + -------- + cf_xarray : https://cf-xarray.readthedocs.io/ + """ + from xarray.accessors import ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("cf", self, ACCESSORS) + + @property + def pint(self) -> PintDataArrayAccessor: + """ + Pint unit accessor for unit-aware arrays. + + Requires: ``pip install pint-xarray`` + + See Also + -------- + pint_xarray : https://pint-xarray.readthedocs.io/ + """ + from xarray.accessors import ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("pint", self, ACCESSORS) + + @property + def rio(self) -> RasterArray: + """ + Rasterio accessor for geospatial raster data. + + Requires: ``pip install rioxarray`` + + See Also + -------- + rioxarray : https://corteva.github.io/rioxarray/ + """ + from xarray.accessors import ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("rio", self, ACCESSORS) + + @property + def plotly(self) -> DataArrayPlotlyAccessor: + """ + Plotly accessor for interactive Plotly visualizations. + + Requires: ``pip install xarray-plotly`` + + See Also + -------- + xarray_plotly : https://github.com/xarray-contrib/xarray-plotly + """ + from xarray.accessors import ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("plotly", self, ACCESSORS) + def __init__( self, data: Any = dtypes.NA, diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py index 84a67d95412..07b925c0b64 100644 --- a/xarray/core/dataset.py +++ b/xarray/core/dataset.py @@ -129,9 +129,15 @@ ) if TYPE_CHECKING: + # External accessor types (for IDE support) + from cf_xarray.accessor import CFAccessor from dask.dataframe import DataFrame as DaskDataFrame from dask.delayed import Delayed + from hvplot.xarray import hvPlotAccessor from numpy.typing import ArrayLike + from pint_xarray import PintDatasetAccessor + from rioxarray import RasterDataset + from xarray_plotly import DatasetPlotlyAccessor from xarray.backends import AbstractDataStore, ZarrStore from xarray.backends.api import T_NetcdfEngine, T_NetcdfTypes @@ -369,6 +375,84 @@ class Dataset( "_variables", ) + # External accessor properties (for IDE support) + # These provide full autocompletion when packages are installed + + @property + def hvplot(self) -> hvPlotAccessor: + """ + hvPlot accessor for interactive plotting. + + Requires: ``pip install hvplot`` + + See Also + -------- + hvplot : https://hvplot.holoviz.org/ + """ + from xarray.accessors import ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("hvplot", self, ACCESSORS) + + @property + def cf(self) -> CFAccessor: + """ + CF conventions accessor. + + Requires: ``pip install cf-xarray`` + + See Also + -------- + cf_xarray : https://cf-xarray.readthedocs.io/ + """ + from xarray.accessors import ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("cf", self, ACCESSORS) + + @property + def pint(self) -> PintDatasetAccessor: + """ + Pint unit accessor for unit-aware arrays. + + Requires: ``pip install pint-xarray`` + + See Also + -------- + pint_xarray : https://pint-xarray.readthedocs.io/ + """ + from xarray.accessors import DATASET_ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("pint", self, DATASET_ACCESSORS) + + @property + def rio(self) -> RasterDataset: + """ + Rasterio accessor for geospatial raster data. + + Requires: ``pip install rioxarray`` + + See Also + -------- + rioxarray : https://corteva.github.io/rioxarray/ + """ + from xarray.accessors import DATASET_ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("rio", self, DATASET_ACCESSORS) + + @property + def plotly(self) -> DatasetPlotlyAccessor: + """ + Plotly accessor for interactive Plotly visualizations. + + Requires: ``pip install xarray-plotly`` + + See Also + -------- + xarray_plotly : https://github.com/xarray-contrib/xarray-plotly + """ + from xarray.accessors import DATASET_ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("plotly", self, DATASET_ACCESSORS) + def __init__( self, # could make a VariableArgs to use more generally, and refine these diff --git a/xarray/core/datatree.py b/xarray/core/datatree.py index e079332780c..90af177c59c 100644 --- a/xarray/core/datatree.py +++ b/xarray/core/datatree.py @@ -78,7 +78,11 @@ if TYPE_CHECKING: import numpy as np import pandas as pd + + # External accessor types (for IDE support) + from cf_xarray.accessor import CFAccessor from dask.delayed import Delayed + from hvplot.xarray import hvPlotAccessor from xarray.backends import ZarrStore from xarray.backends.writers import T_DataTreeNetcdfEngine, T_DataTreeNetcdfTypes @@ -514,6 +518,39 @@ class DataTree( "_parent", ) + # External accessor properties (for IDE support) + # These provide full autocompletion when packages are installed + + @property + def hvplot(self) -> hvPlotAccessor: + """ + hvPlot accessor for interactive plotting. + + Requires: ``pip install hvplot`` + + See Also + -------- + hvplot : https://hvplot.holoviz.org/ + """ + from xarray.accessors import ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("hvplot", self, ACCESSORS) + + @property + def cf(self) -> CFAccessor: + """ + CF conventions accessor. + + Requires: ``pip install cf-xarray`` + + See Also + -------- + cf_xarray : https://cf-xarray.readthedocs.io/ + """ + from xarray.accessors import ACCESSORS, _get_cached_accessor + + return _get_cached_accessor("cf", self, ACCESSORS) + def __init__( self, dataset: Dataset | Coordinates | None = None, From 05aeef0e2ab3ff57509f53fb4506365d0b8cc98c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:37:38 +0100 Subject: [PATCH 02/11] Add other error --- xarray/core/dataarray.py | 23 ++++++++++++----------- xarray/core/dataset.py | 23 ++++++++++++----------- xarray/core/datatree.py | 11 ++++++----- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py index 0ab552ab459..7219d10860c 100644 --- a/xarray/core/dataarray.py +++ b/xarray/core/dataarray.py @@ -419,7 +419,8 @@ class DataArray( dt = utils.UncachedAccessor(CombinedDatetimelikeAccessor["DataArray"]) # External accessor properties (for IDE support) - # These provide full autocompletion when packages are installed + # These provide full autocompletion when packages are installed. + # Raises AttributeError for uninstalled packages (so hasattr returns False). @property def hvplot(self) -> hvPlotAccessor: @@ -432,9 +433,9 @@ def hvplot(self) -> hvPlotAccessor: -------- hvplot : https://hvplot.holoviz.org/ """ - from xarray.accessors import ACCESSORS, _get_cached_accessor + from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - return _get_cached_accessor("hvplot", self, ACCESSORS) + return _get_external_accessor("hvplot", self, DATAARRAY_ACCESSORS) @property def cf(self) -> CFAccessor: @@ -447,9 +448,9 @@ def cf(self) -> CFAccessor: -------- cf_xarray : https://cf-xarray.readthedocs.io/ """ - from xarray.accessors import ACCESSORS, _get_cached_accessor + from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - return _get_cached_accessor("cf", self, ACCESSORS) + return _get_external_accessor("cf", self, DATAARRAY_ACCESSORS) @property def pint(self) -> PintDataArrayAccessor: @@ -462,9 +463,9 @@ def pint(self) -> PintDataArrayAccessor: -------- pint_xarray : https://pint-xarray.readthedocs.io/ """ - from xarray.accessors import ACCESSORS, _get_cached_accessor + from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - return _get_cached_accessor("pint", self, ACCESSORS) + return _get_external_accessor("pint", self, DATAARRAY_ACCESSORS) @property def rio(self) -> RasterArray: @@ -477,9 +478,9 @@ def rio(self) -> RasterArray: -------- rioxarray : https://corteva.github.io/rioxarray/ """ - from xarray.accessors import ACCESSORS, _get_cached_accessor + from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - return _get_cached_accessor("rio", self, ACCESSORS) + return _get_external_accessor("rio", self, DATAARRAY_ACCESSORS) @property def plotly(self) -> DataArrayPlotlyAccessor: @@ -492,9 +493,9 @@ def plotly(self) -> DataArrayPlotlyAccessor: -------- xarray_plotly : https://github.com/xarray-contrib/xarray-plotly """ - from xarray.accessors import ACCESSORS, _get_cached_accessor + from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - return _get_cached_accessor("plotly", self, ACCESSORS) + return _get_external_accessor("plotly", self, DATAARRAY_ACCESSORS) def __init__( self, diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py index 07b925c0b64..55c637fe180 100644 --- a/xarray/core/dataset.py +++ b/xarray/core/dataset.py @@ -376,7 +376,8 @@ class Dataset( ) # External accessor properties (for IDE support) - # These provide full autocompletion when packages are installed + # These provide full autocompletion when packages are installed. + # Raises AttributeError for uninstalled packages (so hasattr returns False). @property def hvplot(self) -> hvPlotAccessor: @@ -389,9 +390,9 @@ def hvplot(self) -> hvPlotAccessor: -------- hvplot : https://hvplot.holoviz.org/ """ - from xarray.accessors import ACCESSORS, _get_cached_accessor + from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - return _get_cached_accessor("hvplot", self, ACCESSORS) + return _get_external_accessor("hvplot", self, DATASET_ACCESSORS) @property def cf(self) -> CFAccessor: @@ -404,9 +405,9 @@ def cf(self) -> CFAccessor: -------- cf_xarray : https://cf-xarray.readthedocs.io/ """ - from xarray.accessors import ACCESSORS, _get_cached_accessor + from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - return _get_cached_accessor("cf", self, ACCESSORS) + return _get_external_accessor("cf", self, DATASET_ACCESSORS) @property def pint(self) -> PintDatasetAccessor: @@ -419,9 +420,9 @@ def pint(self) -> PintDatasetAccessor: -------- pint_xarray : https://pint-xarray.readthedocs.io/ """ - from xarray.accessors import DATASET_ACCESSORS, _get_cached_accessor + from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - return _get_cached_accessor("pint", self, DATASET_ACCESSORS) + return _get_external_accessor("pint", self, DATASET_ACCESSORS) @property def rio(self) -> RasterDataset: @@ -434,9 +435,9 @@ def rio(self) -> RasterDataset: -------- rioxarray : https://corteva.github.io/rioxarray/ """ - from xarray.accessors import DATASET_ACCESSORS, _get_cached_accessor + from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - return _get_cached_accessor("rio", self, DATASET_ACCESSORS) + return _get_external_accessor("rio", self, DATASET_ACCESSORS) @property def plotly(self) -> DatasetPlotlyAccessor: @@ -449,9 +450,9 @@ def plotly(self) -> DatasetPlotlyAccessor: -------- xarray_plotly : https://github.com/xarray-contrib/xarray-plotly """ - from xarray.accessors import DATASET_ACCESSORS, _get_cached_accessor + from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - return _get_cached_accessor("plotly", self, DATASET_ACCESSORS) + return _get_external_accessor("plotly", self, DATASET_ACCESSORS) def __init__( self, diff --git a/xarray/core/datatree.py b/xarray/core/datatree.py index 90af177c59c..a4fdd3e0ae6 100644 --- a/xarray/core/datatree.py +++ b/xarray/core/datatree.py @@ -519,7 +519,8 @@ class DataTree( ) # External accessor properties (for IDE support) - # These provide full autocompletion when packages are installed + # These provide full autocompletion when packages are installed. + # Raises AttributeError for uninstalled packages (so hasattr returns False). @property def hvplot(self) -> hvPlotAccessor: @@ -532,9 +533,9 @@ def hvplot(self) -> hvPlotAccessor: -------- hvplot : https://hvplot.holoviz.org/ """ - from xarray.accessors import ACCESSORS, _get_cached_accessor + from xarray.accessors import DATATREE_ACCESSORS, _get_external_accessor - return _get_cached_accessor("hvplot", self, ACCESSORS) + return _get_external_accessor("hvplot", self, DATATREE_ACCESSORS) @property def cf(self) -> CFAccessor: @@ -547,9 +548,9 @@ def cf(self) -> CFAccessor: -------- cf_xarray : https://cf-xarray.readthedocs.io/ """ - from xarray.accessors import ACCESSORS, _get_cached_accessor + from xarray.accessors import DATATREE_ACCESSORS, _get_external_accessor - return _get_cached_accessor("cf", self, ACCESSORS) + return _get_external_accessor("cf", self, DATATREE_ACCESSORS) def __init__( self, From 22314eaf62b87d1f2d0f8d63fa0f0adf4a89a94b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:56:06 +0100 Subject: [PATCH 03/11] Handle overwrites of known accessors (old version of accessor, new xarray) --- xarray/core/extensions.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/xarray/core/extensions.py b/xarray/core/extensions.py index c235fae000a..51a7917b399 100644 --- a/xarray/core/extensions.py +++ b/xarray/core/extensions.py @@ -50,11 +50,28 @@ def __get__(self, obj, cls): def _register_accessor(name, cls): def decorator(accessor): if hasattr(cls, name): + # Skip registration for known external accessors - xarray provides + # typed properties that load them directly for IDE support + from xarray.accessors import ( + DATAARRAY_ACCESSORS, + DATASET_ACCESSORS, + DATATREE_ACCESSORS, + ) + + known_external = ( + set(DATAARRAY_ACCESSORS) + | set(DATASET_ACCESSORS) + | set(DATATREE_ACCESSORS) + ) + if name in known_external: + # Don't overwrite - our typed property handles this accessor + return accessor + warnings.warn( f"registration of accessor {accessor!r} under name {name!r} for type {cls!r} is " "overriding a preexisting attribute with the same name.", AccessorRegistrationWarning, - stacklevel=2, + stacklevel=3, ) setattr(cls, name, _CachedAccessor(name, accessor)) return accessor From 3eacdd583ac7d371a99c73a437738a61957022e2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:08:02 +0100 Subject: [PATCH 04/11] Add missing file , tests and notes --- doc/whats-new.rst | 5 ++ xarray/accessors.py | 135 ++++++++++++++++++++++++++++++++ xarray/tests/test_extensions.py | 77 ++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 xarray/accessors.py diff --git a/doc/whats-new.rst b/doc/whats-new.rst index da9a4c205c9..ced21b83e71 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -27,6 +27,11 @@ New Features brings improved alignment between h5netcdf and libnetcdf4 in the storage of complex numbers (:pull:`11068`). By `Mark Harfouche `_. +- Added typed properties for external accessor packages (hvplot, cf-xarray, + pint-xarray, rioxarray, xarray-plotly), enabling full IDE support including + autocompletion, parameter hints, and docstrings. For uninstalled packages, + ``hasattr()`` returns ``False`` to keep the namespace clean (:pull:`xxxx`). + By `Your Name `_. Breaking Changes diff --git a/xarray/accessors.py b/xarray/accessors.py new file mode 100644 index 00000000000..a06bb2f620b --- /dev/null +++ b/xarray/accessors.py @@ -0,0 +1,135 @@ +""" +External accessor support for xarray. + +This module provides infrastructure for external accessor packages, +enabling full IDE support (autocompletion, parameter hints, docstrings) +for packages like hvplot, cf-xarray, pint-xarray, rioxarray, and xarray-plotly. + +Properties are defined statically for IDE support, but raise AttributeError +for uninstalled packages (making hasattr() return False). +""" + +from __future__ import annotations + +import importlib +import importlib.util +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from xarray.core.dataarray import DataArray + from xarray.core.dataset import Dataset + from xarray.core.datatree import DataTree + +# Registry of known external accessors +# Format: name -> (module_path, class_name, install_name, top_level_package) +DATAARRAY_ACCESSORS: dict[str, tuple[str, str, str, str]] = { + "hvplot": ("hvplot.xarray", "hvPlotAccessor", "hvplot", "hvplot"), + "cf": ("cf_xarray.accessor", "CFAccessor", "cf-xarray", "cf_xarray"), + "pint": ("pint_xarray", "PintDataArrayAccessor", "pint-xarray", "pint_xarray"), + "rio": ("rioxarray", "RasterArray", "rioxarray", "rioxarray"), + "plotly": ( + "xarray_plotly", + "DataArrayPlotlyAccessor", + "xarray-plotly", + "xarray_plotly", + ), +} + +DATASET_ACCESSORS: dict[str, tuple[str, str, str, str]] = { + "hvplot": ("hvplot.xarray", "hvPlotAccessor", "hvplot", "hvplot"), + "cf": ("cf_xarray.accessor", "CFAccessor", "cf-xarray", "cf_xarray"), + "pint": ("pint_xarray", "PintDatasetAccessor", "pint-xarray", "pint_xarray"), + "rio": ("rioxarray", "RasterDataset", "rioxarray", "rioxarray"), + "plotly": ( + "xarray_plotly", + "DatasetPlotlyAccessor", + "xarray-plotly", + "xarray_plotly", + ), +} + +DATATREE_ACCESSORS: dict[str, tuple[str, str, str, str]] = { + "hvplot": ("hvplot.xarray", "hvPlotAccessor", "hvplot", "hvplot"), + "cf": ("cf_xarray.accessor", "CFAccessor", "cf-xarray", "cf_xarray"), +} + +# Cache for package availability checks +_package_available_cache: dict[str, bool] = {} + + +def _is_package_available(package_name: str) -> bool: + """Check if a package is available without importing it.""" + if package_name not in _package_available_cache: + _package_available_cache[package_name] = ( + importlib.util.find_spec(package_name) is not None + ) + return _package_available_cache[package_name] + + +def _get_external_accessor( + name: str, + obj: DataArray | Dataset | DataTree, + accessor_registry: dict[str, tuple[str, str, str, str]], +) -> Any: + """ + Get an external accessor instance, using the object's cache if available. + + Parameters + ---------- + name : str + Name of the accessor + obj : DataArray | Dataset | DataTree + The xarray object + accessor_registry : dict + The accessor registry to use + + Returns + ------- + Any + An instance of the accessor class + + Raises + ------ + AttributeError + If the accessor package is not installed (makes hasattr return False) + """ + package, cls_name, install_name, top_pkg = accessor_registry[name] + + # Check if package is available - raise AttributeError if not + # This makes hasattr() return False for uninstalled packages + if not _is_package_available(top_pkg): + raise AttributeError( + f"'{type(obj).__name__}' object has no attribute '{name}'. " + f"Install with: pip install {install_name}" + ) + + # Check cache + try: + cache = obj._cache + except AttributeError: + cache = obj._cache = {} + + cache_key = f"_external_{name}" + if cache_key in cache: + return cache[cache_key] + + # Import and instantiate the accessor + try: + module = importlib.import_module(package) + except ImportError as err: + raise AttributeError( + f"'{type(obj).__name__}' object has no attribute '{name}'. " + f"Install with: pip install {install_name}" + ) from err + + try: + accessor_cls = getattr(module, cls_name) + accessor = accessor_cls(obj) + except AttributeError as err: + # __getattr__ on data object will swallow any AttributeErrors + # raised when initializing the accessor, so we need to raise as + # something else (same pattern as _CachedAccessor in extensions.py) + raise RuntimeError(f"Error initializing {name!r} accessor.") from err + + cache[cache_key] = accessor + return accessor diff --git a/xarray/tests/test_extensions.py b/xarray/tests/test_extensions.py index 8a52f79198d..8c614b82b0b 100644 --- a/xarray/tests/test_extensions.py +++ b/xarray/tests/test_extensions.py @@ -5,6 +5,12 @@ import pytest import xarray as xr +from xarray.accessors import ( + DATAARRAY_ACCESSORS, + DATASET_ACCESSORS, + DATATREE_ACCESSORS, + _is_package_available, +) from xarray.core.extensions import register_datatree_accessor from xarray.tests import assert_identical @@ -93,3 +99,74 @@ def __init__(self, xarray_obj): with pytest.raises(RuntimeError, match=r"error initializing"): _ = xr.Dataset().stupid_accessor + + +class TestExternalAccessors: + """Tests for typed external accessor properties.""" + + def test_hasattr_false_for_uninstalled(self) -> None: + """hasattr returns False for accessors whose packages are not installed.""" + da = xr.DataArray([1, 2, 3]) + ds = xr.Dataset({"a": da}) + dt = xr.DataTree(ds) + + for name, (_, _, _, top_pkg) in DATAARRAY_ACCESSORS.items(): + if not _is_package_available(top_pkg): + assert not hasattr(da, name), f"hasattr should be False for {name}" + + for name, (_, _, _, top_pkg) in DATASET_ACCESSORS.items(): + if not _is_package_available(top_pkg): + assert not hasattr(ds, name), f"hasattr should be False for {name}" + + for name, (_, _, _, top_pkg) in DATATREE_ACCESSORS.items(): + if not _is_package_available(top_pkg): + assert not hasattr(dt, name), f"hasattr should be False for {name}" + + def test_hasattr_true_for_installed(self) -> None: + """hasattr returns True for accessors whose packages are installed.""" + da = xr.DataArray([1, 2, 3]) + ds = xr.Dataset({"a": da}) + + for name, (_, _, _, top_pkg) in DATAARRAY_ACCESSORS.items(): + if _is_package_available(top_pkg): + assert hasattr(da, name), f"hasattr should be True for {name}" + + for name, (_, _, _, top_pkg) in DATASET_ACCESSORS.items(): + if _is_package_available(top_pkg): + assert hasattr(ds, name), f"hasattr should be True for {name}" + + def test_attribute_error_for_uninstalled(self) -> None: + """Accessing uninstalled accessor raises AttributeError.""" + da = xr.DataArray([1, 2, 3]) + + for name, (_, _, _, top_pkg) in DATAARRAY_ACCESSORS.items(): + if not _is_package_available(top_pkg): + with pytest.raises(AttributeError): + getattr(da, name) + break # Only need to test one + + def test_external_accessor_no_overwrite(self) -> None: + """Known external accessors don't overwrite typed properties.""" + # The property should remain a property, not get replaced by _CachedAccessor + for name in DATAARRAY_ACCESSORS: + attr = getattr(xr.DataArray, name) + assert isinstance(attr, property), f"{name} should remain a property" + + for name in DATASET_ACCESSORS: + attr = getattr(xr.Dataset, name) + assert isinstance(attr, property), f"{name} should remain a property" + + for name in DATATREE_ACCESSORS: + attr = getattr(xr.DataTree, name) + assert isinstance(attr, property), f"{name} should remain a property" + + def test_accessor_caching(self) -> None: + """Accessor instances are cached on the object.""" + da = xr.DataArray([1, 2, 3]) + + for name, (_, _, _, top_pkg) in DATAARRAY_ACCESSORS.items(): + if _is_package_available(top_pkg): + accessor1 = getattr(da, name) + accessor2 = getattr(da, name) + assert accessor1 is accessor2, f"{name} accessor should be cached" + break # Only need to test one installed accessor From 477f1befe6debb06d659a85a9244fe4900a1dbf4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:48:44 +0100 Subject: [PATCH 05/11] Move accessors to other module --- xarray/accessors.py | 200 ++++++++++++++++++++++++++++++++++++++- xarray/core/dataarray.py | 87 +---------------- xarray/core/dataset.py | 87 +---------------- xarray/core/datatree.py | 40 +------- 4 files changed, 204 insertions(+), 210 deletions(-) diff --git a/xarray/accessors.py b/xarray/accessors.py index a06bb2f620b..1c69d70e06a 100644 --- a/xarray/accessors.py +++ b/xarray/accessors.py @@ -1,8 +1,8 @@ """ External accessor support for xarray. -This module provides infrastructure for external accessor packages, -enabling full IDE support (autocompletion, parameter hints, docstrings) +This module provides mixin classes with typed properties for external accessor +packages, enabling full IDE support (autocompletion, parameter hints, docstrings) for packages like hvplot, cf-xarray, pint-xarray, rioxarray, and xarray-plotly. Properties are defined statically for IDE support, but raise AttributeError @@ -16,6 +16,13 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + # External accessor types (for IDE support) + from cf_xarray.accessor import CFAccessor + from hvplot.xarray import hvPlotAccessor + from pint_xarray import PintDataArrayAccessor, PintDatasetAccessor + from rioxarray import RasterArray, RasterDataset + from xarray_plotly import DataArrayPlotlyAccessor, DatasetPlotlyAccessor + from xarray.core.dataarray import DataArray from xarray.core.dataset import Dataset from xarray.core.datatree import DataTree @@ -133,3 +140,192 @@ def _get_external_accessor( cache[cache_key] = accessor return accessor + + +class DataArrayExternalAccessorMixin: + """ + Mixin class providing typed external accessor properties for DataArray. + + These properties enable IDE support (autocompletion, parameter hints, docstrings) + for external accessor packages. For uninstalled packages, hasattr() returns False. + """ + + __slots__ = () + + @property + def hvplot(self) -> hvPlotAccessor: + """ + hvPlot accessor for interactive plotting. + + Requires: ``pip install hvplot`` + + See Also + -------- + hvplot : https://hvplot.holoviz.org/ + """ + return _get_external_accessor("hvplot", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] + + @property + def cf(self) -> CFAccessor: + """ + CF conventions accessor. + + Requires: ``pip install cf-xarray`` + + See Also + -------- + cf_xarray : https://cf-xarray.readthedocs.io/ + """ + return _get_external_accessor("cf", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] + + @property + def pint(self) -> PintDataArrayAccessor: + """ + Pint unit accessor for unit-aware arrays. + + Requires: ``pip install pint-xarray`` + + See Also + -------- + pint_xarray : https://pint-xarray.readthedocs.io/ + """ + return _get_external_accessor("pint", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] + + @property + def rio(self) -> RasterArray: + """ + Rasterio accessor for geospatial raster data. + + Requires: ``pip install rioxarray`` + + See Also + -------- + rioxarray : https://corteva.github.io/rioxarray/ + """ + return _get_external_accessor("rio", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] + + @property + def plotly(self) -> DataArrayPlotlyAccessor: + """ + Plotly accessor for interactive Plotly visualizations. + + Requires: ``pip install xarray-plotly`` + + See Also + -------- + xarray_plotly : https://github.com/xarray-contrib/xarray-plotly + """ + return _get_external_accessor("plotly", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] + + +class DatasetExternalAccessorMixin: + """ + Mixin class providing typed external accessor properties for Dataset. + + These properties enable IDE support (autocompletion, parameter hints, docstrings) + for external accessor packages. For uninstalled packages, hasattr() returns False. + """ + + __slots__ = () + + @property + def hvplot(self) -> hvPlotAccessor: + """ + hvPlot accessor for interactive plotting. + + Requires: ``pip install hvplot`` + + See Also + -------- + hvplot : https://hvplot.holoviz.org/ + """ + return _get_external_accessor("hvplot", self, DATASET_ACCESSORS) # type: ignore[arg-type] + + @property + def cf(self) -> CFAccessor: + """ + CF conventions accessor. + + Requires: ``pip install cf-xarray`` + + See Also + -------- + cf_xarray : https://cf-xarray.readthedocs.io/ + """ + return _get_external_accessor("cf", self, DATASET_ACCESSORS) # type: ignore[arg-type] + + @property + def pint(self) -> PintDatasetAccessor: + """ + Pint unit accessor for unit-aware arrays. + + Requires: ``pip install pint-xarray`` + + See Also + -------- + pint_xarray : https://pint-xarray.readthedocs.io/ + """ + return _get_external_accessor("pint", self, DATASET_ACCESSORS) # type: ignore[arg-type] + + @property + def rio(self) -> RasterDataset: + """ + Rasterio accessor for geospatial raster data. + + Requires: ``pip install rioxarray`` + + See Also + -------- + rioxarray : https://corteva.github.io/rioxarray/ + """ + return _get_external_accessor("rio", self, DATASET_ACCESSORS) # type: ignore[arg-type] + + @property + def plotly(self) -> DatasetPlotlyAccessor: + """ + Plotly accessor for interactive Plotly visualizations. + + Requires: ``pip install xarray-plotly`` + + See Also + -------- + xarray_plotly : https://github.com/xarray-contrib/xarray-plotly + """ + return _get_external_accessor("plotly", self, DATASET_ACCESSORS) # type: ignore[arg-type] + + +class DataTreeExternalAccessorMixin: + """ + Mixin class providing typed external accessor properties for DataTree. + + These properties enable IDE support (autocompletion, parameter hints, docstrings) + for external accessor packages. For uninstalled packages, hasattr() returns False. + """ + + __slots__ = () + + @property + def hvplot(self) -> hvPlotAccessor: + """ + hvPlot accessor for interactive plotting. + + Requires: ``pip install hvplot`` + + See Also + -------- + hvplot : https://hvplot.holoviz.org/ + """ + return _get_external_accessor("hvplot", self, DATATREE_ACCESSORS) # type: ignore[arg-type] + + @property + def cf(self) -> CFAccessor: + """ + CF conventions accessor. + + Requires: ``pip install cf-xarray`` + + See Also + -------- + cf_xarray : https://cf-xarray.readthedocs.io/ + """ + return _get_external_accessor("cf", self, DATATREE_ACCESSORS) # type: ignore[arg-type] diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py index 7219d10860c..f74e6e9108c 100644 --- a/xarray/core/dataarray.py +++ b/xarray/core/dataarray.py @@ -19,6 +19,7 @@ import numpy as np import pandas as pd +from xarray.accessors import DataArrayExternalAccessorMixin from xarray.coding.calendar_ops import convert_calendar, interp_calendar from xarray.coding.cftimeindex import CFTimeIndex from xarray.computation import computation, ops @@ -85,16 +86,10 @@ from xarray.util.deprecation_helpers import _deprecate_positional_args, deprecate_dims if TYPE_CHECKING: - # External accessor types (for IDE support) - from cf_xarray.accessor import CFAccessor from dask.dataframe import DataFrame as DaskDataFrame from dask.delayed import Delayed - from hvplot.xarray import hvPlotAccessor from iris.cube import Cube as iris_Cube from numpy.typing import ArrayLike - from pint_xarray import PintDataArrayAccessor - from rioxarray import RasterArray - from xarray_plotly import DataArrayPlotlyAccessor from xarray.backends import ZarrStore from xarray.backends.api import T_NetcdfEngine, T_NetcdfTypes @@ -265,6 +260,7 @@ class DataArray( DataWithCoords, DataArrayArithmetic, DataArrayAggregations, + DataArrayExternalAccessorMixin, ): """N-dimensional array with labeled coordinates and dimensions. @@ -418,85 +414,6 @@ class DataArray( dt = utils.UncachedAccessor(CombinedDatetimelikeAccessor["DataArray"]) - # External accessor properties (for IDE support) - # These provide full autocompletion when packages are installed. - # Raises AttributeError for uninstalled packages (so hasattr returns False). - - @property - def hvplot(self) -> hvPlotAccessor: - """ - hvPlot accessor for interactive plotting. - - Requires: ``pip install hvplot`` - - See Also - -------- - hvplot : https://hvplot.holoviz.org/ - """ - from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - - return _get_external_accessor("hvplot", self, DATAARRAY_ACCESSORS) - - @property - def cf(self) -> CFAccessor: - """ - CF conventions accessor. - - Requires: ``pip install cf-xarray`` - - See Also - -------- - cf_xarray : https://cf-xarray.readthedocs.io/ - """ - from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - - return _get_external_accessor("cf", self, DATAARRAY_ACCESSORS) - - @property - def pint(self) -> PintDataArrayAccessor: - """ - Pint unit accessor for unit-aware arrays. - - Requires: ``pip install pint-xarray`` - - See Also - -------- - pint_xarray : https://pint-xarray.readthedocs.io/ - """ - from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - - return _get_external_accessor("pint", self, DATAARRAY_ACCESSORS) - - @property - def rio(self) -> RasterArray: - """ - Rasterio accessor for geospatial raster data. - - Requires: ``pip install rioxarray`` - - See Also - -------- - rioxarray : https://corteva.github.io/rioxarray/ - """ - from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - - return _get_external_accessor("rio", self, DATAARRAY_ACCESSORS) - - @property - def plotly(self) -> DataArrayPlotlyAccessor: - """ - Plotly accessor for interactive Plotly visualizations. - - Requires: ``pip install xarray-plotly`` - - See Also - -------- - xarray_plotly : https://github.com/xarray-contrib/xarray-plotly - """ - from xarray.accessors import DATAARRAY_ACCESSORS, _get_external_accessor - - return _get_external_accessor("plotly", self, DATAARRAY_ACCESSORS) - def __init__( self, data: Any = dtypes.NA, diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py index 55c637fe180..395b14af2fc 100644 --- a/xarray/core/dataset.py +++ b/xarray/core/dataset.py @@ -29,6 +29,7 @@ import numpy as np import pandas as pd +from xarray.accessors import DatasetExternalAccessorMixin from xarray.coding.calendar_ops import convert_calendar, interp_calendar from xarray.coding.cftimeindex import CFTimeIndex, _parse_array_of_cftime_strings from xarray.compat.array_api_compat import to_like_array @@ -129,15 +130,9 @@ ) if TYPE_CHECKING: - # External accessor types (for IDE support) - from cf_xarray.accessor import CFAccessor from dask.dataframe import DataFrame as DaskDataFrame from dask.delayed import Delayed - from hvplot.xarray import hvPlotAccessor from numpy.typing import ArrayLike - from pint_xarray import PintDatasetAccessor - from rioxarray import RasterDataset - from xarray_plotly import DatasetPlotlyAccessor from xarray.backends import AbstractDataStore, ZarrStore from xarray.backends.api import T_NetcdfEngine, T_NetcdfTypes @@ -203,6 +198,7 @@ class Dataset( DataWithCoords, DatasetAggregations, DatasetArithmetic, + DatasetExternalAccessorMixin, Mapping[Hashable, "DataArray"], ): """A multi-dimensional, in memory, array database. @@ -375,85 +371,6 @@ class Dataset( "_variables", ) - # External accessor properties (for IDE support) - # These provide full autocompletion when packages are installed. - # Raises AttributeError for uninstalled packages (so hasattr returns False). - - @property - def hvplot(self) -> hvPlotAccessor: - """ - hvPlot accessor for interactive plotting. - - Requires: ``pip install hvplot`` - - See Also - -------- - hvplot : https://hvplot.holoviz.org/ - """ - from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - - return _get_external_accessor("hvplot", self, DATASET_ACCESSORS) - - @property - def cf(self) -> CFAccessor: - """ - CF conventions accessor. - - Requires: ``pip install cf-xarray`` - - See Also - -------- - cf_xarray : https://cf-xarray.readthedocs.io/ - """ - from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - - return _get_external_accessor("cf", self, DATASET_ACCESSORS) - - @property - def pint(self) -> PintDatasetAccessor: - """ - Pint unit accessor for unit-aware arrays. - - Requires: ``pip install pint-xarray`` - - See Also - -------- - pint_xarray : https://pint-xarray.readthedocs.io/ - """ - from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - - return _get_external_accessor("pint", self, DATASET_ACCESSORS) - - @property - def rio(self) -> RasterDataset: - """ - Rasterio accessor for geospatial raster data. - - Requires: ``pip install rioxarray`` - - See Also - -------- - rioxarray : https://corteva.github.io/rioxarray/ - """ - from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - - return _get_external_accessor("rio", self, DATASET_ACCESSORS) - - @property - def plotly(self) -> DatasetPlotlyAccessor: - """ - Plotly accessor for interactive Plotly visualizations. - - Requires: ``pip install xarray-plotly`` - - See Also - -------- - xarray_plotly : https://github.com/xarray-contrib/xarray-plotly - """ - from xarray.accessors import DATASET_ACCESSORS, _get_external_accessor - - return _get_external_accessor("plotly", self, DATASET_ACCESSORS) - def __init__( self, # could make a VariableArgs to use more generally, and refine these diff --git a/xarray/core/datatree.py b/xarray/core/datatree.py index a4fdd3e0ae6..d46fd236feb 100644 --- a/xarray/core/datatree.py +++ b/xarray/core/datatree.py @@ -28,6 +28,7 @@ overload, ) +from xarray.accessors import DataTreeExternalAccessorMixin from xarray.core import utils from xarray.core._aggregations import DataTreeAggregations from xarray.core._typed_ops import DataTreeOpsMixin @@ -78,11 +79,7 @@ if TYPE_CHECKING: import numpy as np import pandas as pd - - # External accessor types (for IDE support) - from cf_xarray.accessor import CFAccessor from dask.delayed import Delayed - from hvplot.xarray import hvPlotAccessor from xarray.backends import ZarrStore from xarray.backends.writers import T_DataTreeNetcdfEngine, T_DataTreeNetcdfTypes @@ -468,6 +465,7 @@ class DataTree( DataTreeAggregations, DataTreeOpsMixin, TreeAttrAccessMixin, + DataTreeExternalAccessorMixin, Mapping[str, "DataArray | DataTree"], ): """ @@ -518,40 +516,6 @@ class DataTree( "_parent", ) - # External accessor properties (for IDE support) - # These provide full autocompletion when packages are installed. - # Raises AttributeError for uninstalled packages (so hasattr returns False). - - @property - def hvplot(self) -> hvPlotAccessor: - """ - hvPlot accessor for interactive plotting. - - Requires: ``pip install hvplot`` - - See Also - -------- - hvplot : https://hvplot.holoviz.org/ - """ - from xarray.accessors import DATATREE_ACCESSORS, _get_external_accessor - - return _get_external_accessor("hvplot", self, DATATREE_ACCESSORS) - - @property - def cf(self) -> CFAccessor: - """ - CF conventions accessor. - - Requires: ``pip install cf-xarray`` - - See Also - -------- - cf_xarray : https://cf-xarray.readthedocs.io/ - """ - from xarray.accessors import DATATREE_ACCESSORS, _get_external_accessor - - return _get_external_accessor("cf", self, DATATREE_ACCESSORS) - def __init__( self, dataset: Dataset | Coordinates | None = None, From e836ec6159ffef0904ac775b28c732f81e71f160 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:14:01 +0100 Subject: [PATCH 06/11] Shorten docstrings --- xarray/accessors.py | 170 +++++--------------------------------------- 1 file changed, 16 insertions(+), 154 deletions(-) diff --git a/xarray/accessors.py b/xarray/accessors.py index 1c69d70e06a..515c3152474 100644 --- a/xarray/accessors.py +++ b/xarray/accessors.py @@ -16,7 +16,6 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - # External accessor types (for IDE support) from cf_xarray.accessor import CFAccessor from hvplot.xarray import hvPlotAccessor from pint_xarray import PintDataArrayAccessor, PintDatasetAccessor @@ -78,32 +77,9 @@ def _get_external_accessor( obj: DataArray | Dataset | DataTree, accessor_registry: dict[str, tuple[str, str, str, str]], ) -> Any: - """ - Get an external accessor instance, using the object's cache if available. - - Parameters - ---------- - name : str - Name of the accessor - obj : DataArray | Dataset | DataTree - The xarray object - accessor_registry : dict - The accessor registry to use - - Returns - ------- - Any - An instance of the accessor class - - Raises - ------ - AttributeError - If the accessor package is not installed (makes hasattr return False) - """ + """Get an external accessor instance, raising AttributeError if not installed.""" package, cls_name, install_name, top_pkg = accessor_registry[name] - # Check if package is available - raise AttributeError if not - # This makes hasattr() return False for uninstalled packages if not _is_package_available(top_pkg): raise AttributeError( f"'{type(obj).__name__}' object has no attribute '{name}'. " @@ -133,9 +109,6 @@ def _get_external_accessor( accessor_cls = getattr(module, cls_name) accessor = accessor_cls(obj) except AttributeError as err: - # __getattr__ on data object will swallow any AttributeErrors - # raised when initializing the accessor, so we need to raise as - # something else (same pattern as _CachedAccessor in extensions.py) raise RuntimeError(f"Error initializing {name!r} accessor.") from err cache[cache_key] = accessor @@ -143,189 +116,78 @@ def _get_external_accessor( class DataArrayExternalAccessorMixin: - """ - Mixin class providing typed external accessor properties for DataArray. - - These properties enable IDE support (autocompletion, parameter hints, docstrings) - for external accessor packages. For uninstalled packages, hasattr() returns False. - """ + """Mixin providing typed external accessor properties for DataArray.""" __slots__ = () @property def hvplot(self) -> hvPlotAccessor: - """ - hvPlot accessor for interactive plotting. - - Requires: ``pip install hvplot`` - - See Also - -------- - hvplot : https://hvplot.holoviz.org/ - """ + """hvPlot accessor for interactive plotting. Requires: ``pip install hvplot``""" return _get_external_accessor("hvplot", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] @property def cf(self) -> CFAccessor: - """ - CF conventions accessor. - - Requires: ``pip install cf-xarray`` - - See Also - -------- - cf_xarray : https://cf-xarray.readthedocs.io/ - """ + """CF conventions accessor. Requires: ``pip install cf-xarray``""" return _get_external_accessor("cf", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] @property def pint(self) -> PintDataArrayAccessor: - """ - Pint unit accessor for unit-aware arrays. - - Requires: ``pip install pint-xarray`` - - See Also - -------- - pint_xarray : https://pint-xarray.readthedocs.io/ - """ + """Pint unit accessor. Requires: ``pip install pint-xarray``""" return _get_external_accessor("pint", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] @property def rio(self) -> RasterArray: - """ - Rasterio accessor for geospatial raster data. - - Requires: ``pip install rioxarray`` - - See Also - -------- - rioxarray : https://corteva.github.io/rioxarray/ - """ + """Rasterio accessor for geospatial data. Requires: ``pip install rioxarray``""" return _get_external_accessor("rio", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] @property def plotly(self) -> DataArrayPlotlyAccessor: - """ - Plotly accessor for interactive Plotly visualizations. - - Requires: ``pip install xarray-plotly`` - - See Also - -------- - xarray_plotly : https://github.com/xarray-contrib/xarray-plotly - """ + """Plotly accessor. Requires: ``pip install xarray-plotly``""" return _get_external_accessor("plotly", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] class DatasetExternalAccessorMixin: - """ - Mixin class providing typed external accessor properties for Dataset. - - These properties enable IDE support (autocompletion, parameter hints, docstrings) - for external accessor packages. For uninstalled packages, hasattr() returns False. - """ + """Mixin providing typed external accessor properties for Dataset.""" __slots__ = () @property def hvplot(self) -> hvPlotAccessor: - """ - hvPlot accessor for interactive plotting. - - Requires: ``pip install hvplot`` - - See Also - -------- - hvplot : https://hvplot.holoviz.org/ - """ + """hvPlot accessor for interactive plotting. Requires: ``pip install hvplot``""" return _get_external_accessor("hvplot", self, DATASET_ACCESSORS) # type: ignore[arg-type] @property def cf(self) -> CFAccessor: - """ - CF conventions accessor. - - Requires: ``pip install cf-xarray`` - - See Also - -------- - cf_xarray : https://cf-xarray.readthedocs.io/ - """ + """CF conventions accessor. Requires: ``pip install cf-xarray``""" return _get_external_accessor("cf", self, DATASET_ACCESSORS) # type: ignore[arg-type] @property def pint(self) -> PintDatasetAccessor: - """ - Pint unit accessor for unit-aware arrays. - - Requires: ``pip install pint-xarray`` - - See Also - -------- - pint_xarray : https://pint-xarray.readthedocs.io/ - """ + """Pint unit accessor. Requires: ``pip install pint-xarray``""" return _get_external_accessor("pint", self, DATASET_ACCESSORS) # type: ignore[arg-type] @property def rio(self) -> RasterDataset: - """ - Rasterio accessor for geospatial raster data. - - Requires: ``pip install rioxarray`` - - See Also - -------- - rioxarray : https://corteva.github.io/rioxarray/ - """ + """Rasterio accessor for geospatial data. Requires: ``pip install rioxarray``""" return _get_external_accessor("rio", self, DATASET_ACCESSORS) # type: ignore[arg-type] @property def plotly(self) -> DatasetPlotlyAccessor: - """ - Plotly accessor for interactive Plotly visualizations. - - Requires: ``pip install xarray-plotly`` - - See Also - -------- - xarray_plotly : https://github.com/xarray-contrib/xarray-plotly - """ + """Plotly accessor. Requires: ``pip install xarray-plotly``""" return _get_external_accessor("plotly", self, DATASET_ACCESSORS) # type: ignore[arg-type] class DataTreeExternalAccessorMixin: - """ - Mixin class providing typed external accessor properties for DataTree. - - These properties enable IDE support (autocompletion, parameter hints, docstrings) - for external accessor packages. For uninstalled packages, hasattr() returns False. - """ + """Mixin providing typed external accessor properties for DataTree.""" __slots__ = () @property def hvplot(self) -> hvPlotAccessor: - """ - hvPlot accessor for interactive plotting. - - Requires: ``pip install hvplot`` - - See Also - -------- - hvplot : https://hvplot.holoviz.org/ - """ + """hvPlot accessor for interactive plotting. Requires: ``pip install hvplot``""" return _get_external_accessor("hvplot", self, DATATREE_ACCESSORS) # type: ignore[arg-type] @property def cf(self) -> CFAccessor: - """ - CF conventions accessor. - - Requires: ``pip install cf-xarray`` - - See Also - -------- - cf_xarray : https://cf-xarray.readthedocs.io/ - """ + """CF conventions accessor. Requires: ``pip install cf-xarray``""" return _get_external_accessor("cf", self, DATATREE_ACCESSORS) # type: ignore[arg-type] From 4c7397475fd76b592f59403030706503a3bf8de2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:42:10 +0100 Subject: [PATCH 07/11] fix(ci): include pyproject.toml in pixi lock cache key The pixi workspace includes pyproject.toml as source metadata for the local xarray package (via `xarray = { path = "." }`). When pyproject.toml changes, the cached pixi.lock becomes invalid, causing CI failures with "lock-file not up-to-date with the workspace". This fix adds pyproject.toml to the cache key hash, ensuring the lock file is regenerated when either pixi.toml or pyproject.toml changes. --- .github/workflows/cache-pixi-lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cache-pixi-lock.yml b/.github/workflows/cache-pixi-lock.yml index e938a7664dd..da67294d62e 100644 --- a/.github/workflows/cache-pixi-lock.yml +++ b/.github/workflows/cache-pixi-lock.yml @@ -29,7 +29,7 @@ jobs: with: path: | pixi.lock - key: ${{ steps.date.outputs.date }}_${{ inputs.pixi-version }}_${{hashFiles('pixi.toml')}} + key: ${{ steps.date.outputs.date }}_${{ inputs.pixi-version }}_${{hashFiles('pixi.toml', 'pyproject.toml')}} - uses: prefix-dev/setup-pixi@v0.9.3 if: ${{ !steps.restore.outputs.cache-hit }} with: From 40a2f2c18541fa8612f5f19e9f211e434bab2e2e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:58:27 +0100 Subject: [PATCH 08/11] Fix whats-new.rst --- doc/whats-new.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 57f668c006c..95f6318bf25 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -30,8 +30,8 @@ New Features - Added typed properties for external accessor packages (hvplot, cf-xarray, pint-xarray, rioxarray, xarray-plotly), enabling full IDE support including autocompletion, parameter hints, and docstrings. For uninstalled packages, - ``hasattr()`` returns ``False`` to keep the namespace clean (:pull:`xxxx`). - By `Your Name `_. + ``hasattr()`` returns ``False`` to keep the namespace clean (:pull:`11079`). + By `Your Name `_. - :py:func:`set_options` now supports an ``arithmetic_compat`` option which determines how non-index coordinates of the same name are compared for potential conflicts when performing binary operations. The default for it is From ce31134aeed47be44e28f8e04b545f4e542989fd Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Wed, 14 Jan 2026 16:57:33 -0500 Subject: [PATCH 09/11] Only put the types --- .github/workflows/cache-pixi-lock.yml | 2 +- pyproject.toml | 5 - xarray/accessors.py | 168 +++----------------------- xarray/core/extensions.py | 19 +-- xarray/tests/test_extensions.py | 77 ------------ 5 files changed, 16 insertions(+), 255 deletions(-) diff --git a/.github/workflows/cache-pixi-lock.yml b/.github/workflows/cache-pixi-lock.yml index da67294d62e..e938a7664dd 100644 --- a/.github/workflows/cache-pixi-lock.yml +++ b/.github/workflows/cache-pixi-lock.yml @@ -29,7 +29,7 @@ jobs: with: path: | pixi.lock - key: ${{ steps.date.outputs.date }}_${{ inputs.pixi-version }}_${{hashFiles('pixi.toml', 'pyproject.toml')}} + key: ${{ steps.date.outputs.date }}_${{ inputs.pixi-version }}_${{hashFiles('pixi.toml')}} - uses: prefix-dev/setup-pixi@v0.9.3 if: ${{ !steps.restore.outputs.cache-hit }} with: diff --git a/pyproject.toml b/pyproject.toml index 917f202da1b..c8fd153dd52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,7 +138,6 @@ module = [ "bottleneck.*", "cartopy.*", "cf_units.*", - "cf_xarray.*", "cfgrib.*", "cftime.*", "cloudpickle.*", @@ -147,7 +146,6 @@ module = [ "fsspec.*", "h5netcdf.*", "h5py.*", - "hvplot.*", "iris.*", "mpl_toolkits.*", "nc_time_axis.*", @@ -156,17 +154,14 @@ module = [ "numcodecs.*", "opt_einsum.*", "pint.*", - "pint_xarray.*", "pooch.*", "pyarrow.*", "pydap.*", - "rioxarray.*", "scipy.*", "seaborn.*", "setuptools", "sparse.*", "toolz.*", - "xarray_plotly.*", "zarr.*", "numpy.exceptions.*", # remove once support for `numpy<2.0` has been dropped "array_api_strict.*", diff --git a/xarray/accessors.py b/xarray/accessors.py index 515c3152474..d87628b6b94 100644 --- a/xarray/accessors.py +++ b/xarray/accessors.py @@ -5,15 +5,12 @@ packages, enabling full IDE support (autocompletion, parameter hints, docstrings) for packages like hvplot, cf-xarray, pint-xarray, rioxarray, and xarray-plotly. -Properties are defined statically for IDE support, but raise AttributeError -for uninstalled packages (making hasattr() return False). +Properties are defined statically for IDE support """ from __future__ import annotations -import importlib -import importlib.util -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, ClassVar if TYPE_CHECKING: from cf_xarray.accessor import CFAccessor @@ -22,128 +19,17 @@ from rioxarray import RasterArray, RasterDataset from xarray_plotly import DataArrayPlotlyAccessor, DatasetPlotlyAccessor - from xarray.core.dataarray import DataArray - from xarray.core.dataset import Dataset - from xarray.core.datatree import DataTree - -# Registry of known external accessors -# Format: name -> (module_path, class_name, install_name, top_level_package) -DATAARRAY_ACCESSORS: dict[str, tuple[str, str, str, str]] = { - "hvplot": ("hvplot.xarray", "hvPlotAccessor", "hvplot", "hvplot"), - "cf": ("cf_xarray.accessor", "CFAccessor", "cf-xarray", "cf_xarray"), - "pint": ("pint_xarray", "PintDataArrayAccessor", "pint-xarray", "pint_xarray"), - "rio": ("rioxarray", "RasterArray", "rioxarray", "rioxarray"), - "plotly": ( - "xarray_plotly", - "DataArrayPlotlyAccessor", - "xarray-plotly", - "xarray_plotly", - ), -} - -DATASET_ACCESSORS: dict[str, tuple[str, str, str, str]] = { - "hvplot": ("hvplot.xarray", "hvPlotAccessor", "hvplot", "hvplot"), - "cf": ("cf_xarray.accessor", "CFAccessor", "cf-xarray", "cf_xarray"), - "pint": ("pint_xarray", "PintDatasetAccessor", "pint-xarray", "pint_xarray"), - "rio": ("rioxarray", "RasterDataset", "rioxarray", "rioxarray"), - "plotly": ( - "xarray_plotly", - "DatasetPlotlyAccessor", - "xarray-plotly", - "xarray_plotly", - ), -} - -DATATREE_ACCESSORS: dict[str, tuple[str, str, str, str]] = { - "hvplot": ("hvplot.xarray", "hvPlotAccessor", "hvplot", "hvplot"), - "cf": ("cf_xarray.accessor", "CFAccessor", "cf-xarray", "cf_xarray"), -} - -# Cache for package availability checks -_package_available_cache: dict[str, bool] = {} - - -def _is_package_available(package_name: str) -> bool: - """Check if a package is available without importing it.""" - if package_name not in _package_available_cache: - _package_available_cache[package_name] = ( - importlib.util.find_spec(package_name) is not None - ) - return _package_available_cache[package_name] - - -def _get_external_accessor( - name: str, - obj: DataArray | Dataset | DataTree, - accessor_registry: dict[str, tuple[str, str, str, str]], -) -> Any: - """Get an external accessor instance, raising AttributeError if not installed.""" - package, cls_name, install_name, top_pkg = accessor_registry[name] - - if not _is_package_available(top_pkg): - raise AttributeError( - f"'{type(obj).__name__}' object has no attribute '{name}'. " - f"Install with: pip install {install_name}" - ) - - # Check cache - try: - cache = obj._cache - except AttributeError: - cache = obj._cache = {} - - cache_key = f"_external_{name}" - if cache_key in cache: - return cache[cache_key] - - # Import and instantiate the accessor - try: - module = importlib.import_module(package) - except ImportError as err: - raise AttributeError( - f"'{type(obj).__name__}' object has no attribute '{name}'. " - f"Install with: pip install {install_name}" - ) from err - - try: - accessor_cls = getattr(module, cls_name) - accessor = accessor_cls(obj) - except AttributeError as err: - raise RuntimeError(f"Error initializing {name!r} accessor.") from err - - cache[cache_key] = accessor - return accessor - class DataArrayExternalAccessorMixin: """Mixin providing typed external accessor properties for DataArray.""" __slots__ = () - @property - def hvplot(self) -> hvPlotAccessor: - """hvPlot accessor for interactive plotting. Requires: ``pip install hvplot``""" - return _get_external_accessor("hvplot", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] - - @property - def cf(self) -> CFAccessor: - """CF conventions accessor. Requires: ``pip install cf-xarray``""" - return _get_external_accessor("cf", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] - - @property - def pint(self) -> PintDataArrayAccessor: - """Pint unit accessor. Requires: ``pip install pint-xarray``""" - return _get_external_accessor("pint", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] - - @property - def rio(self) -> RasterArray: - """Rasterio accessor for geospatial data. Requires: ``pip install rioxarray``""" - return _get_external_accessor("rio", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] - - @property - def plotly(self) -> DataArrayPlotlyAccessor: - """Plotly accessor. Requires: ``pip install xarray-plotly``""" - return _get_external_accessor("plotly", self, DATAARRAY_ACCESSORS) # type: ignore[arg-type] + hvplot: ClassVar[type[hvPlotAccessor]] + cf: ClassVar[type[CFAccessor]] + pint: ClassVar[type[PintDataArrayAccessor]] + rio: ClassVar[type[RasterArray]] + plotly: ClassVar[type[DataArrayPlotlyAccessor]] class DatasetExternalAccessorMixin: @@ -151,30 +37,11 @@ class DatasetExternalAccessorMixin: __slots__ = () - @property - def hvplot(self) -> hvPlotAccessor: - """hvPlot accessor for interactive plotting. Requires: ``pip install hvplot``""" - return _get_external_accessor("hvplot", self, DATASET_ACCESSORS) # type: ignore[arg-type] - - @property - def cf(self) -> CFAccessor: - """CF conventions accessor. Requires: ``pip install cf-xarray``""" - return _get_external_accessor("cf", self, DATASET_ACCESSORS) # type: ignore[arg-type] - - @property - def pint(self) -> PintDatasetAccessor: - """Pint unit accessor. Requires: ``pip install pint-xarray``""" - return _get_external_accessor("pint", self, DATASET_ACCESSORS) # type: ignore[arg-type] - - @property - def rio(self) -> RasterDataset: - """Rasterio accessor for geospatial data. Requires: ``pip install rioxarray``""" - return _get_external_accessor("rio", self, DATASET_ACCESSORS) # type: ignore[arg-type] - - @property - def plotly(self) -> DatasetPlotlyAccessor: - """Plotly accessor. Requires: ``pip install xarray-plotly``""" - return _get_external_accessor("plotly", self, DATASET_ACCESSORS) # type: ignore[arg-type] + hvplot: ClassVar[type[hvPlotAccessor]] + cf: ClassVar[type[CFAccessor]] + pint: ClassVar[type[PintDatasetAccessor]] + rio: ClassVar[type[RasterDataset]] + plotly: ClassVar[type[DatasetPlotlyAccessor]] class DataTreeExternalAccessorMixin: @@ -182,12 +49,5 @@ class DataTreeExternalAccessorMixin: __slots__ = () - @property - def hvplot(self) -> hvPlotAccessor: - """hvPlot accessor for interactive plotting. Requires: ``pip install hvplot``""" - return _get_external_accessor("hvplot", self, DATATREE_ACCESSORS) # type: ignore[arg-type] - - @property - def cf(self) -> CFAccessor: - """CF conventions accessor. Requires: ``pip install cf-xarray``""" - return _get_external_accessor("cf", self, DATATREE_ACCESSORS) # type: ignore[arg-type] + hvplot: ClassVar[type[hvPlotAccessor]] + cf: ClassVar[type[CFAccessor]] diff --git a/xarray/core/extensions.py b/xarray/core/extensions.py index 51a7917b399..c235fae000a 100644 --- a/xarray/core/extensions.py +++ b/xarray/core/extensions.py @@ -50,28 +50,11 @@ def __get__(self, obj, cls): def _register_accessor(name, cls): def decorator(accessor): if hasattr(cls, name): - # Skip registration for known external accessors - xarray provides - # typed properties that load them directly for IDE support - from xarray.accessors import ( - DATAARRAY_ACCESSORS, - DATASET_ACCESSORS, - DATATREE_ACCESSORS, - ) - - known_external = ( - set(DATAARRAY_ACCESSORS) - | set(DATASET_ACCESSORS) - | set(DATATREE_ACCESSORS) - ) - if name in known_external: - # Don't overwrite - our typed property handles this accessor - return accessor - warnings.warn( f"registration of accessor {accessor!r} under name {name!r} for type {cls!r} is " "overriding a preexisting attribute with the same name.", AccessorRegistrationWarning, - stacklevel=3, + stacklevel=2, ) setattr(cls, name, _CachedAccessor(name, accessor)) return accessor diff --git a/xarray/tests/test_extensions.py b/xarray/tests/test_extensions.py index 8c614b82b0b..8a52f79198d 100644 --- a/xarray/tests/test_extensions.py +++ b/xarray/tests/test_extensions.py @@ -5,12 +5,6 @@ import pytest import xarray as xr -from xarray.accessors import ( - DATAARRAY_ACCESSORS, - DATASET_ACCESSORS, - DATATREE_ACCESSORS, - _is_package_available, -) from xarray.core.extensions import register_datatree_accessor from xarray.tests import assert_identical @@ -99,74 +93,3 @@ def __init__(self, xarray_obj): with pytest.raises(RuntimeError, match=r"error initializing"): _ = xr.Dataset().stupid_accessor - - -class TestExternalAccessors: - """Tests for typed external accessor properties.""" - - def test_hasattr_false_for_uninstalled(self) -> None: - """hasattr returns False for accessors whose packages are not installed.""" - da = xr.DataArray([1, 2, 3]) - ds = xr.Dataset({"a": da}) - dt = xr.DataTree(ds) - - for name, (_, _, _, top_pkg) in DATAARRAY_ACCESSORS.items(): - if not _is_package_available(top_pkg): - assert not hasattr(da, name), f"hasattr should be False for {name}" - - for name, (_, _, _, top_pkg) in DATASET_ACCESSORS.items(): - if not _is_package_available(top_pkg): - assert not hasattr(ds, name), f"hasattr should be False for {name}" - - for name, (_, _, _, top_pkg) in DATATREE_ACCESSORS.items(): - if not _is_package_available(top_pkg): - assert not hasattr(dt, name), f"hasattr should be False for {name}" - - def test_hasattr_true_for_installed(self) -> None: - """hasattr returns True for accessors whose packages are installed.""" - da = xr.DataArray([1, 2, 3]) - ds = xr.Dataset({"a": da}) - - for name, (_, _, _, top_pkg) in DATAARRAY_ACCESSORS.items(): - if _is_package_available(top_pkg): - assert hasattr(da, name), f"hasattr should be True for {name}" - - for name, (_, _, _, top_pkg) in DATASET_ACCESSORS.items(): - if _is_package_available(top_pkg): - assert hasattr(ds, name), f"hasattr should be True for {name}" - - def test_attribute_error_for_uninstalled(self) -> None: - """Accessing uninstalled accessor raises AttributeError.""" - da = xr.DataArray([1, 2, 3]) - - for name, (_, _, _, top_pkg) in DATAARRAY_ACCESSORS.items(): - if not _is_package_available(top_pkg): - with pytest.raises(AttributeError): - getattr(da, name) - break # Only need to test one - - def test_external_accessor_no_overwrite(self) -> None: - """Known external accessors don't overwrite typed properties.""" - # The property should remain a property, not get replaced by _CachedAccessor - for name in DATAARRAY_ACCESSORS: - attr = getattr(xr.DataArray, name) - assert isinstance(attr, property), f"{name} should remain a property" - - for name in DATASET_ACCESSORS: - attr = getattr(xr.Dataset, name) - assert isinstance(attr, property), f"{name} should remain a property" - - for name in DATATREE_ACCESSORS: - attr = getattr(xr.DataTree, name) - assert isinstance(attr, property), f"{name} should remain a property" - - def test_accessor_caching(self) -> None: - """Accessor instances are cached on the object.""" - da = xr.DataArray([1, 2, 3]) - - for name, (_, _, _, top_pkg) in DATAARRAY_ACCESSORS.items(): - if _is_package_available(top_pkg): - accessor1 = getattr(da, name) - accessor2 = getattr(da, name) - assert accessor1 is accessor2, f"{name} accessor should be cached" - break # Only need to test one installed accessor From 618c1c5af2f73c1b6253a3eb033ae820084500ce Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:42:10 +0100 Subject: [PATCH 10/11] fix(ci): include pyproject.toml in pixi lock cache key The pixi workspace includes pyproject.toml as source metadata for the local xarray package (via `xarray = { path = "." }`). When pyproject.toml changes, the cached pixi.lock becomes invalid, causing CI failures with "lock-file not up-to-date with the workspace". This fix adds pyproject.toml to the cache key hash, ensuring the lock file is regenerated when either pixi.toml or pyproject.toml changes. --- .github/workflows/cache-pixi-lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cache-pixi-lock.yml b/.github/workflows/cache-pixi-lock.yml index e938a7664dd..da67294d62e 100644 --- a/.github/workflows/cache-pixi-lock.yml +++ b/.github/workflows/cache-pixi-lock.yml @@ -29,7 +29,7 @@ jobs: with: path: | pixi.lock - key: ${{ steps.date.outputs.date }}_${{ inputs.pixi-version }}_${{hashFiles('pixi.toml')}} + key: ${{ steps.date.outputs.date }}_${{ inputs.pixi-version }}_${{hashFiles('pixi.toml', 'pyproject.toml')}} - uses: prefix-dev/setup-pixi@v0.9.3 if: ${{ !steps.restore.outputs.cache-hit }} with: From 1fed2ceb8cd5b0417cff0925e45e62d6c8e98902 Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Fri, 16 Jan 2026 09:24:47 -0500 Subject: [PATCH 11/11] Add pyproject.toml skips --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c8fd153dd52..917f202da1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,6 +138,7 @@ module = [ "bottleneck.*", "cartopy.*", "cf_units.*", + "cf_xarray.*", "cfgrib.*", "cftime.*", "cloudpickle.*", @@ -146,6 +147,7 @@ module = [ "fsspec.*", "h5netcdf.*", "h5py.*", + "hvplot.*", "iris.*", "mpl_toolkits.*", "nc_time_axis.*", @@ -154,14 +156,17 @@ module = [ "numcodecs.*", "opt_einsum.*", "pint.*", + "pint_xarray.*", "pooch.*", "pyarrow.*", "pydap.*", + "rioxarray.*", "scipy.*", "seaborn.*", "setuptools", "sparse.*", "toolz.*", + "xarray_plotly.*", "zarr.*", "numpy.exceptions.*", # remove once support for `numpy<2.0` has been dropped "array_api_strict.*",