[Git][debian-gis-team/pint-xarray][upstream] New upstream version 0.6.0

Antonio Valentino (@antonio.valentino) gitlab at salsa.debian.org
Wed Sep 3 19:30:23 BST 2025



Antonio Valentino pushed to branch upstream at Debian GIS Project / pint-xarray


Commits:
5cd6b882 by Antonio Valentino at 2025-09-03T18:24:56+00:00
New upstream version 0.6.0
- - - - -


30 changed files:

- + .gitattributes
- .github/release.yml
- .github/workflows/ci-additional.yml
- .github/workflows/ci.yml
- .github/workflows/nightly.yml
- .github/workflows/pypi.yaml
- .gitignore
- .pre-commit-config.yaml
- .readthedocs.yaml
- README.md
- − ci/install-upstream-dev.sh
- − ci/requirements.txt
- docs/api.rst
- − docs/requirements.txt
- docs/terminology.rst
- docs/whats-new.rst
- pint_xarray/__init__.py
- + pint_xarray/_expects.py
- pint_xarray/accessors.py
- pint_xarray/conversion.py
- pint_xarray/errors.py
- pint_xarray/index.py
- + pint_xarray/itertools.py
- pint_xarray/tests/test_accessors.py
- pint_xarray/tests/test_conversion.py
- + pint_xarray/tests/test_expects.py
- pint_xarray/tests/test_index.py
- + pint_xarray/tests/test_itertools.py
- + pixi.lock
- pyproject.toml


Changes:

=====================================
.gitattributes
=====================================
@@ -0,0 +1,2 @@
+# SCM syntax highlighting & preventing 3-way merges
+pixi.lock merge=binary linguist-language=YAML linguist-generated=true


=====================================
.github/release.yml
=====================================
@@ -1,5 +1,5 @@
 changelog:
   exclude:
     authors:
-      - dependabot
-      - pre-commit-ci
+      - dependabot[bot]
+      - pre-commit-ci[bot]


=====================================
.github/workflows/ci-additional.yml
=====================================
@@ -18,40 +18,25 @@ jobs:
     runs-on: ubuntu-latest
     if: github.repository == 'xarray-contrib/pint-xarray'
 
-    strategy:
-      fail-fast: false
-      matrix:
-        python-version: ["3.13"]
-
     env:
       FORCE_COLOR: 3
 
     steps:
-      - name: checkout
-        uses: actions/checkout at v4
-      - name: setup python
-        uses: actions/setup-python at v5
+      - name: checkout the repository
+        uses: actions/checkout at v5
         with:
-          python-version: ${{ matrix.python-version }}
-      - name: initialize cache
-        uses: actions/cache at v4
+          # need to fetch all tags to get a correct version
+          fetch-depth: 0 # fetch all branches and tags
+
+      - name: setup environment
+        uses: prefix-dev/setup-pixi at fef5c9568ca6c4ff7707bf840ab0692ba3f08293 # 0.9.0
         with:
-          path: ~/.cache/pip
-          key: ${{ runner.os }}-pip-py${{ matrix.python-version }}-${{ hashFiles('ci/requirements/**.txt') }}
-          restore-keys: |
-            ${{ runner.os }}-pip-py${{ matrix.python-version }}-
-      - name: upgrade pip
-        run: |
-          python -m pip install --upgrade pip setuptools wheel
-      - name: install dependencies
-        run: |
-          python -m pip install -r ci/requirements.txt
-      - name: install pint-xarray
-        run: |
-          python -m pip install .
-      - name: show versions
+          environments: "doctests"
+
+      - name: import pint-xarray
         run: |
-          python -c 'import pint_xarray'
+          pixi run -e doctests python -c 'import pint_xarray'
+
       - name: run doctests
         run: |
-          python -m pytest --doctest-modules pint_xarray --ignore pint_xarray/tests
+          pixi run -e doctests doctests


=====================================
.github/workflows/ci.yml
=====================================
@@ -20,7 +20,7 @@ jobs:
     outputs:
       triggered: ${{ steps.detect-trigger.outputs.trigger-found }}
     steps:
-      - uses: actions/checkout at v4
+      - uses: actions/checkout at v5
         with:
           fetch-depth: 2
       - uses: xarray-contrib/ci-trigger at v1
@@ -29,7 +29,7 @@ jobs:
           keyword: "[skip-ci]"
 
   ci:
-    name: ${{ matrix.os }} py${{ matrix.python-version }}
+    name: ${{ matrix.os }} ${{ matrix.env }}
     runs-on: ${{ matrix.os }}
     needs: detect-skip-ci-trigger
     defaults:
@@ -50,56 +50,41 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.10", "3.11", "3.12", "3.13"]
+        env: ["tests-py311", "tests-py312", "tests-py313"]
         os: ["ubuntu-latest", "macos-latest", "windows-latest"]
 
     steps:
       - name: checkout the repository
-        uses: actions/checkout at v4
+        uses: actions/checkout at v5
         with:
           # need to fetch all tags to get a correct version
           fetch-depth: 0 # fetch all branches and tags
 
-      - name: cache pip
-        uses: actions/cache at v4
+      - name: setup environment
+        uses: prefix-dev/setup-pixi at fef5c9568ca6c4ff7707bf840ab0692ba3f08293 # 0.9.0
         with:
-          path: ~/.cache/pip
-          key: pip-py${{ matrix.python-version }}
-          restore-keys: |
-            pip-
+          environments: "${{ matrix.env }}"
 
-      - name: setup python
-        uses: actions/setup-python at v5
-        with:
-          python-version: ${{ matrix.python-version }}
-
-      - name: upgrade pip
-        run: python -m pip install --upgrade pip setuptools wheel
-
-      - name: install dependencies
+      - name: investigate env variables
         run: |
-          python -m pip install -r ci/requirements.txt
-
-      - name: install pint-xarray
-        run: python -m pip install --no-deps .
-
-      - name: show versions
-        run: python -m pip list
+          echo PYTHON_VERSION=$(pixi run -e ${{ matrix.env }} python -c 'import sys; print(".".join(map(str, sys.version_info[:2])))') >> $GITHUB_ENV
+          echo RUNNER_OS="${{ matrix.os }}"
 
       - name: import pint-xarray
         run: |
-          python -c 'import pint_xarray'
+          pixi run -e ${{ matrix.env }} python -c 'import pint_xarray'
 
       - name: run tests
         if: success()
         id: status
         run: |
-          python -m pytest --cov=pint_xarray --cov-report=xml
+          pixi run -e ${{ matrix.env }} tests --cov-report=xml
 
       - name: Upload code coverage to Codecov
-        uses: codecov/codecov-action at v5.4.3
+        uses: codecov/codecov-action at v5.5.0
         with:
-          file: ./coverage.xml
+          token: "${{ secrets.CODECOV_TOKEN }}"
+          files: ./coverage.xml
           flags: unittests
           env_vars: RUNNER_OS,PYTHON_VERSION
           name: codecov-umbrella


=====================================
.github/workflows/nightly.yml
=====================================
@@ -8,7 +8,6 @@ on:
     branches: [main]
   schedule:
     - cron: "0 0 * * *" # Daily "At 00:00" UTC
-
   workflow_dispatch:
 
 concurrency:
@@ -25,7 +24,7 @@ jobs:
     outputs:
       triggered: ${{ steps.detect-trigger.outputs.trigger-found }}
     steps:
-      - uses: actions/checkout at v4
+      - uses: actions/checkout at v5
         with:
           fetch-depth: 2
       - uses: xarray-contrib/ci-trigger at v1.2
@@ -50,11 +49,6 @@ jobs:
         )
       )
 
-    strategy:
-      fail-fast: false
-      matrix:
-        python-version: ["3.12"]
-
     outputs:
       artifacts_availability: ${{ steps.status.outputs.ARTIFACTS_AVAILABLE }}
 
@@ -63,38 +57,32 @@ jobs:
 
     steps:
       - name: checkout the repository
-        uses: actions/checkout at v4
+        uses: actions/checkout at v5
         with:
           # need to fetch all tags to get a correct version
           fetch-depth: 0 # fetch all branches and tags
 
-      - name: setup python
-        uses: actions/setup-python at v5
-        with:
-          python-version: ${{ matrix.python-version }}
-
-      - name: upgrade pip
-        run: python -m pip install --upgrade pip
-
-      - name: install dependencies
+      - name: remove lockfile
         run: |
