Skip to content

Commit 77a4f3f

Browse files
authored
feat: errors.py (#1071)
1 parent 42d1a7b commit 77a4f3f

4 files changed

Lines changed: 225 additions & 47 deletions

File tree

src/packaging/errors.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import dataclasses
5+
import sys
6+
import typing
7+
8+
__all__ = ["ExceptionGroup"]
9+
10+
11+
def __dir__() -> list[str]:
12+
return __all__
13+
14+
15+
if sys.version_info >= (3, 11): # pragma: no cover
16+
from builtins import ExceptionGroup
17+
else: # pragma: no cover
18+
19+
class ExceptionGroup(Exception):
20+
"""A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11.
21+
22+
If :external:exc:`ExceptionGroup` is already defined by Python itself,
23+
that version is used instead.
24+
"""
25+
26+
message: str
27+
exceptions: list[Exception]
28+
29+
def __init__(self, message: str, exceptions: list[Exception]) -> None:
30+
self.message = message
31+
self.exceptions = exceptions
32+
33+
def __repr__(self) -> str:
34+
return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})"
35+
36+
37+
@dataclasses.dataclass
38+
class _ErrorCollector:
39+
"""
40+
Collect errors into ExceptionGroups.
41+
42+
Used like this:
43+
44+
collector = _ErrorCollector()
45+
# Add a single exception
46+
collector.error(ValueError("one"))
47+
48+
# Supports nesting, including combining ExceptionGroups
49+
with collector.collect():
50+
raise ValueError("two")
51+
collector.finalize("Found some errors")
52+
53+
Since making a collector and then calling finalize later is a common pattern,
54+
a convenience method ``on_exit`` is provided.
55+
"""
56+
57+
errors: list[Exception] = dataclasses.field(default_factory=list, init=False)
58+
59+
def finalize(self, msg: str) -> None:
60+
"""Raise a group exception if there are any errors."""
61+
if self.errors:
62+
raise ExceptionGroup(msg, self.errors)
63+
64+
@contextlib.contextmanager
65+
def on_exit(self, msg: str) -> typing.Generator[_ErrorCollector, None, None]:
66+
"""
67+
Calls finalize if no uncollected errors were present.
68+
69+
Uncollected errors are raised normally.
70+
"""
71+
yield self
72+
self.finalize(msg)
73+
74+
@contextlib.contextmanager
75+
def collect(self, *err_cls: type[Exception]) -> typing.Generator[None, None, None]:
76+
"""
77+
Context manager to collect errors into the error list.
78+
79+
Must be inside loops, as only one error can be collected at a time.
80+
"""
81+
error_classes = err_cls or (Exception,)
82+
try:
83+
yield
84+
except ExceptionGroup as error:
85+
self.errors.extend(error.exceptions)
86+
except error_classes as error:
87+
self.errors.append(error)
88+
89+
def error(
90+
self,
91+
error: Exception,
92+
) -> None:
93+
"""Add an error to the list."""
94+
self.errors.append(error)

src/packaging/metadata.py

