diff --git a/gemd/entity/bounds/integer_bounds.py b/gemd/entity/bounds/integer_bounds.py index 7c8f0ef3..212fffda 100644 --- a/gemd/entity/bounds/integer_bounds.py +++ b/gemd/entity/bounds/integer_bounds.py @@ -1,34 +1,51 @@ """Bounds an integer to be between two values.""" +from math import isfinite +from typing import Union + from gemd.entity.bounds.base_bounds import BaseBounds -from typing import Union +__all__ = ["IntegerBounds"] class IntegerBounds(BaseBounds, typ="integer_bounds"): - """ - Bounded subset of the integers, parameterized by a lower and upper bound. + """Bounded subset of the integers, parameterized by a lower and upper bound.""" - Parameters - ---------- - lower_bound: int - Lower endpoint. - upper_bound: int - Upper endpoint. + def __init__(self, lower_bound: int, upper_bound: int): + self._lower_bound = None + self._upper_bound = None - """ - - def __init__(self, lower_bound=None, upper_bound=None): self.lower_bound = lower_bound self.upper_bound = upper_bound - if self.lower_bound is None or abs(self.lower_bound) >= float("inf"): - raise ValueError("Lower bound must be given and finite: {}".format(self.lower_bound)) - - if self.upper_bound is None or abs(self.upper_bound) >= float("inf"): - raise ValueError("Upper bound must be given and finite") - - if self.upper_bound < self.lower_bound: - raise ValueError("Upper bound must be greater than or equal to lower bound") + @property + def lower_bound(self) -> int: + """The lower endpoint of the permitted range.""" + return self._lower_bound + + @lower_bound.setter + def lower_bound(self, value: int): + """Set the lower endpoint of the permitted range.""" + if value is None or not isfinite(value) or int(value) != float(value): + raise ValueError(f"Lower bound must be given, integer and finite: {value}") + if self.upper_bound is not None and value > self.upper_bound: + raise ValueError(f"Upper bound ({self.upper_bound}) must be " + f"greater than or equal to lower bound ({value})") + self._lower_bound = int(value) + + @property + def upper_bound(self) -> int: + """The upper endpoint of the permitted range.""" + return self._upper_bound + + @upper_bound.setter + def upper_bound(self, value: int): + """Set the upper endpoint of the permitted range.""" + if value is None or not isfinite(value) or int(value) != float(value): + raise ValueError(f"Upper bound must be given, integer and finite: {value}") + if self.lower_bound is not None and value < self.lower_bound: + raise ValueError(f"Upper bound ({value}) must be " + f"greater than or equal to lower bound ({self.lower_bound})") + self._upper_bound = int(value) def contains(self, bounds: Union[BaseBounds, "BaseValue"]) -> bool: # noqa: F821 """ diff --git a/gemd/entity/bounds/real_bounds.py b/gemd/entity/bounds/real_bounds.py index c49c9455..786888e8 100644 --- a/gemd/entity/bounds/real_bounds.py +++ b/gemd/entity/bounds/real_bounds.py @@ -1,53 +1,70 @@ """Bound a real number to be between two values.""" +from math import isfinite +from typing import Union + from gemd.entity.bounds.base_bounds import BaseBounds import gemd.units as units -from typing import Union - class RealBounds(BaseBounds, typ="real_bounds"): - """ - Bounded subset of the real numbers, parameterized by a lower and upper bound. - - Parameters - ---------- - lower_bound: float - Lower endpoint. - upper_bound: float - Upper endpoint. - default_units: str - A string describing the units. Units must be present and parseable by Pint. - An empty string can be used for the units of a dimensionless quantity. + """Bounded subset of the real numbers, parameterized by a lower and upper bound.""" - """ + def __init__(self, lower_bound: float, upper_bound: float, default_units: str): + self._default_units = None + self._lower_bound = None + self._upper_bound = None - def __init__(self, lower_bound=None, upper_bound=None, default_units=None): + self.default_units = default_units self.lower_bound = lower_bound self.upper_bound = upper_bound - self._default_units = None - self.default_units = default_units - - if self.lower_bound is None or abs(self.lower_bound) >= float("inf"): - raise ValueError("Lower bound must be given and finite: {}".format(self.lower_bound)) - - if self.upper_bound is None or abs(self.upper_bound) >= float("inf"): - raise ValueError("Upper bound must be given and finite") + @property + def lower_bound(self) -> float: + """The lower endpoint of the permitted range.""" + return self._lower_bound + + @lower_bound.setter + def lower_bound(self, value: float): + """Set the lower endpoint of the permitted range.""" + if value is None or not isfinite(value): + raise ValueError(f"Lower bound must be given and finite: {value}") + if self.upper_bound is not None and value > self.upper_bound: + raise ValueError(f"Upper bound ({self.upper_bound}) must be " + f"greater than or equal to lower bound ({value})") + self._lower_bound = float(value) - if self.upper_bound < self.lower_bound: - raise ValueError("Upper bound must be greater than or equal to lower bound") + @property + def upper_bound(self) -> float: + """The upper endpoint of the permitted range.""" + return self._upper_bound + + @upper_bound.setter + def upper_bound(self, value: float): + """Set the upper endpoint of the permitted range.""" + if value is None or not isfinite(value): + raise ValueError(f"Upper bound must be given and finite: {value}") + if self.lower_bound is not None and value < self.lower_bound: + raise ValueError(f"Upper bound ({value}) must be " + f"greater than or equal to lower bound ({self.lower_bound})") + self._upper_bound = float(value) @property - def default_units(self): - """Get default units.""" + def default_units(self) -> str: + """ + A string describing the units. + + Units must be present and parseable by Pint. + An empty string can be used for the units of a dimensionless quantity. + """ return self._default_units @default_units.setter - def default_units(self, default_units): + def default_units(self, default_units: str): + """Set the string describing the units.""" if default_units is None: raise ValueError("Real bounds must have units. " "Use an empty string for a dimensionless quantity.") - self._default_units = units.parse_units(default_units) + self._default_units = units.parse_units(default_units, return_unit=False) def contains(self, bounds: Union[BaseBounds, "BaseValue"]) -> bool: # noqa: F821 """ diff --git a/setup.py b/setup.py index 02e94a51..afbb3375 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ packages.append("") setup(name='gemd', - version='1.17.1', + version='1.18.0', python_requires='>=3.8', url='http://github.com/CitrineInformatics/gemd-python', description="Python binding for Citrine's GEMD data model", diff --git a/tests/entity/bounds/test_integer_bounds.py b/tests/entity/bounds/test_integer_bounds.py index a9b16477..5f4e6f24 100644 --- a/tests/entity/bounds/test_integer_bounds.py +++ b/tests/entity/bounds/test_integer_bounds.py @@ -8,15 +8,42 @@ def test_errors(): """Make sure invalid bounds raise value errors.""" - with pytest.raises(ValueError): + with pytest.raises(TypeError): IntegerBounds() + with pytest.raises(TypeError): + IntegerBounds("0", 10) + + with pytest.raises(ValueError): + IntegerBounds(float("-inf"), 0) + + with pytest.raises(ValueError): + IntegerBounds(0.5, 1) + with pytest.raises(ValueError): IntegerBounds(0, float("inf")) + with pytest.raises(ValueError): + IntegerBounds(0, 0.5) + + with pytest.raises(TypeError): + IntegerBounds(0, "10") + with pytest.raises(ValueError): IntegerBounds(10, 1) + with pytest.raises(ValueError): + bnd = IntegerBounds(0, 1) + bnd.lower_bound = 10 + + with pytest.raises(ValueError): + bnd = IntegerBounds(0, 1) + bnd.upper_bound = -1 + + bnd = IntegerBounds(0, 1) + assert bnd.lower_bound == 0 + assert bnd.upper_bound == 1 + def test_incompatible_types(): """Make sure that incompatible types aren't contained or validated.""" diff --git a/tests/entity/bounds/test_real_bounds.py b/tests/entity/bounds/test_real_bounds.py index d45bff34..cd34ebea 100644 --- a/tests/entity/bounds/test_real_bounds.py +++ b/tests/entity/bounds/test_real_bounds.py @@ -57,20 +57,28 @@ def test_contains_incompatible_units(): def test_constructor_error(): """Test that invalid real bounds cannot be constructed.""" - with pytest.raises(ValueError): + with pytest.raises(TypeError): RealBounds() with pytest.raises(ValueError): - RealBounds(0, float("inf"), "meter") + RealBounds(lower_bound=0, upper_bound=float("inf"), default_units="meter") with pytest.raises(ValueError): - RealBounds(None, 10, '') + RealBounds(lower_bound=None, upper_bound=10, default_units='') with pytest.raises(ValueError): - RealBounds(0, 100) + RealBounds(lower_bound=0, upper_bound=100, default_units=None) with pytest.raises(ValueError): - RealBounds(100, 0, "m") + RealBounds(lower_bound=100, upper_bound=0, default_units="m") + + with pytest.raises(ValueError): + bnd = RealBounds(lower_bound=0, upper_bound=10, default_units="m") + bnd.lower_bound = 100 + + bnd = RealBounds(0, 1, "m") + assert bnd.lower_bound == 0.0 + assert bnd.upper_bound == 1.0 def test_type_mismatch():