-          python -m pip install -r ci/requirements.txt
-          python -m pip install pytest-reportlog
-
-      - name: install upstream-dev dependencies
-        run: bash ci/install-upstream-dev.sh
+          rm pixi.lock
 
-      - name: install pint-xarray
-        run: python -m pip install .
+      - name: setup environment
+        uses: prefix-dev/setup-pixi at fef5c9568ca6c4ff7707bf840ab0692ba3f08293 # 0.9.0
+        with:
+          environments: "nightly"
+          locked: false
+          frozen: false
+          cache: false
 
-      - name: show versions
-        run: python -m pip list
+      - name: import pint-xarray
+        run: |
+          pixi run -e nightly python -c 'import pint_xarray'
 
       - name: run tests
         if: success()
         id: status
         run: |
-          python -m pytest -rf --report-log=pytest-log.jsonl
+          pixi run -e nightly tests -rf --report-log=pytest-log.jsonl
 
       - name: report failures
         if: |


=====================================
.github/workflows/pypi.yaml
=====================================
@@ -10,7 +10,7 @@ jobs:
     runs-on: ubuntu-latest
     if: github.repository == 'xarray-contrib/pint-xarray'
     steps:
-      - uses: actions/checkout at v4
+      - uses: actions/checkout at v5
         with:
           fetch-depth: 0
 
@@ -57,7 +57,7 @@ jobs:
       id-token: write
 
     steps:
-      - uses: actions/download-artifact at v4
+      - uses: actions/download-artifact at v5
         with:
           name: releases
           path: dist


=====================================
.gitignore
=====================================
@@ -128,3 +128,6 @@ dmypy.json
 
 # Pyre type checker
 .pyre/
+# pixi environments
+.pixi/*
+!.pixi/config.toml


=====================================
.pre-commit-config.yaml
=====================================
@@ -4,7 +4,7 @@ ci:
 # https://pre-commit.com/
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v5.0.0
+    rev: v6.0.0
     hooks:
       - id: trailing-whitespace
       - id: end-of-file-fixer
@@ -37,7 +37,7 @@ repos:
         additional_dependencies: ["black==25.1.0"]
       - id: blackdoc-autoupdate-black
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.12.7
+    rev: v0.12.10
     hooks:
       - id: ruff
         args: [--fix]


=====================================
.readthedocs.yaml
=====================================
@@ -1,22 +1,25 @@
 version: 2
 
 build:
-  os: ubuntu-22.04
+  os: ubuntu-lts-latest
   tools:
-    python: "3.11"
+    # just so RTD stops complaining
+    python: "latest"
   jobs:
     post_checkout:
       - (git --no-pager log --pretty="tformat:%s" -1 | grep -vqF "[skip-rtd]") || exit 183
       - git fetch --unshallow || true
+    create_environment:
+      - asdf plugin add pixi
+      - asdf install pixi latest
+      - asdf global pixi latest
     pre_install:
       - git update-index --assume-unchanged docs/conf.py
-
-python:
-  install:
-    - requirements: docs/requirements.txt
-    - method: pip
-      path: .
+    install:
+      - pixi install -e docs
+    build:
+      html:
+        - pixi run -e docs build-docs-rtd
 
 sphinx:
   configuration: docs/conf.py
-  fail_on_warning: true


=====================================
README.md
=====================================
@@ -1,4 +1,4 @@
-[![CI](https://github.com/xarray-contrib/pint-xarray/workflows/CI/badge.svg?branch=main)](https://github.com/xarray-contrib/pint-xarray/actions?query=branch%3Amain)
+[![CI](https://github.com/xarray-contrib/pint-xarray/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/xarray-contrib/pint-xarray/actions/workflows/ci.yml)
 [![code coverage](https://codecov.io/gh/xarray-contrib/pint-xarray/branch/main/graph/badge.svg)](https://codecov.io/gh/xarray-contrib/pint-xarray)
 [![docs](https://readthedocs.org/projects/pint-xarray/badge/?version=latest)](https://pint-xarray.readthedocs.io)
 [![PyPI version](https://img.shields.io/pypi/v/pint-xarray.svg)](https://pypi.org/project/pint-xarray)


=====================================
ci/install-upstream-dev.sh deleted
=====================================
@@ -1,11 +0,0 @@
-#!/usr/bin/env bash
-python -m pip install \
-  -i https://pypi.anaconda.org/scientific-python-nightly-wheels/simple \
-  --no-deps \
-  --pre \
-  --upgrade \
-  numpy \
-  scipy  # until `scipy` has released a version compatible with `numpy>=2.0`
-python -m pip install --upgrade \
-  git+https://github.com/hgrecco/pint \
-  git+https://github.com/pydata/xarray


=====================================
ci/requirements.txt deleted
=====================================
@@ -1,11 +0,0 @@
-pint!=0.24.0
-numpy
-scipy
-dask[array]
-bottleneck
-xarray
-isort
-black
-flake8
-pytest
-pytest-cov


=====================================
docs/api.rst
=====================================
@@ -65,6 +65,20 @@ DataArray
    xarray.DataArray.pint.bfill
    xarray.DataArray.pint.interpolate_na
 
+Wrapping quantity-unaware functions
+-----------------------------------
+.. autosummary::
+   :toctree: generated/
+
+   pint_xarray.expects
+
+Exceptions
+----------
+.. autosummary::
+   :toctree: generated/
+
+   pint_xarray.errors.PintExceptionGroup
+
 Testing
 -------
 


=====================================
docs/requirements.txt deleted
=====================================
@@ -1,13 +0,0 @@
-pint>=0.21
-xarray>=2022.06.0
-pooch
-netCDF4
-cf-xarray>=0.6
-sphinx
-sphinx_rtd_theme>=1.0
-ipython
-ipykernel
-jupyter_client
-nbsphinx
-matplotlib
-sphinx-autosummary-accessors


=====================================
docs/terminology.rst
=====================================
@@ -5,6 +5,7 @@ Terminology
 
     unit-like
         A `pint`_ unit definition, as accepted by :py:class:`pint.Unit`.
-        May be either a :py:class:`str` or a :py:class:`pint.Unit` instance.
+        May be a :py:class:`str`, a :py:class:`pint.Unit` instance or
+        :py:obj:`None`.
 
 .. _pint: https://pint.readthedocs.io/en/stable


=====================================
docs/whats-new.rst
=====================================
@@ -2,6 +2,31 @@
 
 What's new
 ==========
+0.6.0 (31 Aug 2025)
+-------------------
+- Bump dependency versions (:pull:`313`):
+
+  ============ ==============  ==============
+   dependency    old minimum     new minimum
+  ============ ==============  ==============
+   python              3.10             3.11
+   xarray         2022.06.0        2023.07.0
+   numpy               1.23             1.26
+   pint                0.21             0.24
+  ============ ==============  ==============
+
+  By `Justus Magin <https://github.com/keewis>`_.
+- Switch to using pixi for all dependency management (:pull:`314`).
+  By `Justus Magin <https://github.com/keewis>`_.
+- Added the :py:func:`pint_xarray.expects` decorator to allow wrapping quantity-unaware functions (:issue:`141`, :pull:`316`).
+  By `Justus Magin <https://github.com/keewis>`_ and `Tom Nicholas <https://github.com/TomNicholas>`_.
+- Follow the change in signature of :py:meth:`xarray.Index.equals` (:issue:`322`, :pull:`324`)
+  By `Justus Magin <https://github.com/keewis>`_.
+- Add units to the inline ``repr`` and define a custom ``repr`` (:issue:`308`, :pull:`325`)
+  By `Justus Magin <https://github.com/keewis>`_.
+- Collect multiple errors into a specific exception group (:pull:`329`)
+  By `Justus Magin <https://github.com/keewis>`_.
+
 0.5.1 (10 Aug 2025)
 -------------------
 - Pass ``sel`` options to the wrapped array (:pull:`304`, :issue:`303`)


=====================================
pint_xarray/__init__.py
=====================================
@@ -3,13 +3,14 @@ from importlib.metadata import version
 import pint
 
 from pint_xarray import accessors, formatting, testing  # noqa: F401
+from pint_xarray._expects import expects
 from pint_xarray.accessors import default_registry as unit_registry
 from pint_xarray.accessors import setup_registry
 from pint_xarray.index import PintIndex
 
 try:
     __version__ = version("pint-xarray")
-except Exception:
+except Exception:  # pragma: no cover
     # Local copy or not installed with setuptools.
     # Disable minimum version checks on downstream libraries.
     __version__ = "999"
@@ -23,4 +24,5 @@ __all__ = [
     "unit_registry",
     "setup_registry",
     "PintIndex",
+    "expects",
 ]


=====================================
pint_xarray/_expects.py
=====================================
@@ -0,0 +1,260 @@
+import functools
+import inspect
+import itertools
+from inspect import Parameter
+
+import pint
+import pint.testing
+import xarray as xr
+
+from pint_xarray.accessors import get_registry
+from pint_xarray.conversion import extract_units
+from pint_xarray.itertools import zip_mappings
+
+variable_parameters = (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD)
+
+
+def _number_of_results(result):
+    if isinstance(result, tuple):
+        return len(result)
+    elif result is None:
+        return 0
+    else:
+        return 1
+
+
+def expects(*args_units, return_value=None, **kwargs_units):
+    """
+    Decorator which ensures the inputs and outputs of the decorated
+    function are expressed in the expected units.
+
+    Arguments to the decorated function are checked for the specified
+    units, converting to those units if necessary, and then stripped
+    of their units before being passed into the undecorated
+    function. Therefore the undecorated function should expect
+    unquantified DataArrays, Datasets, or numpy-like arrays, but with
+    the values expressed in specific units.
+
+    Parameters
+    ----------
+    func : callable
+        Function to decorate, which accepts zero or more
+        xarray.DataArrays or numpy-like arrays as inputs, and may
+        optionally return one or more xarray.DataArrays or numpy-like
+        arrays.
+    *args_units : unit-like or mapping of hashable to unit-like, optional
+        Units to expect for each positional argument given to func.
+
+        The decorator will first check that arguments passed to the
+        decorated function possess these specific units (or will
+        attempt to convert the argument to these units), then will
+        strip the units before passing the magnitude to the wrapped
+        function.
+
+        A value of None indicates not to check that argument for units
+        (suitable for flags and other non-data arguments).
+    return_value : unit-like or list of unit-like or mapping of hashable to unit-like \
+                   or list of mapping of hashable to unit-like, optional
+        The expected units of the returned value(s), either as a
+        single unit or as a list of units. The decorator will attach
+        these units to the variables returned from the function.
+
+        A value of None indicates not to attach any units to that
+        return value (suitable for flags and other non-data results).
+    **kwargs_units : mapping of hashable to unit-like, optional
+        Unit to expect for each keyword argument given to func.
+
+        The decorator will first check that arguments passed to the decorated
+        function possess these specific units (or will attempt to convert the
+        argument to these units), then will strip the units before passing the
+        magnitude to the wrapped function.
+
+        A value of None indicates not to check that argument for units (suitable
+        for flags and other non-data arguments).
+
+    Returns
+    -------
+    return_values : Any
+        Return values of the wrapped function, either a single value or a tuple
+        of values. These will be given units according to ``return_value``.
+
+    Raises
+    ------
+    TypeError
+        If any of the units are not a valid type.
+    ValueError
+        If the number of arguments or return values does not match the number of
+        units specified. Also thrown if any parameter does not have a unit
+        specified.
+
+    See Also
+    --------
+    pint.wraps
+
+    Examples
+    --------
+    Decorating a function which takes one quantified input, but
+    returns a non-data value (in this case a boolean).
+
+    >>> @expects("deg C")
+    ... def above_freezing(temp):
+    ...     return temp > 0
+    ...
+
+    Decorating a function which allows any dimensions for the array, but also
+    accepts an optional `weights` keyword argument, which must be dimensionless.
+
+    >>> @expects(None, weights="dimensionless")
+    ... def mean(da, weights=None):
+    ...     if weights:
+    ...         return da.weighted(weights=weights).mean()
+    ...     else:
+    ...         return da.mean()
+    ...
+    """
+
+    def outer(func):
+        signature = inspect.signature(func)
+
+        params_units = signature.bind(*args_units, **kwargs_units)
+
+        missing_params = [
+            name
+            for name, p in signature.parameters.items()
+            if p.kind not in variable_parameters and name not in params_units.arguments
+        ]
+        if missing_params:
+            raise ValueError(
+                "Missing units for the following parameters: "
+                + ", ".join(map(repr, missing_params))
+            )
+
+        n_expected_results = _number_of_results(return_value)
+
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs):
+            nonlocal return_value
+
+            params = signature.bind(*args, **kwargs)
+            # don't apply defaults, as those can't be quantities and thus must
+            # already be in the correct units
+
+            spec_units = dict(
+                enumerate(
+                    itertools.chain.from_iterable(
+                        spec.values() if isinstance(spec, dict) else (spec,)
+                        for spec in params_units.arguments.values()
+                        if spec is not None
+                    )
+                )
+            )
+            params_units_ = dict(
+                enumerate(
+                    itertools.chain.from_iterable(
+                        (
+                            extract_units(param)
+                            if isinstance(param, (xr.DataArray, xr.Dataset))
+                            else (param.units,)
+                        )
+                        for name, param in params.arguments.items()
+                        if isinstance(param, (xr.DataArray, xr.Dataset, pint.Quantity))
+                    )
+                )
+            )
+
+            ureg = get_registry(
+                None,
+                dict(spec_units) if spec_units else {},
+                dict(params_units_) if params_units else {},
+            )
+
+            errors = []
+            for name, (value, units) in zip_mappings(
+                params.arguments, params_units.arguments
+            ):
+                try:
+                    if units is None:
+                        if isinstance(value, pint.Quantity) or (
+                            isinstance(value, (xr.DataArray, xr.Dataset))
+                            and value.pint.units
+                        ):
+                            raise TypeError(
+                                "Passed in a quantity where none was expected"
+                            )
+                        continue
+                    if isinstance(value, pint.Quantity):
+                        params.arguments[name] = value.m_as(units)
+                    elif isinstance(value, (xr.DataArray, xr.Dataset)):
+                        params.arguments[name] = value.pint.to(units).pint.dequantify()
+                    else:
+                        raise TypeError(
+                            f"Attempting to convert non-quantity {value} to {units}."
+                        )
+                except (
+                    TypeError,
+                    pint.errors.UndefinedUnitError,
+                    pint.errors.DimensionalityError,
+                ) as e:
+                    e.add_note(
+                        f"expects: raised while trying to convert parameter {name}"
+                    )
+                    errors.append(e)
+
+            if errors:
+                raise ExceptionGroup("Errors while converting parameters", errors)
+
+            result = func(*params.args, **params.kwargs)
+
+            n_results = _number_of_results(result)
+            if return_value is not None and (
+                (isinstance(result, tuple) ^ isinstance(return_value, tuple))
+                or (n_results != n_expected_results)
+            ):
+                message = "mismatched number of return values:"
+                if n_results != n_expected_results:
+                    message += f" expected {n_expected_results} but got {n_results}."
+                elif isinstance(result, tuple) and not isinstance(return_value, tuple):
+                    message += (
+                        " expected a single return value but got a 1-sized tuple."
+                    )
+                else:
+                    message += (
+                        " expected a 1-sized tuple but got a single return value."
+                    )
+                raise ValueError(message)
+
+            if result is None:
+                return
+
+            if not isinstance(result, tuple):
+                result = (result,)
+            if not isinstance(return_value, tuple):
+                return_value = (return_value,)
+
+            final_result = []
+            errors = []
+            for index, (value, units) in enumerate(zip(result, return_value)):
+                if units is not None:
+                    try:
+                        if isinstance(value, (xr.Dataset, xr.DataArray)):
+                            value = value.pint.quantify(units)
+                        else:
+                            value = ureg.Quantity(value, units)
+                    except Exception as e:
+                        e.add_note(
+                            f"expects: raised while trying to convert return value {index}"
+                        )
+                        errors.append(e)
+
+                final_result.append(value)
+
+            if errors:
+                raise ExceptionGroup("Errors while converting return values", errors)
+
+            if n_results == 1:
+                return final_result[0]
+            return tuple(final_result)
+
+        return wrapper
+
+    return outer


=====================================
pint_xarray/accessors.py
=====================================
@@ -8,7 +8,7 @@ from xarray.core.dtypes import NA
 
 from pint_xarray import conversion
 from pint_xarray.conversion import no_unit_values
-from pint_xarray.errors import format_error_message
+from pint_xarray.errors import create_exception_group
 
 _default = object()
 
@@ -355,7 +355,7 @@ class PintDataArrayAccessor:
                     invalid_units[name] = (reported_unit, type, e)
 
         if invalid_units:
-            raise ValueError(format_error_message(invalid_units, "parse"))
+            raise create_exception_group(invalid_units, "parse")
 
         existing_units = {
             name: unit
@@ -380,7 +380,7 @@ class PintDataArrayAccessor:
                 )
                 for name, (old, new) in overwritten_units.items()
             }
-            raise ValueError(format_error_message(errors, "attach"))
+            raise create_exception_group(errors, "attach")
 
         return self.da.pipe(conversion.strip_unit_attributes).pipe(
             conversion.attach_units, new_units
@@ -1091,7 +1091,7 @@ class PintDatasetAccessor:
                     invalid_units[name] = (reported_unit, type, e)
 
         if invalid_units:
-            raise ValueError(format_error_message(invalid_units, "parse"))
+            raise create_exception_group(invalid_units, "parse")
 
         existing_units = {
             name: unit
@@ -1116,7 +1116,7 @@ class PintDatasetAccessor:
                 )
                 for name, (old, new) in overwritten_units.items()
             }
-            raise ValueError(format_error_message(errors, "attach"))
+            raise create_exception_group(errors, "attach")
 
         return self.ds.pipe(conversion.strip_unit_attributes).pipe(
             conversion.attach_units, new_units


=====================================
pint_xarray/conversion.py
=====================================
@@ -5,7 +5,7 @@ import pint
 from xarray import Coordinates, DataArray, Dataset, IndexVariable, Variable
 
 from pint_xarray.compat import call_on_dataset
-from pint_xarray.errors import format_error_message
+from pint_xarray.errors import create_exception_group
 from pint_xarray.index import PintIndex
 
 no_unit_values = ("none", None)
@@ -198,7 +198,7 @@ def attach_units(obj, units):
         if temporary_name in rejected_vars:
             rejected_vars[obj.name] = rejected_vars.pop(temporary_name)
 
-        raise ValueError(format_error_message(rejected_vars, "attach")) from e
+        raise create_exception_group(rejected_vars, "attach") from None
 
     return new_obj
 
@@ -328,7 +328,7 @@ def convert_units(obj, units):
         if temporary_name in failed:
             failed[obj.name] = failed.pop(temporary_name)
 
-        raise ValueError(format_error_message(failed, "convert")) from e
+        raise create_exception_group(failed, "convert") from None
 
     return new_obj
 
@@ -482,7 +482,7 @@ def convert_indexer_units(indexers, units):
             invalid[name] = e
 
     if invalid:
-        raise ValueError(format_error_message(invalid, "convert_indexers"))
+        raise create_exception_group(invalid, "convert_indexers")
 
     return converted
 


=====================================
pint_xarray/errors.py
=====================================
@@ -1,42 +1,49 @@
-def format_error_message(mapping, op):
-    sep = "\n    " if len(mapping) == 1 else "\n -- "
-    if op == "attach":
-        message = "Cannot attach units:"
-        message = sep.join(
-            [message]
-            + [
-                f"cannot attach units to variable {key!r}: {unit} (reason: {str(e)})"
+from collections.abc import Hashable
+from typing import Any
+
+
+class PintExceptionGroup(ExceptionGroup, ValueError):
+    """Exception group for errors related to unit operations
+
+    Raised whenever there's the possibility of multiple errors.
+    """
+
+    pass
+
+
+def _add_note(e: Exception, note: str) -> Exception:
+    e.add_note(note)
+
+    return e
+
+
+def create_exception_group(mapping: dict[Hashable, Any], op: str) -> ExceptionGroup:
+    match op:
+        case "attach":
+            message = "Cannot attach units"
+            errors = [
+                _add_note(e, f"cannot attach units to variable {key!r}: {unit}")
                 for key, (unit, e) in mapping.items()
             ]
-        )
-    elif op == "parse":
-        message = "Cannot parse units:"
-        message = sep.join(
-            [message]
-            + [
-                f"invalid units for variable {key!r}: {unit} ({type}) (reason: {str(e)})"
+        case "parse":
+            message = "Cannot parse units"
+            errors = [
+                _add_note(e, f"invalid units for variable {key!r}: {unit} ({type})")
                 for key, (unit, type, e) in mapping.items()
             ]
-        )
-    elif op == "convert":
-        message = "Cannot convert variables:"
-        message = sep.join(
-            [message]
-            + [
-                f"incompatible units for variable {key!r}: {error}"
-                for key, error in mapping.items()
+        case "convert":
+            message = "Cannot convert variables"
+            errors = [
+                _add_note(e, f"incompatible units for variable {key!r}")
+                for key, e in mapping.items()
             ]
-        )
-    elif op == "convert_indexers":
-        message = "Cannot convert indexers:"
-        message = sep.join(
-            [message]
-            + [
-                f"incompatible units for indexer for {key!r}: {error}"
-                for key, error in mapping.items()
+        case "convert_indexers":
+            message = "Cannot convert indexers"
+            errors = [
+                _add_note(e, f"incompatible units for indexer for {key!r}")
+                for key, e in mapping.items()
             ]
-        )
-    else:
-        raise ValueError("invalid op")
+        case _:  # pragma: no cover
+            raise ValueError("invalid op")
 
-    return message
+    return PintExceptionGroup(message, errors)


=====================================
pint_xarray/index.py
=====================================
@@ -1,3 +1,5 @@
+import inspect
+
 from xarray import Variable
 from xarray.core.indexes import Index, PandasIndex
 
@@ -15,6 +17,11 @@ class PintIndex(Index):
         units : mapping of hashable to unit-like
             The units of the indexed coordinates
         """
+        if not isinstance(units, dict):
+            raise TypeError(
+                "Index units have to be a dict of coordinate names to units."
+            )
+
         self.index = index
         self.units = units
 
@@ -71,16 +78,35 @@ class PintIndex(Index):
     def reindex_like(self, other):
         raise NotImplementedError()
 
-    def equals(self, other):
+    def equals(self, other, *, exclude=None):
         if not isinstance(other, PintIndex):
             return False
 
-        # for now we require exactly matching units to avoid the potentially expensive conversion
+        # for now we require exactly matching units to avoid the potentially
+        # expensive conversion
         if self.units != other.units:
             return False
 
-        # last to avoid the potentially expensive comparison
-        return self.index.equals(other.index)
+        # TODO:
+        # - remove try-except once we can drop xarray<2025.06.0
+        # - remove compat once we can require a version of xarray that completed
+        #   the deprecation cycle
+        try:
+            from xarray.core.indexes import _wrap_index_equals
+
+            equals = _wrap_index_equals(self.index)
+            kwargs = {"exclude": exclude}
+        except ImportError:  # pragma: no cover
+            equals = self.index.equals
+            signature = inspect.signature(self.index.equals)
+
+            if "exclude" in signature.parameters:
+                kwargs = {"exclude": exclude}
+            else:
+                kwargs = {}
+
+        # Last to avoid the potentially expensive comparison
+        return equals(other.index, **kwargs)
 
     def roll(self, shifts):
         return self._replace(self.index.roll(shifts))
@@ -92,4 +118,14 @@ class PintIndex(Index):
         return self._replace(self.index[indexer])
 
     def _repr_inline_(self, max_width):
-        return f"{self.__class__.__name__}({self.index.__class__.__name__})"
+        name = self.__class__.__name__
+        wrapped_name = self.index.__class__.__name__
+
+        formatted_units = {n: f"{u:~P}" for n, u in self.units.items()}
+        return f"{name}({wrapped_name}, units={formatted_units})"
+
+    def __repr__(self):
+        formatted_units = {n: f"{u:~P}" for n, u in self.units.items()}
+        summary = f"<{self.__class__.__name__} (units={formatted_units})>"
+
+        return "\n".join([summary, repr(self.index)])


=====================================
pint_xarray/itertools.py
=====================================
@@ -0,0 +1,30 @@
+import itertools
+from functools import reduce
+
+
+def separate(predicate, iterable):
+    evaluated = ((predicate(el), el) for el in iterable)
+
+    key = lambda x: x[0]
+    grouped = itertools.groupby(sorted(evaluated, key=key), key=key)
+
+    groups = {label: [el for _, el in group] for label, group in grouped}
+
+    return groups[False], groups[True]
+
+
+def unique(iterable):
+    return list(dict.fromkeys(iterable))
+
+
+def zip_mappings(*mappings):
+    def common_keys(a, b):
+        all_keys = unique(itertools.chain(a.keys(), b.keys()))
+        intersection = set(a.keys()).intersection(b.keys())
+
+        return [key for key in all_keys if key in intersection]
+
+    keys = list(reduce(common_keys, mappings))
+
+    for key in keys:
+        yield key, tuple(m[key] for m in mappings)


=====================================
pint_xarray/tests/test_accessors.py
=====================================
@@ -7,6 +7,7 @@ from numpy.testing import assert_array_equal
 from pint import Unit, UnitRegistry
 
 from pint_xarray import accessors, conversion
+from pint_xarray.errors import PintExceptionGroup
 from pint_xarray.index import PintIndex
 from pint_xarray.tests.utils import (
     assert_equal,
@@ -114,7 +115,11 @@ class TestQuantifyDataArray:
 
     def test_error_when_changing_units(self, example_quantity_da):
         da = example_quantity_da
-        with pytest.raises(ValueError, match="already has units"):
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(ValueError, match="already has units"),
+            match="Cannot attach units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
+        ):
             da.pint.quantify("s")
 
     def test_attach_no_units(self):
@@ -141,7 +146,11 @@ class TestQuantifyDataArray:
             dims="x",
             coords={"x": ("x", [-1, 0, 1], {"units": unit_registry.Unit("m")})},
         )
-        with pytest.raises(ValueError, match="already has units"):
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(ValueError, match="already has units"),
+            match="Cannot attach units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
+        ):
             arr.pint.quantify({"x": "s"})
 
     def test_dimension_coordinate_array(self):
@@ -157,7 +166,11 @@ class TestQuantifyDataArray:
         ds = xr.Dataset(coords={"x": ("x", [10], {"units": unit_registry.Unit("m")})})
         arr = ds.x
 
-        with pytest.raises(ValueError):
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(ValueError, match="already has units"),
+            match="Cannot attach units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
+        ):
             arr.pint.quantify({"x": "s"})
 
     def test_dimension_coordinate_array_already_quantified_same_units(self):
@@ -181,14 +194,24 @@ class TestQuantifyDataArray:
 
     def test_error_on_nonsense_units(self, example_unitless_da):
         da = example_unitless_da
-        with pytest.raises(ValueError, match=str(da.name)):
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(
+                pint.UndefinedUnitError, match=rf"{da.name}: .+ \(parameter\)"
+            ),
+            match="Cannot parse units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
+        ):
             da.pint.quantify(units="aecjhbav")
 
     def test_error_on_nonsense_units_attrs(self, example_unitless_da):
         da = example_unitless_da
         da.attrs["units"] = "aecjhbav"
-        with pytest.raises(
-            ValueError, match=rf"{da.name}: {da.attrs['units']} \(attribute\)"
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(
+                pint.UndefinedUnitError, match=rf"{da.name}: .+ \(attribute\)"
+            ),
+            match="Cannot parse units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
         ):
             da.pint.quantify()
 
@@ -355,7 +378,11 @@ class TestQuantifyDataSet:
         )
 
     def test_error_when_already_units(self, example_quantity_ds):
-        with pytest.raises(ValueError, match="already has units"):
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(ValueError, match="already has units"),
+            match="Cannot attach units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
+        ):
             example_quantity_ds.pint.quantify({"funds": "kg"})
 
     def test_attach_no_units(self):
@@ -382,25 +409,45 @@ class TestQuantifyDataSet:
         ds = xr.Dataset(
             coords={"x": ("x", [-1, 0, 1], {"units": unit_registry.Unit("m")})},
         )
-        with pytest.raises(ValueError, match="already has units"):
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(ValueError, match="already has units"),
+            match="Cannot attach units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
+        ):
             ds.pint.quantify({"x": "s"})
 
     def test_error_on_nonsense_units(self, example_unitless_ds):
         ds = example_unitless_ds
-        with pytest.raises(ValueError):
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(
+                pint.UndefinedUnitError, match=r"'users': .+ \(parameter\)"
+            ),
+            match="Cannot parse units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
+        ):
             ds.pint.quantify(units={"users": "aecjhbav"})
 
     def test_error_on_nonsense_units_attrs(self, example_unitless_ds):
         ds = example_unitless_ds
         ds.users.attrs["units"] = "aecjhbav"
-        with pytest.raises(
-            ValueError, match=rf"'users': {ds.users.attrs['units']} \(attribute\)"
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(
+                pint.UndefinedUnitError, match=r"'users': .+ \(attribute\)"
+            ),
+            match="Cannot parse units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
         ):
             ds.pint.quantify()
 
     def test_error_indicates_problematic_variable(self, example_unitless_ds):
         ds = example_unitless_ds
-        with pytest.raises(ValueError, match="'users'"):
+        with pytest.RaisesGroup(
+            pytest.RaisesExc(
+                pint.UndefinedUnitError, match=r"'users': aecjhbav \(parameter\)"
+            ),
+            match="Cannot parse units",
+            check=lambda eg: isinstance(eg, PintExceptionGroup),
+        ):
             ds.pint.quantify(units={"users": "aecjhbav"})
 
     def test_existing_units(self, example_quantity_ds):


=====================================
pint_xarray/tests/test_conversion.py
=====================================
@@ -2,10 +2,12 @@ import numpy as np
 import pandas as pd
 import pint
 import pytest
+from tlz.dicttoolz import dissoc
 from xarray import Coordinates, DataArray, Dataset, Variable
 from xarray.core.indexes import PandasIndex
 
 from pint_xarray import conversion
+from pint_xarray.errors import PintExceptionGroup
 from pint_xarray.index import PintIndex
 from pint_xarray.tests.utils import (
     assert_array_equal,
@@ -252,7 +254,7 @@ class TestXarrayFunctions:
 
         index = PandasIndex(x, dim="x")
         if units.get("x") is not None:
-            index = PintIndex(index=index, units=units.get("x"))
+            index = PintIndex(index=index, units={"x": units.get("x")})
 
         obj = Dataset({"a": ("x", a), "b": ("x", b)}, coords={"u": ("x", u), "x": x})
         coords = Coordinates(
@@ -297,76 +299,73 @@ class TestXarrayFunctions:
 
     @pytest.mark.parametrize("type", ("DataArray", "Dataset"))
     @pytest.mark.parametrize(
-        ["variant", "units", "error", "match"],
+        ["variant", "units", "error", "suberrors"],
         (
-            pytest.param("none", {}, None, None, id="none-no units"),
+            pytest.param("none", {}, None, {}, id="none-no units"),
             pytest.param(
                 "none",
                 {"a": Unit("g"), "b": Unit("Pa"), "u": Unit("ms"), "x": Unit("mm")},
-                ValueError,
-                "(?s)Cannot convert variables:.+'u'",
+                PintExceptionGroup,
+                {
+                    "a": (pint.DimensionalityError, "'a'"),
+                    "b": (pint.DimensionalityError, "'b'"),
+                    "u": (pint.DimensionalityError, "'u'"),
+                    "x": (ValueError, "'x'"),
+                },
                 id="none-with units",
             ),
-            pytest.param("data", {}, None, None, id="data-no units"),
+            pytest.param("data", {}, None, {}, id="data-no units"),
             pytest.param(
                 "data",
                 {"a": Unit("g"), "b": Unit("Pa")},
                 None,
-                None,
+                {},
                 id="data-compatible units",
             ),
             pytest.param(
                 "data",
                 {"a": Unit("s"), "b": Unit("m")},
-                ValueError,
-                "(?s)Cannot convert variables:.+'a'",
+                PintExceptionGroup,
+                {
+                    "a": (pint.DimensionalityError, "'a'"),
+                    "b": (pint.DimensionalityError, "'b'"),
+                },
                 id="data-incompatible units",
             ),
+            pytest.param("dims", {}, None, {}, id="dims-no units"),
             pytest.param(
-                "dims",
-                {},
-                None,
-                None,
-                id="dims-no units",
-            ),
-            pytest.param(
-                "dims",
-                {"x": Unit("mm")},
-                None,
-                None,
-                id="dims-compatible units",
+                "dims", {"x": Unit("mm")}, None, {}, id="dims-compatible units"
             ),
             pytest.param(
                 "dims",
                 {"x": Unit("ms")},
-                ValueError,
-                "(?s)Cannot convert variables:.+'x'",
+                PintExceptionGroup,
+                {"x": (ValueError, "'x'")},
                 id="dims-incompatible units",
             ),
-            pytest.param(
-                "coords",
-                {},
-                None,
-                None,
-                id="coords-no units",
-            ),
+            pytest.param("coords", {}, None, {}, id="coords-no units"),
             pytest.param(
                 "coords",
                 {"u": Unit("ms")},
                 None,
-                None,
+                {"u": (pint.DimensionalityError, "'u'")},
                 id="coords-compatible units",
             ),
             pytest.param(
                 "coords",
                 {"u": Unit("mm")},
-                ValueError,
-                "(?s)Cannot convert variables:.+'u'",
+                PintExceptionGroup,
+                {
+                    "u": (
+                        pint.DimensionalityError,
+                        "incompatible units for variable 'u'",
+                    )
+                },
                 id="coords-incompatible units",
             ),
         ),
     )
-    def test_convert_units(self, type, variant, units, error, match):
+    def test_convert_units(self, type, variant, units, error, suberrors):
         variants = {
             "none": {"a": None, "b": None, "u": None, "x": None},
             "data": {"a": Unit("kg"), "b": Unit("hPa"), "u": None, "x": None},
@@ -401,9 +400,17 @@ class TestXarrayFunctions:
         )
         if type == "DataArray":
             obj = obj["a"]
+            suberrors = dissoc(suberrors, "b")
 
         if error is not None:
-            with pytest.raises(error, match=match):
+            matchers = [
+                pytest.RaisesExc(err, match=match) for err, match in suberrors.values()
+            ]
+            with pytest.RaisesGroup(
+                *matchers,
+                match="Cannot convert variables",
+                check=lambda eg: isinstance(eg, PintExceptionGroup),
+            ):
                 conversion.convert_units(obj, units)
 
             return
@@ -607,7 +614,7 @@ class TestXarrayFunctions:
 
 class TestIndexerFunctions:
     @pytest.mark.parametrize(
-        ["indexers", "units", "expected", "error", "match"],
+        ["indexers", "units", "expected", "error", "suberrors"],
         (
             pytest.param(
                 {"x": 1}, {"x": None}, {"x": 1}, None, None, id="scalar-no units"
@@ -616,8 +623,8 @@ class TestIndexerFunctions:
                 {"x": 1},
                 {"x": "dimensionless"},
                 None,
-                ValueError,
-                "(?s)Cannot convert indexers:.+'x'",
+                PintExceptionGroup,
+                {"x": (ValueError, "'x'")},
                 id="scalar-dimensionless",
             ),
             pytest.param(
@@ -712,23 +719,30 @@ class TestIndexerFunctions:
                 {"x": slice(Quantity(1, "m"), Quantity(2, "m"))},
                 {"x": Unit("ms")},
                 None,
-                ValueError,
-                "(?s)Cannot convert indexers:.+'x'",
+                PintExceptionGroup,
+                {"x": (pint.DimensionalityError, "'x'")},
                 id="slice-incompatible units",
             ),
             pytest.param(
                 {"x": slice(1000, Quantity(2000, "ms"))},
                 {"x": Unit("s")},
                 None,
-                ValueError,
-                "(?s)Cannot convert indexers:.+'x'",
+                PintExceptionGroup,
+                {"x": (pint.DimensionalityError, "'x'")},
                 id="slice-incompatible units-mixed",
             ),
         ),
     )
-    def test_convert_indexer_units(self, indexers, units, expected, error, match):
+    def test_convert_indexer_units(self, indexers, units, expected, error, suberrors):
         if error is not None:
-            with pytest.raises(error, match=match):
+            matchers = [
+                pytest.RaisesExc(err, match=match) for err, match in suberrors.values()
+            ]
+            with pytest.RaisesGroup(
+                *matchers,
+                match="Cannot convert indexers",
+                check=lambda eg: isinstance(eg, PintExceptionGroup),
+            ):
                 conversion.convert_indexer_units(indexers, units)
         else:
             actual = conversion.convert_indexer_units(indexers, units)


=====================================
pint_xarray/tests/test_expects.py
=====================================
@@ -0,0 +1,296 @@
+import re
+
+import pint
+import pytest
+import xarray as xr
+
+import pint_xarray
+from pint_xarray.testing import assert_units_equal
+
+ureg = pint_xarray.unit_registry
+
+
+class TestExpects:
+    @pytest.mark.parametrize(
+        ["values", "units", "expected"],
+        (
+            ((ureg.Quantity(1, "m"), 2), ("mm", None, None), 500),
+            ((ureg.Quantity(1, "m"), ureg.Quantity(0.5, "s")), ("mm", "ms", None), 2),
+            (
+                (xr.DataArray(4).pint.quantify("km"), 2),
+                ("m", None, None),
+                xr.DataArray(2000),
+            ),
+            (
+                (
+                    xr.DataArray([4, 2, 0]).pint.quantify("cm"),
+                    xr.DataArray([4, 2, 1]).pint.quantify("mg"),
+                ),
+                ("m", "g", None),
+                xr.DataArray([10, 10, 0]),
+            ),
+            (
+                (ureg.Quantity(16, "m"), 2, ureg.Quantity(4, "s")),
+                ("mm", None, "ms"),
+                2,
+            ),
+        ),
+    )
+    def test_args(self, values, units, expected):
+        @pint_xarray.expects(*units)
+        def func(a, b, c=1):
+            return a / (b * c)
+
+        actual = func(*values)
+
+        if isinstance(actual, xr.DataArray):
+            xr.testing.assert_identical(actual, expected)
+        elif isinstance(actual, pint.Quantity):
+            pint.testing.assert_equal(actual, expected)
+        else:
+            assert actual == expected
+
+    @pytest.mark.parametrize(
+        ["value", "units", "error", "message", "multiple"],
+        (
+            (
+                ureg.Quantity(1, "m"),
+                (None, None),
+                TypeError,
+                "Passed in a quantity where none was expected",
+                True,
+            ),
+            (1, ("m", None), TypeError, "Attempting to convert non-quantity", True),
+            (
+                1,
+                (None,),
+                ValueError,
+                "Missing units for the following parameters: 'b'",
+                False,
+            ),
+            (
+                ureg.Quantity(1, "m"),
+                ("nonsense_unit", None),
+                pint.errors.UndefinedUnitError,
+                "'nonsense_unit' is not defined in the unit registry",
+                True,
+            ),
+        ),
+    )
+    def test_args_error(self, value, units, error, message, multiple):
+        if multiple:
+            root_error = ExceptionGroup
+            root_message = "Errors while converting parameters"
+        else:
+            root_error = error
+            root_message = message
+
+        with pytest.raises(root_error, match=root_message) as excinfo:
+
+            @pint_xarray.expects(*units)
+            def func(a, b=1):
+                return a * b
+
+            func(value)
+
+        if not multiple:
+            return
+
+        group = excinfo.value
+        assert len(group.exceptions) == 1, f"Found {len(group.exceptions)} exceptions"
+        exc = group.exceptions[0]
+        assert isinstance(
+            exc, error
+        ), f"Unexpected exception type: {type(exc)}, expected {error}"
+        if not re.search(message, str(exc)):
+            raise AssertionError(f"exception {exc!r} did not match pattern {message!r}")
+
+    @pytest.mark.parametrize(
+        ["values", "units", "expected"],
+        (
+            (
+                {"a": ureg.Quantity(1, "m"), "b": 2},
+                {"a": "mm", "b": None, "c": None},
+                1000,
+            ),
+            (
+                {"a": 2, "b": ureg.Quantity(100, "cm")},
+                {"a": None, "b": "m", "c": None},
+                4,
+            ),
+            (
+                {"a": ureg.Quantity(1, "m"), "b": ureg.Quantity(0.5, "s")},
+                {"a": "mm", "b": "ms", "c": None},
+                4,
+            ),
+            (
+                {"a": xr.DataArray(4).pint.quantify("km"), "b": 2},
+                {"a": "m", "b": None, "c": None},
+                xr.DataArray(4000),
+            ),
+            (
+                {
+                    "a": xr.DataArray([4, 2, 0]).pint.quantify("cm"),
+                    "b": xr.DataArray([4, 2, 1]).pint.quantify("mg"),
+                },
+                {"a": "m", "b": "g", "c": None},
+                xr.DataArray([20, 20, 0]),
+            ),
+        ),
+    )
+    def test_kwargs(self, values, units, expected):
+        @pint_xarray.expects(**units)
+        def func(a, b, c=2):
+            return a / b * c
+
+        actual = func(**values)
+
+        if isinstance(actual, xr.DataArray):
+            xr.testing.assert_identical(actual, expected)
+        elif isinstance(actual, pint.Quantity):
+            pint.testing.assert_equal(actual, expected)
+        else:
+            assert actual == expected
+
+    @pytest.mark.parametrize(
+        ["values", "return_value_units", "expected"],
+        (
+            ((1, 2), ("m", "s"), (ureg.Quantity(1, "m"), ureg.Quantity(2, "s"))),
+            ((1, 2), "m / s", ureg.Quantity(0.5, "m / s")),
+            ((1, 2), None, 0.5),
+            (
+                (xr.DataArray(2), 2),
+                ("m", "s"),
+                (xr.DataArray(2).pint.quantify("m"), ureg.Quantity(2, "s")),
+            ),
+            (
+                (xr.DataArray(2), 2),
+                "kg / m^2",
+                xr.DataArray(1).pint.quantify("kg / m^2"),
+            ),
+        ),
+    )
+    def test_return_value(self, values, return_value_units, expected):
+        multiple = isinstance(return_value_units, tuple)
+
+        @pint_xarray.expects(a=None, b=None, return_value=return_value_units)
+        def func(a, b):
+            if multiple:
+                return a, b
+            else:
+                return a / b
+
+        actual = func(*values)
+        if isinstance(actual, xr.DataArray):
+            xr.testing.assert_identical(actual, expected)
+        elif isinstance(actual, pint.Quantity):
+            pint.testing.assert_equal(actual, expected)
+        else:
+            assert actual == expected
+
+    def test_return_value_none(self):
+        @pint_xarray.expects(None)
+        def func(a):
+            return None
+
+        actual = func(1)
+        assert actual is None
+
+    def test_return_value_none_error(self):
+        @pint_xarray.expects(return_value="Hz")
+        def func():
+            return None
+
+        with pytest.raises(
+            ValueError,
+            match="mismatched number of return values: expected 1 but got 0.",
+        ):
+            func()
+
+    @pytest.mark.parametrize(
+        [
+            "return_value_units",
+            "multiple_units",
+            "error",
+            "multiple_errors",
+            "message",
+        ],
+        (
+            (
+                ("m", "s"),
+                False,
+                ValueError,
+                False,
+                "mismatched number of return values",
+            ),
+            (
+                "m",
+                True,
+                ValueError,
+                False,
+                "mismatched number of return values: expected 1 but got 2",
+            ),
+            (
+                ("m",),
+                True,
+                ValueError,
+                False,
+                "mismatched number of return values: expected 1 but got 2",
+            ),
+            (1, False, TypeError, True, "units must be of type"),
+            (("m",), False, ValueError, False, ".*expected a 1-sized tuple.*"),
+            ("m", 1, ValueError, False, ".*expected a single return value.*"),
+        ),
+    )
+    def test_return_value_error(
+        self, return_value_units, multiple_units, error, multiple_errors, message
+    ):
+        if multiple_errors:
+            root_error = ExceptionGroup
+            root_message = "Errors while converting return values"
+        else:
+            root_error = error
+            root_message = message
+
+        with pytest.raises(root_error, match=root_message) as excinfo:
+
+            @pint_xarray.expects(a=None, b=None, return_value=return_value_units)
+            def func(a, b):
+                if not isinstance(multiple_units, bool) and multiple_units == 1:
+                    print("return 1-tuple")
+                    return (a / b,)
+                elif multiple_units:
+                    return a, b
+                else:
+                    return a / b
+
+            func(1, 2)
+
+        if not multiple_errors:
+            return
+
+        group = excinfo.value
+        assert len(group.exceptions) == 1, f"Found {len(group.exceptions)} exceptions"
+        exc = group.exceptions[0]
+        assert isinstance(
+            exc, error
+        ), f"Unexpected exception type: {type(exc)}, expected {error}"
+        if not re.search(message, str(exc)):
+            raise AssertionError(f"exception {exc!r} did not match pattern {message!r}")
+
+    def test_datasets(self):
+        @pint_xarray.expects({"m": "kg", "a": "m / s^2"}, return_value={"f": "newtons"})
+        def second_law(ds):
+            f_da = ds["m"] * ds["a"]
+            return f_da.to_dataset(name="f")
+
+        ds = xr.Dataset({"m": 0.1, "a": 10}).pint.quantify(
+            {"m": "tons", "a": "feet / second^2"}
+        )
+
+        expected = xr.Dataset({"f": ds["m"] * ds["a"]}).pint.to("newtons")
+
+        actual = second_law(ds)
+
+        assert_units_equal(actual, expected)
+        xr.testing.assert_allclose(actual, expected)


=====================================
pint_xarray/tests/test_index.py
=====================================
@@ -28,17 +28,24 @@ def indexer_equal(first, second):
 )
 @pytest.mark.parametrize("units", [ureg.Unit("m"), ureg.Unit("s")])
 def test_init(base_index, units):
-    index = PintIndex(index=base_index, units=units)
+    index = PintIndex(index=base_index, units={base_index.dim: units})
 
     assert index.index.equals(base_index)
-    assert index.units == units
+    assert index.units == {base_index.dim: units}
+
+
+def test_init_error():
+    base_index = PandasIndex(pd.Index([1, 2, 3]), dim="x")
+
+    with pytest.raises(TypeError, match="dict of coordinate names to units"):
+        PintIndex(index=base_index, units=ureg.Unit("s"))
 
 
 def test_replace():
-    old_index = PandasIndex([1, 2, 3], dim="y")
+    old_index = PandasIndex([1, 2, 3], dim="x")
     new_index = PandasIndex([0.1, 0.2, 0.3], dim="x")
 
-    old = PintIndex(index=old_index, units=ureg.Unit("m"))
+    old = PintIndex(index=old_index, units={"x": ureg.Unit("m")})
     new = old._replace(new_index)
 
     assert new.index.equals(new_index)
@@ -205,6 +212,28 @@ def test_equals(other, expected):
     assert actual == expected
 
 
+ at pytest.mark.filterwarnings("error")
+def test_align_equals_warning():
+    index1 = PintIndex(
+        index=PandasIndex(pd.Index([1, 2]), dim="x"),
+        units={"x": ureg.Unit("m")},
+    )
+    index2 = PintIndex(
+        index=PandasIndex(pd.Index([0, 1, 2]), dim="y"),
+        units={"y": ureg.Unit("m")},
+    )
+
+    ds = xr.Dataset(
+        {"a": (["y", "x"], [[-1, 1], [0, 2], [1, 3]])},
+        coords=xr.Coordinates(
+            {"x": [1, 2], "y": [0, 1, 2]}, indexes={"x": index1, "y": index2}
+        ),
+    )
+
+    # trigger comparison
+    ds["a"] * ds["x"] * ds["y"]
+
+
 @pytest.mark.parametrize(
     ["shifts", "expected_index"],
     (
@@ -254,10 +283,23 @@ def test_getitem(indexer):
 
 @pytest.mark.parametrize("wrapped_index", (PandasIndex(pd.Index([1, 2]), dim="x"),))
 def test_repr_inline(wrapped_index):
-    index = PintIndex(index=wrapped_index, units=ureg.Unit("m"))
+    index = PintIndex(index=wrapped_index, units={"x": ureg.Unit("m")})
 
     # TODO: parametrize
     actual = index._repr_inline_(90)
 
     assert "PintIndex" in actual
     assert wrapped_index.__class__.__name__ in actual
+    assert "units" in actual
+
+
+ at pytest.mark.parametrize("wrapped_index", (PandasIndex(pd.Index([1, 2]), dim="x"),))
+def test_repr(wrapped_index):
+    index = PintIndex(index=wrapped_index, units={"x": ureg.Unit("m")})
+
+    # TODO: parametrize
+    actual = repr(index)
+
+    assert "<PintIndex" in actual
+    assert "'x': 'm'" in actual
+    assert wrapped_index.__class__.__name__ in actual


=====================================
pint_xarray/tests/test_itertools.py
=====================================
@@ -0,0 +1,48 @@
+import pytest
+
+from pint_xarray.itertools import separate, unique, zip_mappings
+
+
+ at pytest.mark.parametrize(
+    ["predicate", "iterable"],
+    (
+        (lambda x: x % 2 == 0, range(10)),
+        (lambda x: x in [0, 2, 3, 5], range(10)),
+        (lambda x: "s" in x, ["ab", "de", "sf", "fs"]),
+    ),
+)
+def test_separate(predicate, iterable):
+    actual_false, actual_true = separate(predicate, iterable)
+
+    expected_true = [el for el in iterable if predicate(el)]
+    expected_false = [el for el in iterable if not predicate(el)]
+
+    assert actual_true == expected_true
+    assert actual_false == expected_false
+
+
+ at pytest.mark.parametrize(
+    ["iterable", "expected"],
+    (
+        ([5, 4, 4, 1, 2, 3, 2, 1], [5, 4, 1, 2, 3]),
+        (list("dadgafffgaefed"), list("dagfe")),
+    ),
+)
+def test_unique(iterable, expected):
+    actual = unique(iterable)
+
+    assert actual == expected
+
+
+ at pytest.mark.parametrize(
+    ["mappings", "expected"],
+    (
+        (({"a": 1, "b": 2}, {"a": 2, "b": 3}), [("a", (1, 2)), ("b", (2, 3))]),
+        (({"a": 1, "c": 2}, {"a": 2, "b": 0}), [("a", (1, 2))]),
+        (({"a": 1, "c": 2}, {"c": 2, "b": 0}), [("c", (2, 2))]),
+        (({"a": 1}, {"c": 2, "b": 0}), []),
+    ),
+)
+def test_zip_mappings(mappings, expected):
+    actual = list(zip_mappings(*mappings))
+    assert actual == expected


=====================================
pixi.lock
=====================================
The diff for this file was not included because it is too large.

=====================================
pyproject.toml
=====================================
@@ -4,26 +4,24 @@ authors = [
   { name = "Tom Nicholas", email = "tomnicholas1 at googlemail.com" },
 ]
 description = "Physical units interface to xarray using Pint"
-license = { text = "Apache-2" }
+license = "Apache-2.0"
 readme = "README.md"
 classifiers = [
   "Development Status :: 3 - Alpha",
   "Environment :: Console",
   "Intended Audience :: Science/Research",
-  "License :: OSI Approved :: Apache Software License",
   "Operating System :: OS Independent",
   "Programming Language :: Python",
-  "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
   "Programming Language :: Python :: 3.12",
   "Programming Language :: Python :: 3.13",
   "Topic :: Scientific/Engineering",
 ]
-requires-python = ">=3.10"
+requires-python = ">=3.11"
 dependencies = [
-  "numpy >= 1.23",
-  "xarray >= 2022.06.0",
-  "pint >= 0.21",
+  "xarray >= 2023.07.0",
+  "numpy >= 1.26",
+  "pint >= 0.24",
 ]
 dynamic = ["version"]
 
@@ -38,7 +36,7 @@ include = [
 ]
 
 [build-system]
-requires = ["setuptools >= 64", "setuptools_scm >= 7.0"]
+requires = ["setuptools >= 77", "setuptools_scm >= 8"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools_scm]
@@ -48,7 +46,7 @@ fallback_version = "999"
 junit_family = "xunit2"
 
 [tool.ruff]
-target-version = "py310"
+target-version = "py311"
 builtins = ["ellipsis"]
 exclude = [
   ".git",
@@ -95,7 +93,106 @@ ban-relative-imports = "all"
 [tool.coverage.run]
 source = ["pint_xarray"]
 branch = true
+omit = ["pint_xarray/tests/*"]
 
 [tool.coverage.report]
 show_missing = true
 exclude_lines = ["pragma: no cover", "if TYPE_CHECKING"]
+
+[tool.pixi.workspace]
+channels = ["conda-forge"]
+platforms = ["linux-64", "osx-arm64", "win-64"]
+
+[tool.pixi.dependencies]
+numpy = "*"
+pint = "*"
+xarray = "*"
+
+[tool.pixi.pypi-dependencies]
+pint-xarray = { path = ".", editable = true }
+
+[tool.pixi.feature.optional-deps.dependencies]
+dask = "*"
+scipy = "*"
+bottleneck = "*"
+
+[tool.pixi.feature.tests.dependencies]
+pytest = ">=8"
+pytest-cov = "*"
+pytest-xdist = "*"
+cytoolz = "*"
+
+[tool.pixi.feature.tests-py311.dependencies]
+python = "3.11.*"
+
+[tool.pixi.feature.tests-py311.tasks]
+tests = "pytest -n auto --cov=pint_xarray"
+
+[tool.pixi.feature.tests-py312.dependencies]
+python = "3.12.*"
+
+[tool.pixi.feature.tests-py312.tasks]
+tests = "pytest -n auto --cov=pint_xarray"
+
+[tool.pixi.feature.tests-py313.dependencies]
+python = "3.13.*"
+
+[tool.pixi.feature.tests-py313.tasks]
+doctests = "pytest --doctest-modules pint_xarray --ignore pint_xarray/tests"
+tests = "pytest -n auto --cov=pint_xarray"
+
+[tool.pixi.feature.nightly.pypi-options]
+extra-index-urls = [
+  "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple",
+]
+
+[tool.pixi.feature.nightly.pypi-dependencies]
+pint-xarray = { path = ".", editable = true }
+pint = { git = "git+https://github.com/hgrecco/pint.git" }
+xarray = "*"
+numpy = "*"
+scipy = "*"
+
+[tool.pixi.feature.nightly.dependencies]
+pytest-reportlog = ">=0.1.2"
+python = "3.13.*"
+
+[tool.pixi.feature.nightly.tasks]
+tests = "pytest -n auto --cov=pint_xarray --report-log=tests.jsonl"
+
+[tool.pixi.feature.docs.dependencies]
+sphinx = "*"
+sphinx-rtd-theme = ">=1.0"
+sphinx-autosummary-accessors = "*"
+nbsphinx = "*"
+cf-xarray = ">=0.10"
+pooch = "*"
+netcdf4 = "*"
+ipython = "*"
+ipykernel = "*"
+jupyter_client = "*"
+matplotlib-base = "*"
+sphinx-autobuild = "*"
+python = "3.13.*"
+
+[tool.pixi.feature.docs.tasks]
+build-docs = { cmd = "rm -rf generated/; python -m sphinx -b html -w warnings.log -W -Tn -j auto . _build", cwd = "docs" }
+autobuild-docs = { cmd = "sphinx-autobuild -b html -w warnings.log -W -Tn -j auto . _build", cwd = "docs" }
+build-docs-rtd = { cmd = "python -m sphinx -b html -W -T -j auto . $READTHEDOCS_OUTPUT/html", cwd = "docs" }
+
+[tool.pixi.feature.dev.dependencies]
+ipython = "*"
+ipdb = "*"
+python = "3.13.*"
+pooch = ">=1.8.2,<2"
+netcdf4 = ">=1.7.2,<2"
+
+[tool.pixi.environments]
+tests = ["optional-deps", "tests"]
+nightly = { features = ["tests", "nightly"], no-default-feature = true }
+docs = ["docs"]
+tests-py311 = ["optional-deps", "tests", "tests-py311"]
+tests-py312 = ["optional-deps", "tests", "tests-py312"]
+tests-py313 = ["optional-deps", "tests", "tests-py313"]
+doctests = ["optional-deps", "tests", "tests-py313"]
+dev = ["optional-deps", "tests", "dev"]



View it on GitLab: https://salsa.debian.org/debian-gis-team/pint-xarray/-/commit/5cd6b88255a5e0c9dbb148fa9a8c073ca984d6b8

-- 
View it on GitLab: https://salsa.debian.org/debian-gis-team/pint-xarray/-/commit/5cd6b88255a5e0c9dbb148fa9a8c073ca984d6b8
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20250903/172d7485/attachment-0001.htm>


More information about the Pkg-grass-devel mailing list