Lines changed: 15 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import email.policy
77
import keyword
88
import pathlib
9-
import sys
109
import typing
1110
from typing import (
1211
Any,
@@ -19,35 +18,14 @@
1918

2019
from . import licenses, requirements, specifiers, utils
2120
from . import version as version_module
21+
from .errors import ExceptionGroup, _ErrorCollector
2222

2323
if typing.TYPE_CHECKING:
2424
from .licenses import NormalizedLicenseExpression
2525

2626
T = typing.TypeVar("T")
2727

2828

29-
if sys.version_info >= (3, 11): # pragma: no cover
30-
ExceptionGroup = ExceptionGroup # noqa: F821
31-
else: # pragma: no cover
32-
33-
class ExceptionGroup(Exception):
34-
"""A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11.
35-
36-
If :external:exc:`ExceptionGroup` is already defined by Python itself,
37-
that version is used instead.
38-
"""
39-
40-
message: str
41-
exceptions: list[Exception]
42-
43-
def __init__(self, message: str, exceptions: list[Exception]) -> None:
44-
self.message = message
45-
self.exceptions = exceptions
46-
47-
def __repr__(self) -> str:
48-
return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})"
49-
50-
5129
__all__ = [
5230
"InvalidMetadata",
5331
"Metadata",
@@ -797,13 +775,11 @@ def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata:
797775
ins._raw = data.copy() # Mutations occur due to caching enriched values.
798776

799777
if validate:
800-
exceptions: list[Exception] = []
801-
try:
778+
collector = _ErrorCollector()
779+
metadata_version = None
780+
with collector.collect(InvalidMetadata):
802781
metadata_version = ins.metadata_version
803782
metadata_age = _VALID_METADATA_VERSIONS.index(metadata_version)
804-
except InvalidMetadata as metadata_version_exc:
805-
exceptions.append(metadata_version_exc)
806-
metadata_version = None
807783

808784
# Make sure to check for the fields that are present, the required
809785
# fields (so their absence can be reported).
@@ -820,7 +796,7 @@ def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata:
820796
field_metadata_version = cls.__dict__[key].added
821797
except KeyError:
822798
exc = InvalidMetadata(key, f"unrecognized field: {key!r}")
823-
exceptions.append(exc)
799+
collector.error(exc)
824800
continue
825801
field_age = _VALID_METADATA_VERSIONS.index(
826802
field_metadata_version
@@ -832,14 +808,13 @@ def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata:
832808
f"{field} introduced in metadata version "
833809
f"{field_metadata_version}, not {metadata_version}",
834810
)
835-
exceptions.append(exc)
811+
collector.error(exc)
836812
continue
837813
getattr(ins, key)
838814
except InvalidMetadata as exc:
839-
exceptions.append(exc)
815+
collector.error(exc)
840816

841-
if exceptions:
842-
raise ExceptionGroup("invalid metadata", exceptions)
817+
collector.finalize("invalid metadata")
843818

844819
return ins
845820

@@ -853,16 +828,13 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata:
853828
raw, unparsed = parse_email(data)
854829

855830
if validate:
856-
exceptions: list[Exception] = []
857-
for unparsed_key in unparsed:
858-
if unparsed_key in _EMAIL_TO_RAW_MAPPING:
859-
message = f"{unparsed_key!r} has invalid data"
860-
else:
861-
message = f"unrecognized field: {unparsed_key!r}"
862-
exceptions.append(InvalidMetadata(unparsed_key, message))
863-
864-
if exceptions:
865-
raise ExceptionGroup("unparsed", exceptions)
831+
with _ErrorCollector().on_exit("unparsed") as collector:
832+
for unparsed_key in unparsed:
833+
if unparsed_key in _EMAIL_TO_RAW_MAPPING:
834+
message = f"{unparsed_key!r} has invalid data"
835+
else:
836+
message = f"unrecognized field: {unparsed_key!r}"
837+
collector.error(InvalidMetadata(unparsed_key, message))
866838

867839
try:
868840
return cls.from_raw(raw, validate=validate)

tests/test_errors.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import pytest
2+
3+
import packaging.errors
4+
5+
6+
def test_error_collector_collect() -> None:
7+
collector = packaging.errors._ErrorCollector()
8+
9+
with collector.collect():
10+
raise ValueError("first error")
11+
12+
with collector.collect():
13+
raise KeyError("second error")
14+
15+
collector.error(TypeError("third error"))
16+
17+
with pytest.raises(packaging.errors.ExceptionGroup) as exc_info:
18+
collector.finalize("collected errors")
19+
20+
exception_group = exc_info.value
21+
assert exception_group.message == "collected errors"
22+
assert len(exception_group.exceptions) == 3
23+
assert isinstance(exception_group.exceptions[0], ValueError)
24+
assert str(exception_group.exceptions[0]) == "first error"
25+
assert isinstance(exception_group.exceptions[1], KeyError)
26+
assert str(exception_group.exceptions[1]) == "'second error'"
27+
assert isinstance(exception_group.exceptions[2], TypeError)
28+
assert str(exception_group.exceptions[2]) == "third error"
29+
30+
31+
def test_error_collector_no_errors() -> None:
32+
collector = packaging.errors._ErrorCollector()
33+
34+
with collector.collect():
35+
pass # No error
36+
37+
collector.finalize("no errors") # Should not raise
38+
39+
40+
def test_error_collector_exception_group() -> None:
41+
collector = packaging.errors._ErrorCollector()
42+
43+
with collector.collect():
44+
raise packaging.errors.ExceptionGroup(
45+
"inner group",
46+
[ValueError("inner error 1"), KeyError("inner error 2")],
47+
)
48+
49+
with pytest.raises(packaging.errors.ExceptionGroup) as exc_info:
50+
collector.finalize("outer group")
51+
52+
exception_group = exc_info.value
53+
assert exception_group.message == "outer group"
54+
assert len(exception_group.exceptions) == 2
55+
assert isinstance(exception_group.exceptions[0], ValueError)
56+
assert str(exception_group.exceptions[0]) == "inner error 1"
57+
assert isinstance(exception_group.exceptions[1], KeyError)
58+
assert str(exception_group.exceptions[1]) == "'inner error 2'"
59+
60+
61+
def test_error_collector_on_exit() -> None:
62+
collector = packaging.errors._ErrorCollector()
63+
64+
with pytest.raises(packaging.errors.ExceptionGroup) as exc_info, collector.on_exit(
65+
"exiting"
66+
):
67+
collector.error(ValueError("an error"))
68+
69+
exception_group = exc_info.value
70+
assert exception_group.message == "exiting"
71+
assert len(exception_group.exceptions) == 1
72+
assert isinstance(exception_group.exceptions[0], ValueError)
73+
assert str(exception_group.exceptions[0]) == "an error"
74+
75+
76+
def test_error_collector_on_exit_no_errors() -> None:
77+
collector = packaging.errors._ErrorCollector()
78+
79+
with collector.on_exit("exiting"):
80+
pass # No errors added
81+
82+
83+
def test_error_collector_collect_specific_exception() -> None:
84+
collector = packaging.errors._ErrorCollector()
85+
86+
with collector.collect(KeyError):
87+
raise KeyError("a key error")
88+
89+
with pytest.raises(packaging.errors.ExceptionGroup) as exc_info:
90+
collector.finalize("collected errors")
91+
92+
exception_group = exc_info.value
93+
assert exception_group.message == "collected errors"
94+
assert len(exception_group.exceptions) == 1
95+
assert isinstance(exception_group.exceptions[0], KeyError)
96+
assert str(exception_group.exceptions[0]) == "'a key error'"
97+
98+
99+
def test_error_collector_collect_unmatched_exception() -> None:
100+
collector = packaging.errors._ErrorCollector()
101+
102+
# Now test that other exceptions are not collected
103+
with pytest.raises(
104+
ValueError, match="a value error"
105+
) as exc_info, collector.collect(KeyError):
106+
raise ValueError("a value error")
107+
108+
assert str(exc_info.value) == "a value error"

tests/test_metadata.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
from __future__ import annotations
22

3-
import email.message
3+
import email
44
import inspect
55
import pathlib
66
import textwrap
7+
import typing
78

89
import pytest
910

1011
from packaging import metadata, requirements, specifiers, utils, version
11-
from packaging.metadata import ExceptionGroup, RawMetadata
12+
from packaging.errors import ExceptionGroup
13+
14+
if typing.TYPE_CHECKING:
15+
from packaging.metadata import RawMetadata
1216

1317

1418
class TestRawMetadata:
@@ -259,13 +263,13 @@ def test_complete(self) -> None:
259263
class TestExceptionGroup:
260264
def test_attributes(self) -> None:
261265
individual_exception = Exception("not important")
262-
exc = metadata.ExceptionGroup("message", [individual_exception])
266+
exc = ExceptionGroup("message", [individual_exception])
263267
assert exc.message == "message"
264268
assert list(exc.exceptions) == [individual_exception]
265269

266270
def test_repr(self) -> None:
267271
individual_exception = RuntimeError("not important")
268-
exc = metadata.ExceptionGroup("message", [individual_exception])
272+
exc = ExceptionGroup("message", [individual_exception])
269273
assert individual_exception.__class__.__name__ in repr(exc)
270274

271275

0 commit comments

Comments
 (0)