From 47a05e0477452f527cf8ddeb9cb1d7978c5c27e0 Mon Sep 17 00:00:00 2001 From: Gregory Mulholland Date: Wed, 25 Mar 2026 13:23:58 -0700 Subject: [PATCH] Add custom exception hierarchy for improved error handling Introduce GemdError base class and specific subclasses (GemdValueError, GemdTypeError, GemdBoundsError, etc.) that inherit from both the gemd base and the corresponding Python built-in types for backward compatibility. Bump version to 2.2.2. Co-Authored-By: Claude Opus 4.6 (1M context) --- gemd/__init__.py | 8 ++- gemd/__version__.py | 2 +- gemd/exceptions.py | 105 +++++++++++++++++++++++++++++++++++++++ tests/test_exceptions.py | 84 +++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 gemd/exceptions.py create mode 100644 tests/test_exceptions.py diff --git a/gemd/__init__.py b/gemd/__init__.py index b8ae0b5a..e70f91d9 100644 --- a/gemd/__init__.py +++ b/gemd/__init__.py @@ -13,6 +13,9 @@ EmpiricalFormula, NominalComposition, InChI, Smiles, \ LinkByUID, \ FileLink # noqa: F401 +from .exceptions import GemdError, GemdValueError, GemdTypeError, \ + GemdBoundsError, GemdValidationError, GemdEnumerationError, \ + GemdSerializationError # noqa: F401 __all__ = ["Condition", "Parameter", "Property", "PropertyAndConditions", "CategoricalBounds", "CompositionBounds", "IntegerBounds", @@ -26,5 +29,8 @@ "UniformInteger", "DiscreteCategorical", "NominalCategorical", "EmpiricalFormula", "NominalComposition", "InChI", "Smiles", "LinkByUID", - "FileLink" + "FileLink", + "GemdError", "GemdValueError", "GemdTypeError", + "GemdBoundsError", "GemdValidationError", + "GemdEnumerationError", "GemdSerializationError", ] diff --git a/gemd/__version__.py b/gemd/__version__.py index b19ee4b7..ba51cedf 100644 --- a/gemd/__version__.py +++ b/gemd/__version__.py @@ -1 +1 @@ -__version__ = "2.2.1" +__version__ = "2.2.2" diff --git a/gemd/exceptions.py b/gemd/exceptions.py new file mode 100644 index 00000000..870d260d --- /dev/null +++ b/gemd/exceptions.py @@ -0,0 +1,105 @@ +"""Custom exception hierarchy for gemd. + +All exceptions inherit from both a gemd base class and the corresponding +Python built-in, so existing ``except ValueError`` code continues to work. +""" +from pint.errors import DimensionalityError, UndefinedUnitError + +__all__ = [ + "GemdError", + "GemdValueError", + "GemdBoundsError", + "GemdValidationError", + "GemdEnumerationError", + "GemdSerializationError", + "GemdTypeError", + "GemdUnitError", + "GemdIncompatibleUnitsError", + "GemdUndefinedUnitError", + "GemdKeyError", +] + + +class GemdError(Exception): + """Base exception for all gemd errors.""" + + def __init__(self, message, *, context=None, guidance=None): + self.context = context + self.guidance = guidance + super().__init__(message) + + +class GemdValueError(GemdError, ValueError): + """A value is invalid in the gemd context.""" + + def __init__(self, message, *, expected=None, received=None, + context=None, guidance=None): + self.expected = expected + self.received = received + super().__init__(message, context=context, guidance=guidance) + + +class GemdBoundsError(GemdValueError): + """A value is outside the permitted bounds.""" + + def __init__(self, message, *, bounds=None, value=None, + context=None, guidance=None): + self.bounds = bounds + self.value = value + super().__init__( + message, expected=str(bounds), received=str(value), + context=context, guidance=guidance + ) + + +class GemdValidationError(GemdValueError): + """A value is inconsistent with its template or schema.""" + + +class GemdEnumerationError(GemdValueError): + """An invalid enumeration choice was provided.""" + + +class GemdSerializationError(GemdValueError): + """A serialization or deserialization error occurred.""" + + +class GemdTypeError(GemdError, TypeError): + """A wrong argument type was provided.""" + + def __init__(self, message, *, expected_type=None, + received_type=None, context=None, guidance=None): + self.expected_type = expected_type + self.received_type = received_type + super().__init__(message, context=context, guidance=guidance) + + +class GemdUnitError(GemdError): + """Base for unit-related errors.""" + + +class GemdIncompatibleUnitsError(GemdUnitError, DimensionalityError): + """Units cannot be converted between each other.""" + + def __init__(self, message, *, context=None, guidance=None): + self.context = context + self.guidance = guidance + Exception.__init__(self, message) + + +class GemdUndefinedUnitError(GemdUnitError, UndefinedUnitError): + """A unit string is not recognized.""" + + def __init__(self, message, *, context=None, guidance=None): + self.context = context + self.guidance = guidance + Exception.__init__(self, message) + + +class GemdKeyError(GemdError, KeyError): + """A key conflict in a case-insensitive dictionary.""" + + def __init__(self, message, *, context=None, guidance=None): + self.context = context + self.guidance = guidance + Exception.__init__(self, message) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 00000000..06eb55d5 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,84 @@ +"""Tests for the custom exception hierarchy.""" +import pytest + +from gemd.exceptions import ( + GemdError, GemdValueError, GemdBoundsError, GemdValidationError, + GemdEnumerationError, GemdSerializationError, GemdTypeError, + GemdUnitError, GemdIncompatibleUnitsError, GemdUndefinedUnitError, + GemdKeyError, +) + + +def test_hierarchy_isinstance(): + """Every leaf exception is an instance of GemdError.""" + for cls in [GemdValueError, GemdBoundsError, GemdValidationError, + GemdEnumerationError, GemdSerializationError, + GemdTypeError, GemdUnitError, + GemdIncompatibleUnitsError, GemdUndefinedUnitError, + GemdKeyError]: + exc = cls("test") + assert isinstance(exc, GemdError) + + +def test_backward_compat_value_error(): + """Verify GemdValueError is caught by except ValueError.""" + with pytest.raises(ValueError): + raise GemdValueError("bad value") + + +def test_backward_compat_type_error(): + """Verify GemdTypeError is caught by except TypeError.""" + with pytest.raises(TypeError): + raise GemdTypeError("bad type") + + +def test_backward_compat_key_error(): + """Verify GemdKeyError is caught by except KeyError.""" + with pytest.raises(KeyError): + raise GemdKeyError("bad key") + + +def test_bounds_error_is_value_error(): + """Verify GemdBoundsError is caught by except ValueError.""" + with pytest.raises(ValueError): + raise GemdBoundsError("out of range") + + +def test_structured_attributes_value_error(): + """Verify GemdValueError stores structured data.""" + exc = GemdValueError( + "bad", expected="int", received="str", + context="field x", guidance="pass an int" + ) + assert exc.expected == "int" + assert exc.received == "str" + assert exc.context == "field x" + assert exc.guidance == "pass an int" + assert str(exc) == "bad" + + +def test_structured_attributes_type_error(): + """Verify GemdTypeError stores structured data.""" + exc = GemdTypeError( + "wrong type", expected_type=int, received_type=str + ) + assert exc.expected_type is int + assert exc.received_type is str + + +def test_structured_attributes_bounds_error(): + """Verify GemdBoundsError stores bounds and value.""" + exc = GemdBoundsError( + "out of range", bounds="[0, 100]", value="200" + ) + assert exc.bounds == "[0, 100]" + assert exc.value == "200" + + +def test_top_level_imports(): + """Verify exception classes are importable from the top-level package.""" + from gemd import ( # noqa: F401 + GemdError, GemdValueError, GemdTypeError, + GemdBoundsError, GemdValidationError, + GemdEnumerationError, GemdSerializationError, + )