Skip to content

Commit 23aa1c1

Browse files
authored
raise PintExceptionGroup containers instead (#329)
* change the error formatting to return a exception group * rename the exception group * more renaming, and reset the group traceback * expect the group in conversion tests * make the group a subclass of `ValueError` for backwards compat * add `cytoolz` to the test deps * expect the exception group in `test_convert_units` * adapt the indexer conversion tests * also expect the exception group in the accessor code * ignore test files in coverage * install the optional deps as a separate feature * bump the lockfile * document the exception group * changelog * docstring for the exception
1 parent 9c480cb commit 23aa1c1

File tree

9 files changed

+7394
-1316
lines changed

9 files changed

+7394
-1316
lines changed

docs/api.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ Wrapping quantity-unaware functions
7272

7373
pint_xarray.expects
7474

75+
Exceptions
76+
----------
77+
.. autosummary::
78+
:toctree: generated/
79+
80+
pint_xarray.errors.PintExceptionGroup
81+
7582
Testing
7683
-------
7784

docs/whats-new.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ What's new
2424
By `Justus Magin <https://github.com/keewis>`_.
2525
- Add units to the inline ``repr`` and define a custom ``repr`` (:issue:`308`, :pull:`325`)
2626
By `Justus Magin <https://github.com/keewis>`_.
27+
- Collect multiple errors into a specific exception group (:pull:`329`)
28+
By `Justus Magin <https://github.com/keewis>`_.
2729

2830
0.5.1 (10 Aug 2025)
2931
-------------------

pint_xarray/accessors.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from pint_xarray import conversion
1010
from pint_xarray.conversion import no_unit_values
11-
from pint_xarray.errors import format_error_message
11+
from pint_xarray.errors import create_exception_group
1212

1313
_default = object()
1414

@@ -355,7 +355,7 @@ def quantify(self, units=_default, unit_registry=None, **unit_kwargs):
355355
invalid_units[name] = (reported_unit, type, e)
356356

357357
if invalid_units:
358-
raise ValueError(format_error_message(invalid_units, "parse"))
358+
raise create_exception_group(invalid_units, "parse")
359359

360360
existing_units = {
361361
name: unit
@@ -380,7 +380,7 @@ def quantify(self, units=_default, unit_registry=None, **unit_kwargs):
380380
)
381381
for name, (old, new) in overwritten_units.items()
382382
}
383-
raise ValueError(format_error_message(errors, "attach"))
383+
raise create_exception_group(errors, "attach")
384384

385385
return self.da.pipe(conversion.strip_unit_attributes).pipe(
386386
conversion.attach_units, new_units
@@ -1091,7 +1091,7 @@ def quantify(self, units=_default, unit_registry=None, **unit_kwargs):
10911091
invalid_units[name] = (reported_unit, type, e)
10921092

10931093
if invalid_units:
1094-
raise ValueError(format_error_message(invalid_units, "parse"))
1094+
raise create_exception_group(invalid_units, "parse")
10951095

10961096
existing_units = {
10971097
name: unit
@@ -1116,7 +1116,7 @@ def quantify(self, units=_default, unit_registry=None, **unit_kwargs):
11161116
)
11171117
for name, (old, new) in overwritten_units.items()
11181118
}
1119-
raise ValueError(format_error_message(errors, "attach"))
1119+
raise create_exception_group(errors, "attach")
11201120

