Skip to content
Draft
2 changes: 1 addition & 1 deletion .github/workflows/cache-pixi-lock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]
if: ${{ !steps.restore.outputs.cache-hit }}
with:
Expand Down
6 changes: 6 additions & 0 deletions doc/whats-new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ New Features
brings improved alignment between h5netcdf and libnetcdf4 in the storage of
complex numbers (:pull:`11068`). By `Mark Harfouche
<https://github.com/hmaarrfk>`_.
- 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:`11079`).
By `Your Name <https://github.com/FBumann>`_.

- :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
``arithmetic_compat='minimal'`` which matches the existing behaviour.
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ module = [
"bottleneck.*",
"cartopy.*",
"cf_units.*",
"cf_xarray.*",
"cfgrib.*",
"cftime.*",
"cloudpickle.*",
Expand All @@ -146,6 +147,7 @@ module = [
"fsspec.*",
"h5netcdf.*",
"h5py.*",
"hvplot.*",
"iris.*",
"mpl_toolkits.*",
"nc_time_axis.*",
Expand All @@ -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.*",
Expand Down
193 changes: 193 additions & 0 deletions xarray/accessors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""
External accessor support for xarray.

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
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 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

# 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]


class DatasetExternalAccessorMixin:
"""Mixin providing typed external accessor properties for Dataset."""

__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]


class DataTreeExternalAccessorMixin:
"""Mixin providing typed external accessor properties for DataTree."""

__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]
2 changes: 2 additions & 0 deletions xarray/core/dataarray.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -259,6 +260,7 @@ class DataArray(
DataWithCoords,
DataArrayArithmetic,
DataArrayAggregations,
DataArrayExternalAccessorMixin,
):
"""N-dimensional array with labeled coordinates and dimensions.

Expand Down
2 changes: 2 additions & 0 deletions xarray/core/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -197,6 +198,7 @@ class Dataset(
DataWithCoords,
DatasetAggregations,
DatasetArithmetic,
DatasetExternalAccessorMixin,
Mapping[Hashable, "DataArray"],
):
"""A multi-dimensional, in memory, array database.
Expand Down
2 changes: 2 additions & 0 deletions xarray/core/datatree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -464,6 +465,7 @@ class DataTree(
DataTreeAggregations,
DataTreeOpsMixin,
TreeAttrAccessMixin,
DataTreeExternalAccessorMixin,
Mapping[str, "DataArray | DataTree"],
):
"""
Expand Down
19 changes: 18 additions & 1 deletion xarray/core/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading