[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 @@
-[](https://github.com/xarray-contrib/pint-xarray/actions?query=branch%3Amain)
+[](https://github.com/xarray-contrib/pint-xarray/actions/workflows/ci.yml)
[](https://codecov.io/gh/xarray-contrib/pint-xarray)
[](https://pint-xarray.readthedocs.io)
[](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