11211121
return self.ds.pipe(conversion.strip_unit_attributes).pipe(
11221122
conversion.attach_units, new_units

pint_xarray/conversion.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from xarray import Coordinates, DataArray, Dataset, IndexVariable, Variable
66

77
from pint_xarray.compat import call_on_dataset
8-
from pint_xarray.errors import format_error_message
8+
from pint_xarray.errors import create_exception_group
99
from pint_xarray.index import PintIndex
1010

1111
no_unit_values = ("none", None)
@@ -198,7 +198,7 @@ def attach_units(obj, units):
198198
if temporary_name in rejected_vars:
199199
rejected_vars[obj.name] = rejected_vars.pop(temporary_name)
200200

201-
raise ValueError(format_error_message(rejected_vars, "attach")) from e
201+
raise create_exception_group(rejected_vars, "attach") from None
202202

203203
return new_obj
204204

@@ -328,7 +328,7 @@ def convert_units(obj, units):
328328
if temporary_name in failed:
329329
failed[obj.name] = failed.pop(temporary_name)
330330

331-
raise ValueError(format_error_message(failed, "convert")) from e
331+
raise create_exception_group(failed, "convert") from None
332332

333333
return new_obj
334334

@@ -482,7 +482,7 @@ def convert(indexer, units):
482482
invalid[name] = e
483483

484484
if invalid:
485-
raise ValueError(format_error_message(invalid, "convert_indexers"))
485+
raise create_exception_group(invalid, "convert_indexers")
486486

487487
return converted
488488

pint_xarray/errors.py

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,49 @@
1-
def format_error_message(mapping, op):
2-
sep = "\n " if len(mapping) == 1 else "\n -- "
3-
if op == "attach":
4-
message = "Cannot attach units:"
5-
message = sep.join(
6-
[message]
7-
+ [
8-
f"cannot attach units to variable {key!r}: {unit} (reason: {str(e)})"
1+
from collections.abc import Hashable
2+
from typing import Any
3+
4+
5+
class PintExceptionGroup(ExceptionGroup, ValueError):
6+
"""Exception group for errors related to unit operations
7+
8+
Raised whenever there's the possibility of multiple errors.
9+
"""
10+
11+
pass
12+
13+
14+
def _add_note(e: Exception, note: str) -> Exception:
15+
e.add_note(note)
16+
17+
return e
18+
19+
20+
def create_exception_group(mapping: dict[Hashable, Any], op: str) -> ExceptionGroup:
21+
match op:
22+
case "attach":
23+
message = "Cannot attach units"
24+
errors = [
25+
_add_note(e, f"cannot attach units to variable {key!r}: {unit}")
926
for key, (unit, e) in mapping.items()
1027
]
11-
)
12-
elif op == "parse":
13-
message = "Cannot parse units:"
14-
message = sep.join(
15-
[message]
16-
+ [
17-
f"invalid units for variable {key!r}: {unit} ({type}) (reason: {str(e)})"
28+
case "parse":
29+
message = "Cannot parse units"
30+
errors = [
31+
_add_note(e, f"invalid units for variable {key!r}: {unit} ({type})")
1832
for key, (unit, type, e) in mapping.items()
1933
]
20-
)
21-
elif op == "convert":
22-
message = "Cannot convert variables:"
23-
message = sep.join(
24-
[message]
25-
+ [
26-
f"incompatible units for variable {key!r}: {error}"
27-
for key, error in mapping.items()
34+
case "convert":
35+
message = "Cannot convert variables"
36+
errors = [
37+
_add_note(e, f"incompatible units for variable {key!r}")
38+
for key, e in mapping.items()
2839
]
29-
)
30-
elif op == "convert_indexers":
31-
message = "Cannot convert indexers:"
32-
message = sep.join(
33-
[message]
34-
+ [
35-
f"incompatible units for indexer for {key!r}: {error}"
36-
for key, error in mapping.items()
40+
case "convert_indexers":
41+
message = "Cannot convert indexers"
42+
errors = [
43+
_add_note(e, f"incompatible units for indexer for {key!r}")
44+
for key, e in mapping.items()
3745
]
38-
)
39-
else:
40-
raise ValueError("invalid op")
46+
case _: # pragma: no cover
47+
raise ValueError("invalid op")
4148

42-
return message
49+
return PintExceptionGroup(message, errors)

pint_xarray/tests/test_accessors.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pint import Unit, UnitRegistry
88

99
from pint_xarray import accessors, conversion
10+
from pint_xarray.errors import PintExceptionGroup
1011
from pint_xarray.index import PintIndex
1112
from pint_xarray.tests.utils import (
1213
assert_equal,
@@ -114,7 +115,11 @@ def test_override_units(self, example_unitless_da, no_unit_value):
114115

115116
def test_error_when_changing_units(self, example_quantity_da):
116117
da = example_quantity_da
117-
with pytest.raises(ValueError, match="already has units"):
118+
with pytest.RaisesGroup(
119+
pytest.RaisesExc(ValueError, match="already has units"),
120+
match="Cannot attach units",
121+
check=lambda eg: isinstance(eg, PintExceptionGroup),
122+
):
118123
da.pint.quantify("s")
119124

120125
def test_attach_no_units(self):
@@ -141,7 +146,11 @@ def test_error_when_changing_units_dimension_coordinates(self):
141146
dims="x",
142147
coords={"x": ("x", [-1, 0, 1], {"units": unit_registry.Unit("m")})},
143148
)
144-
with pytest.raises(ValueError, match="already has units"):
149+
with pytest.RaisesGroup(
150+
pytest.RaisesExc(ValueError, match="already has units"),
151+
match="Cannot attach units",
152+
check=lambda eg: isinstance(eg, PintExceptionGroup),
153+
):
145154
arr.pint.quantify({"x": "s"})
146155

147156
def test_dimension_coordinate_array(self):
@@ -157,7 +166,11 @@ def test_dimension_coordinate_array_already_quantified(self):
157166
ds = xr.Dataset(coords={"x": ("x", [10], {"units": unit_registry.Unit("m")})})
158167
arr = ds.x
159168

160-
with pytest.raises(ValueError):
169+
with pytest.RaisesGroup(
170+
pytest.RaisesExc(ValueError, match="already has units"),
171+
match="Cannot attach units",
172+
check=lambda eg: isinstance(eg, PintExceptionGroup),
173+
):
161174
arr.pint.quantify({"x": "s"})
162175

163176
def test_dimension_coordinate_array_already_quantified_same_units(self):
@@ -181,14 +194,24 @@ def test_dimension_coordinate_array_already_quantified_same_units(self):
181194

182195
def test_error_on_nonsense_units(self, example_unitless_da):
183196
da = example_unitless_da
184-
with pytest.raises(ValueError, match=str(da.name)):
197+
with pytest.RaisesGroup(
198+
pytest.RaisesExc(
199+
pint.UndefinedUnitError, match=rf"{da.name}: .+ \(parameter\)"
200+
),
201+
match="Cannot parse units",
202+
check=lambda eg: isinstance(eg, PintExceptionGroup),
203+
):
185204
da.pint.quantify(units="aecjhbav")
186205

187206
def test_error_on_nonsense_units_attrs(self, example_unitless_da):
188207
da = example_unitless_da
189208
da.attrs["units"] = "aecjhbav"
190-
with pytest.raises(
191-
ValueError, match=rf"{da.name}: {da.attrs['units']} \(attribute\)"
209+
with pytest.RaisesGroup(
210+
pytest.RaisesExc(
211+
pint.UndefinedUnitError, match=rf"{da.name}: .+ \(attribute\)"
212+
),
213+
match="Cannot parse units",
214+
check=lambda eg: isinstance(eg, PintExceptionGroup),
192215
):
193216
da.pint.quantify()
194217

@@ -355,7 +378,11 @@ def test_override_units(self, example_unitless_ds, no_unit_value):
355378
)
356379

357380
def test_error_when_already_units(self, example_quantity_ds):
358-
with pytest.raises(ValueError, match="already has units"):
381+
with pytest.RaisesGroup(
382+
pytest.RaisesExc(ValueError, match="already has units"),
383+
match="Cannot attach units",
384+
check=lambda eg: isinstance(eg, PintExceptionGroup),
385+
):
359386
example_quantity_ds.pint.quantify({"funds": "kg"})
360387

361388
def test_attach_no_units(self):
@@ -382,25 +409,45 @@ def test_error_when_changing_units_dimension_coordinates(self):
382409
ds = xr.Dataset(
383410
coords={"x": ("x", [-1, 0, 1], {"units": unit_registry.Unit("m")})},
384411
)
385-
with pytest.raises(ValueError, match="already has units"):
412+
with pytest.RaisesGroup(
413+
pytest.RaisesExc(ValueError, match="already has units"),
414+
match="Cannot attach units",
415+
check=lambda eg: isinstance(eg, PintExceptionGroup),
416+
):
386417
ds.pint.quantify({"x": "s"})
387418

388419
def test_error_on_nonsense_units(self, example_unitless_ds):
389420
ds = example_unitless_ds
390-
with pytest.raises(ValueError):
421+
with pytest.RaisesGroup(
422+
pytest.RaisesExc(
423+
pint.UndefinedUnitError, match=r"'users': .+ \(parameter\)"
424+
),
425+
match="Cannot parse units",
426+
check=lambda eg: isinstance(eg, PintExceptionGroup),
427+
):
391428
ds.pint.quantify(units={"users": "aecjhbav"})
392429

393430
def test_error_on_nonsense_units_attrs(self, example_unitless_ds):
394431
ds = example_unitless_ds
395432
ds.users.attrs["units"] = "aecjhbav"
396-
with pytest.raises(
397-
ValueError, match=rf"'users': {ds.users.attrs['units']} \(attribute\)"
433+
with pytest.RaisesGroup(
434+
pytest.RaisesExc(
435+
pint.UndefinedUnitError, match=r"'users': .+ \(attribute\)"
436+
),
437+
match="Cannot parse units",
438+
check=lambda eg: isinstance(eg, PintExceptionGroup),
398439
):
399440
ds.pint.quantify()
400441

401442
def test_error_indicates_problematic_variable(self, example_unitless_ds):
402443
ds = example_unitless_ds
403-
with pytest.raises(ValueError, match="'users'"):
444+
with pytest.RaisesGroup(
445+
pytest.RaisesExc(
446+
pint.UndefinedUnitError, match=r"'users': aecjhbav \(parameter\)"
447+
),
448+
match="Cannot parse units",
449+
check=lambda eg: isinstance(eg, PintExceptionGroup),
450+
):
404451
ds.pint.quantify(units={"users": "aecjhbav"})
405452

406453
def test_existing_units(self, example_quantity_ds):

0 commit comments

Comments
 (0)