From 4de0927c625e1b0e235973987500f3834dd2f87e Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 23 Jan 2026 11:27:20 -0500 Subject: [PATCH 1/4] feat: add errors module for ExceptionGroups Signed-off-by: Henry Schreiner --- src/packaging/errors.py | 31 +++++++++++++++++++++++++++++++ src/packaging/metadata.py | 24 +----------------------- tests/test_metadata.py | 12 ++++++++---- 3 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 src/packaging/errors.py diff --git a/src/packaging/errors.py b/src/packaging/errors.py new file mode 100644 index 000000000..2f6500ad6 --- /dev/null +++ b/src/packaging/errors.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import sys + +__all__ = ["ExceptionGroup"] + + +def __dir__() -> list[str]: + return __all__ + + +if sys.version_info >= (3, 11): # pragma: no cover + from builtins import ExceptionGroup +else: # pragma: no cover + + class ExceptionGroup(Exception): + """A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11. + + If :external:exc:`ExceptionGroup` is already defined by Python itself, + that version is used instead. + """ + + message: str + exceptions: list[Exception] + + def __init__(self, message: str, exceptions: list[Exception]) -> None: + self.message = message + self.exceptions = exceptions + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index 4dd08f424..45d08e08a 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -6,7 +6,6 @@ import email.policy import keyword import pathlib -import sys import typing from typing import ( Any, @@ -19,6 +18,7 @@ from . import licenses, requirements, specifiers, utils from . import version as version_module +from .errors import ExceptionGroup if typing.TYPE_CHECKING: from .licenses import NormalizedLicenseExpression @@ -26,28 +26,6 @@ T = typing.TypeVar("T") -if sys.version_info >= (3, 11): # pragma: no cover - ExceptionGroup = ExceptionGroup # noqa: F821 -else: # pragma: no cover - - class ExceptionGroup(Exception): - """A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11. - - If :external:exc:`ExceptionGroup` is already defined by Python itself, - that version is used instead. - """ - - message: str - exceptions: list[Exception] - - def __init__(self, message: str, exceptions: list[Exception]) -> None: - self.message = message - self.exceptions = exceptions - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" - - __all__ = [ "InvalidMetadata", "Metadata", diff --git a/tests/test_metadata.py b/tests/test_metadata.py index db7e46eaa..0af214d21 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,14 +1,18 @@ from __future__ import annotations -import email.message +import email import inspect import pathlib import textwrap +import typing import pytest from packaging import metadata, requirements, specifiers, utils, version -from packaging.metadata import ExceptionGroup, RawMetadata +from packaging.errors import ExceptionGroup + +if typing.TYPE_CHECKING: + from packaging.metadata import RawMetadata class TestRawMetadata: @@ -259,13 +263,13 @@ def test_complete(self) -> None: class TestExceptionGroup: def test_attributes(self) -> None: individual_exception = Exception("not important") - exc = metadata.ExceptionGroup("message", [individual_exception]) + exc = ExceptionGroup("message", [individual_exception]) assert exc.message == "message" assert list(exc.exceptions) == [individual_exception] def test_repr(self) -> None: individual_exception = RuntimeError("not important") - exc = metadata.ExceptionGroup("message", [individual_exception]) + exc = ExceptionGroup("message", [individual_exception]) assert individual_exception.__class__.__name__ in repr(exc) From a7433dc7cd8bf89cb76f3fc907d4c32bf1340b74 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 23 Jan 2026 11:43:07 -0500 Subject: [PATCH 2/4] feat: add ErrorCollector Signed-off-by: Henry Schreiner --- src/packaging/errors.py | 38 ++++++++++++++++++++++++- src/packaging/metadata.py | 22 +++++++-------- tests/test_errors.py | 58 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 tests/test_errors.py diff --git a/src/packaging/errors.py b/src/packaging/errors.py index 2f6500ad6..cfd0ef5ce 100644 --- a/src/packaging/errors.py +++ b/src/packaging/errors.py @@ -1,8 +1,11 @@ from __future__ import annotations +import contextlib +import dataclasses import sys +import typing -__all__ = ["ExceptionGroup"] +__all__ = ["ErrorCollector", "ExceptionGroup"] def __dir__() -> list[str]: @@ -29,3 +32,36 @@ def __init__(self, message: str, exceptions: list[Exception]) -> None: def __repr__(self) -> str: return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" + + +@dataclasses.dataclass +class ErrorCollector: + """ + Collect errors. + """ + + errors: list[Exception] = dataclasses.field(default_factory=list, init=False) + + def finalize(self, msg: str) -> None: + """Raise a group exception if there are any errors.""" + if self.errors: + raise ExceptionGroup(msg, self.errors) + + @contextlib.contextmanager + def collect( + self, err_cls: type[Exception] = Exception + ) -> typing.Generator[None, None, None]: + """Collect errors into the error list. Must be inside loops.""" + try: + yield + except ExceptionGroup as error: + self.errors.extend(error.exceptions) + except err_cls as error: + self.errors.append(error) + + def error( + self, + error: Exception, + ) -> None: + """Add an error to the list.""" + self.errors.append(error) diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index 45d08e08a..d02963681 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -18,7 +18,7 @@ from . import licenses, requirements, specifiers, utils from . import version as version_module -from .errors import ExceptionGroup +from .errors import ErrorCollector, ExceptionGroup if typing.TYPE_CHECKING: from .licenses import NormalizedLicenseExpression @@ -775,12 +775,12 @@ def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata: ins._raw = data.copy() # Mutations occur due to caching enriched values. if validate: - exceptions: list[Exception] = [] + collector = ErrorCollector() try: metadata_version = ins.metadata_version metadata_age = _VALID_METADATA_VERSIONS.index(metadata_version) except InvalidMetadata as metadata_version_exc: - exceptions.append(metadata_version_exc) + collector.error(metadata_version_exc) metadata_version = None # Make sure to check for the fields that are present, the required @@ -798,7 +798,7 @@ def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata: field_metadata_version = cls.__dict__[key].added except KeyError: exc = InvalidMetadata(key, f"unrecognized field: {key!r}") - exceptions.append(exc) + collector.error(exc) continue field_age = _VALID_METADATA_VERSIONS.index( field_metadata_version @@ -810,14 +810,13 @@ def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata: f"{field} introduced in metadata version " f"{field_metadata_version}, not {metadata_version}", ) - exceptions.append(exc) + collector.error(exc) continue getattr(ins, key) except InvalidMetadata as exc: - exceptions.append(exc) + collector.error(exc) - if exceptions: - raise ExceptionGroup("invalid metadata", exceptions) + collector.finalize("invalid metadata") return ins @@ -831,16 +830,15 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata: raw, unparsed = parse_email(data) if validate: - exceptions: list[Exception] = [] + collector = ErrorCollector() for unparsed_key in unparsed: if unparsed_key in _EMAIL_TO_RAW_MAPPING: message = f"{unparsed_key!r} has invalid data" else: message = f"unrecognized field: {unparsed_key!r}" - exceptions.append(InvalidMetadata(unparsed_key, message)) + collector.error(InvalidMetadata(unparsed_key, message)) - if exceptions: - raise ExceptionGroup("unparsed", exceptions) + collector.finalize("unparsed") try: return cls.from_raw(raw, validate=validate) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 000000000..274d54a3f --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,58 @@ +import pytest + +import packaging.errors + + +def test_error_collector_collect() -> None: + collector = packaging.errors.ErrorCollector() + + with collector.collect(): + raise ValueError("first error") + + with collector.collect(): + raise KeyError("second error") + + collector.error(TypeError("third error")) + + with pytest.raises(packaging.errors.ExceptionGroup) as exc_info: + collector.finalize("collected errors") + + exception_group = exc_info.value + assert exception_group.message == "collected errors" + assert len(exception_group.exceptions) == 3 + assert isinstance(exception_group.exceptions[0], ValueError) + assert str(exception_group.exceptions[0]) == "first error" + assert isinstance(exception_group.exceptions[1], KeyError) + assert str(exception_group.exceptions[1]) == "'second error'" + assert isinstance(exception_group.exceptions[2], TypeError) + assert str(exception_group.exceptions[2]) == "third error" + + +def test_error_collector_no_errors() -> None: + collector = packaging.errors.ErrorCollector() + + with collector.collect(): + pass # No error + + collector.finalize("no errors") # Should not raise + + +def test_error_collector_exception_group() -> None: + collector = packaging.errors.ErrorCollector() + + with collector.collect(): + raise packaging.errors.ExceptionGroup( + "inner group", + [ValueError("inner error 1"), KeyError("inner error 2")], + ) + + with pytest.raises(packaging.errors.ExceptionGroup) as exc_info: + collector.finalize("outer group") + + exception_group = exc_info.value + assert exception_group.message == "outer group" + assert len(exception_group.exceptions) == 2 + assert isinstance(exception_group.exceptions[0], ValueError) + assert str(exception_group.exceptions[0]) == "inner error 1" + assert isinstance(exception_group.exceptions[1], KeyError) + assert str(exception_group.exceptions[1]) == "'inner error 2'" From 970c670e5d13a36205abb447f92d7a5cc2a8d449 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 23 Jan 2026 13:12:48 -0500 Subject: [PATCH 3/4] feat: add on_exit() Signed-off-by: Henry Schreiner --- src/packaging/errors.py | 39 +++++++++++++++++++++++++----- src/packaging/metadata.py | 22 +++++++---------- tests/test_errors.py | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/src/packaging/errors.py b/src/packaging/errors.py index cfd0ef5ce..6f152e8ee 100644 --- a/src/packaging/errors.py +++ b/src/packaging/errors.py @@ -37,7 +37,21 @@ def __repr__(self) -> str: @dataclasses.dataclass class ErrorCollector: """ - Collect errors. + Collect errors into ExceptionGroups. + + Used like this: + + collector = ErrorCollector() + # Add a single exception + collector.error(ValueError("one")) + + # Supports nesting, including combining ExceptionGroups + with collector.collect(): + raise ValueError("two") + collector.finalize("Found some errors") + + Since making a collector and then calling finalize later is a common pattern, + a convenience method ``on_exit`` is provided. """ errors: list[Exception] = dataclasses.field(default_factory=list, init=False) @@ -48,15 +62,28 @@ def finalize(self, msg: str) -> None: raise ExceptionGroup(msg, self.errors) @contextlib.contextmanager - def collect( - self, err_cls: type[Exception] = Exception - ) -> typing.Generator[None, None, None]: - """Collect errors into the error list. Must be inside loops.""" + def on_exit(self, msg: str) -> typing.Generator[ErrorCollector, None, None]: + """ + Calls finalize if no uncollected errors were present. + + Uncollected errors are raised normally. + """ + yield self + self.finalize(msg) + + @contextlib.contextmanager + def collect(self, *err_cls: type[Exception]) -> typing.Generator[None, None, None]: + """ + Context manager to collect errors into the error list. + + Must be inside loops, as only one error can be collected at a time. + """ + error_classes = err_cls or (Exception,) try: yield except ExceptionGroup as error: self.errors.extend(error.exceptions) - except err_cls as error: + except error_classes as error: self.errors.append(error) def error( diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index d02963681..a8f7bb4b8 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -776,12 +776,10 @@ def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata: if validate: collector = ErrorCollector() - try: + metadata_version = None + with collector.collect(InvalidMetadata): metadata_version = ins.metadata_version metadata_age = _VALID_METADATA_VERSIONS.index(metadata_version) - except InvalidMetadata as metadata_version_exc: - collector.error(metadata_version_exc) - metadata_version = None # Make sure to check for the fields that are present, the required # fields (so their absence can be reported). @@ -830,15 +828,13 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata: raw, unparsed = parse_email(data) if validate: - collector = ErrorCollector() - for unparsed_key in unparsed: - if unparsed_key in _EMAIL_TO_RAW_MAPPING: - message = f"{unparsed_key!r} has invalid data" - else: - message = f"unrecognized field: {unparsed_key!r}" - collector.error(InvalidMetadata(unparsed_key, message)) - - collector.finalize("unparsed") + with ErrorCollector().on_exit("unparsed") as collector: + for unparsed_key in unparsed: + if unparsed_key in _EMAIL_TO_RAW_MAPPING: + message = f"{unparsed_key!r} has invalid data" + else: + message = f"unrecognized field: {unparsed_key!r}" + collector.error(InvalidMetadata(unparsed_key, message)) try: return cls.from_raw(raw, validate=validate) diff --git a/tests/test_errors.py b/tests/test_errors.py index 274d54a3f..1e0e174d8 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -56,3 +56,53 @@ def test_error_collector_exception_group() -> None: assert str(exception_group.exceptions[0]) == "inner error 1" assert isinstance(exception_group.exceptions[1], KeyError) assert str(exception_group.exceptions[1]) == "'inner error 2'" + + +def test_error_collector_on_exit() -> None: + collector = packaging.errors.ErrorCollector() + + with pytest.raises(packaging.errors.ExceptionGroup) as exc_info, collector.on_exit( + "exiting" + ): + collector.error(ValueError("an error")) + + exception_group = exc_info.value + assert exception_group.message == "exiting" + assert len(exception_group.exceptions) == 1 + assert isinstance(exception_group.exceptions[0], ValueError) + assert str(exception_group.exceptions[0]) == "an error" + + +def test_error_collector_on_exit_no_errors() -> None: + collector = packaging.errors.ErrorCollector() + + with collector.on_exit("exiting"): + pass # No errors added + + +def test_error_collector_collect_specific_exception() -> None: + collector = packaging.errors.ErrorCollector() + + with collector.collect(KeyError): + raise KeyError("a key error") + + with pytest.raises(packaging.errors.ExceptionGroup) as exc_info: + collector.finalize("collected errors") + + exception_group = exc_info.value + assert exception_group.message == "collected errors" + assert len(exception_group.exceptions) == 1 + assert isinstance(exception_group.exceptions[0], KeyError) + assert str(exception_group.exceptions[0]) == "'a key error'" + + +def test_error_collector_collect_unmatched_exception() -> None: + collector = packaging.errors.ErrorCollector() + + # Now test that other exceptions are not collected + with pytest.raises( + ValueError, match="a value error" + ) as exc_info, collector.collect(KeyError): + raise ValueError("a value error") + + assert str(exc_info.value) == "a value error" From 00032624a28949d449a0b4cec1bd7d52d581de31 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 26 Jan 2026 13:09:58 -0500 Subject: [PATCH 4/4] refactor: make ErrorCollector private Signed-off-by: Henry Schreiner --- src/packaging/errors.py | 8 ++++---- src/packaging/metadata.py | 6 +++--- tests/test_errors.py | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/packaging/errors.py b/src/packaging/errors.py index 6f152e8ee..d1d47cf6c 100644 --- a/src/packaging/errors.py +++ b/src/packaging/errors.py @@ -5,7 +5,7 @@ import sys import typing -__all__ = ["ErrorCollector", "ExceptionGroup"] +__all__ = ["ExceptionGroup"] def __dir__() -> list[str]: @@ -35,13 +35,13 @@ def __repr__(self) -> str: @dataclasses.dataclass -class ErrorCollector: +class _ErrorCollector: """ Collect errors into ExceptionGroups. Used like this: - collector = ErrorCollector() + collector = _ErrorCollector() # Add a single exception collector.error(ValueError("one")) @@ -62,7 +62,7 @@ def finalize(self, msg: str) -> None: raise ExceptionGroup(msg, self.errors) @contextlib.contextmanager - def on_exit(self, msg: str) -> typing.Generator[ErrorCollector, None, None]: + def on_exit(self, msg: str) -> typing.Generator[_ErrorCollector, None, None]: """ Calls finalize if no uncollected errors were present. diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index a8f7bb4b8..5eb488bbf 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -18,7 +18,7 @@ from . import licenses, requirements, specifiers, utils from . import version as version_module -from .errors import ErrorCollector, ExceptionGroup +from .errors import ExceptionGroup, _ErrorCollector if typing.TYPE_CHECKING: from .licenses import NormalizedLicenseExpression @@ -775,7 +775,7 @@ def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata: ins._raw = data.copy() # Mutations occur due to caching enriched values. if validate: - collector = ErrorCollector() + collector = _ErrorCollector() metadata_version = None with collector.collect(InvalidMetadata): metadata_version = ins.metadata_version @@ -828,7 +828,7 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata: raw, unparsed = parse_email(data) if validate: - with ErrorCollector().on_exit("unparsed") as collector: + with _ErrorCollector().on_exit("unparsed") as collector: for unparsed_key in unparsed: if unparsed_key in _EMAIL_TO_RAW_MAPPING: message = f"{unparsed_key!r} has invalid data" diff --git a/tests/test_errors.py b/tests/test_errors.py index 1e0e174d8..d138f65df 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -4,7 +4,7 @@ def test_error_collector_collect() -> None: - collector = packaging.errors.ErrorCollector() + collector = packaging.errors._ErrorCollector() with collector.collect(): raise ValueError("first error") @@ -29,7 +29,7 @@ def test_error_collector_collect() -> None: def test_error_collector_no_errors() -> None: - collector = packaging.errors.ErrorCollector() + collector = packaging.errors._ErrorCollector() with collector.collect(): pass # No error @@ -38,7 +38,7 @@ def test_error_collector_no_errors() -> None: def test_error_collector_exception_group() -> None: - collector = packaging.errors.ErrorCollector() + collector = packaging.errors._ErrorCollector() with collector.collect(): raise packaging.errors.ExceptionGroup( @@ -59,7 +59,7 @@ def test_error_collector_exception_group() -> None: def test_error_collector_on_exit() -> None: - collector = packaging.errors.ErrorCollector() + collector = packaging.errors._ErrorCollector() with pytest.raises(packaging.errors.ExceptionGroup) as exc_info, collector.on_exit( "exiting" @@ -74,14 +74,14 @@ def test_error_collector_on_exit() -> None: def test_error_collector_on_exit_no_errors() -> None: - collector = packaging.errors.ErrorCollector() + collector = packaging.errors._ErrorCollector() with collector.on_exit("exiting"): pass # No errors added def test_error_collector_collect_specific_exception() -> None: - collector = packaging.errors.ErrorCollector() + collector = packaging.errors._ErrorCollector() with collector.collect(KeyError): raise KeyError("a key error") @@ -97,7 +97,7 @@ def test_error_collector_collect_specific_exception() -> None: def test_error_collector_collect_unmatched_exception() -> None: - collector = packaging.errors.ErrorCollector() + collector = packaging.errors._ErrorCollector() # Now test that other exceptions are not collected with pytest.raises(