diff --git a/gemd/__init__.py b/gemd/__init__.py index b8ae0b5..e70f91d 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 b19ee4b..ba51ced 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 0000000..870d260 --- /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 0000000..06eb55d --- /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, + )