Source code for valguard.constraints

"""Constraints on `Value` instances.

This module defines the `Constraint` interface and its subclasses, which validate
`Value` instances according to type and semantic rules.

Overview:
    Constraints encapsulate both the expected type of a value and any additional
    semantic restrictions it must satisfy. This eliminates the need to separately
    check the type of a `Value` before validating its content.

    While `Value` subclasses ensure that their internal data is well-formed and
    strongly typed, they do not, on their own, enforce domain-specific constraints
    such as value ranges, membership in a set, or compatibility with a marking
    scheme. These concerns are handled by `Constraint` subclasses.

Validation:
    Each subclass of `Constraint` validates an object starting with a type check, to
    ensure the object is an instance of a specific `Value` subclass, and then applies
    additional rules such as bounding intervals or allowed categories.

    A `Value` instance can be validated against a `Constraint` at any time using the
    `validate()` method. If the validation fails, a `ValidationError` is raised.

Type Casting:
    >>> value: TypedValue[Any] = ...   # obtained from external source
    >>> int_value = IntConstraint().validate(value)
    `int_value` is of type IntValue

Composite Constraints:
    Constraints may internally combine multiple checks. For example, a bounded integer
    constraint may ensure that the value is an integer and lies within a specific range.
    These are represented as a single `Constraint` instance and validated together.

Type Inspection:
    You can use `isinstance()` to determine the type of a constraint, even when it
    represents a composition of simpler conditions.
"""

from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import Any

from .core import (
    BoolValue,
    FloatValue,
    IntValue,
    StrValue,
    TypedValue,
    _NumericValue,
)
from .exceptions import ConfigurationError, ValidationError

# -------------------------------------------------------------------------------------
#   Constraint
# -------------------------------------------------------------------------------------

Value = TypedValue[Any]
NumericValue = _NumericValue[int | float]


def ensure_value_type[V: Value](value: object, cls: type[V], expected: str) -> V:
    if not isinstance(value, cls):
        msg = f"Invalid value: expected {expected}, got {value!r}"
        raise ValidationError(msg)
    return value


[docs] class Constraint(ABC): """Represents a constraint that may be imposed on a `Value`.""" _is_parametrised = False
[docs] @abstractmethod def validate(self, value: object) -> Value: """Validate the given `value`, or raise a `ValidationError`. Args: value: The `Value` instance to be validated. Returns: A type-cast version of the original `value`. Raises: ValidationError: If the value violates the constraint. """
[docs] def __str__(self) -> str: """Return a string representation of the constraint.""" return self.__class__.__name__
def __repr__(self) -> str: return str(self)
[docs] class AnyConstraint(Constraint): """A constraint that accepts any `Value` instance without additional checks. This constraint enforces only that the input is an instance of `Value`, but imposes no further semantic restrictions. """
[docs] def validate(self, value: object) -> Value: return ensure_value_type(value, TypedValue, "a Value instance")
[docs] class NumericConstraint(AnyConstraint): """Constrains a value to be numeric (an integer or float)."""
[docs] def validate(self, value: object) -> NumericValue: return ensure_value_type(value, _NumericValue, "a numeric")
[docs] class IntervalConstraint(NumericConstraint): """Constrains a numeric value to lie in a closed interval.""" _is_parametrised = True def __init__(self, lower: float, upper: float) -> None: try: self._lower = float(lower) self._upper = float(upper) except (TypeError, ValueError) as e: msg = f"Invalid bounds: expected float, got {lower!r}, {upper!r}" raise ConfigurationError(msg) from e if self._lower > self._upper: msg = f"Invalid bounds: {self._lower} > {self._upper}" raise ConfigurationError(msg) def __str__(self) -> str: return f"{self.__class__.__name__}[{self.lower}, {self.upper}]"
[docs] def validate(self, value: object) -> NumericValue: numeric_value = super().validate(value) if not (self.lower <= numeric_value.to_float <= self.upper): msg = ( f"Invalid value: {numeric_value.to_float} lies outside " f"[{self.lower}, {self.upper}]" ) raise ValidationError(msg) return numeric_value
[docs] def same_interval(self, other: Constraint) -> bool: return ( isinstance(other, IntervalConstraint) and self.lower == other.lower and self.upper == other.upper )
@property def lower(self) -> float: return self._lower @property def upper(self) -> float: return self._upper
[docs] class LiteralStrConstraint(AnyConstraint): """Constrains a string to lie within a chosen set of possibilities.""" _is_parametrised = True def __init__(self, literals: Sequence[str]) -> None: if not all(g and type(g) is str and g == g.strip() for g in literals): msg = f"Invalid literals: expected strings, got {literals!r}" raise ConfigurationError(msg) if len(literals) == 0: msg = "Invalid literals: cannot be empty" raise ConfigurationError(msg) self._literals = frozenset(literals) def __str__(self) -> str: return f"LiteralStrConstraint({self.literals_as_repr_string})"
[docs] def validate(self, value: object) -> StrValue: str_value = ensure_value_type(value, StrValue, "a string") if str_value.value not in self.literals: msg = ( f"Invalid literal: {value!s} not in {{{self.literals_as_repr_string}}}" ) raise ValidationError(msg) return str_value
@property def literals(self) -> frozenset[str]: return self._literals @property def literals_as_repr_string(self) -> str: return ", ".join([repr(s) for s in sorted(self.literals)])
[docs] class BoolConstraint(AnyConstraint): """Constrains a value to be a boolean."""
[docs] def validate(self, value: object) -> BoolValue: return ensure_value_type(value, BoolValue, "a boolean")
[docs] class IntConstraint(NumericConstraint): """Constrains a value to be an integer."""
[docs] def validate(self, value: object) -> IntValue: return ensure_value_type(value, IntValue, "an integer")
[docs] class FloatConstraint(NumericConstraint): """Constrains a value to be a float."""
[docs] def validate(self, value: object) -> FloatValue: return ensure_value_type(value, FloatValue, "a float")
[docs] class BoundedIntConstraint(IntervalConstraint, IntConstraint): """Constrains a value to be an integer in a closed numeric interval. This combines the checks of `IntConstraint` and `IntervalConstraint`. """
[docs] def validate(self, value: object) -> IntValue: int_value = IntConstraint.validate(self, value) IntervalConstraint.validate(self, int_value) return int_value
[docs] class BoundedFloatConstraint(IntervalConstraint, FloatConstraint): """Constrains a value to be an integer in a closed numeric interval. This combines the checks of `IntConstraint` and `IntervalConstraint`. """
[docs] def validate(self, value: object) -> FloatValue: float_value = FloatConstraint.validate(self, value) IntervalConstraint.validate(self, float_value) return float_value
# ------------------------------------------------------------------------------------- # Constraint implication logic # ------------------------------------------------------------------------------------- def _implies_for_intervals(a: IntervalConstraint, b: IntervalConstraint) -> bool: # A necessary condition is for some instance of a to imply b if not isinstance(a, type(b)): return False # Comes down to checking intervals return b.lower <= a.lower and a.upper <= b.upper def _implies_for_literals(a: LiteralStrConstraint, b: LiteralStrConstraint) -> bool: # Comes down to checking the literals return a.literals <= b.literals
[docs] def implies(a: Constraint | type[Constraint], b: Constraint | type[Constraint]) -> bool: """Returns True if `a` implies `b`. If constraint `a` implies constraint `b` then if a value satisfies constraint `a`, it is guaranteed to also satisfy constraint `b`. A class implies another constraint if every instance of that class implies the constraint. A constraint implies a class if it implies at least one instance of that class. And a class implies a class if each instance of the first class implies at least one instance of the second class. """ cls_a = a if isinstance(a, type) else type(a) cls_b = b if isinstance(b, type) else type(b) # Unless an instance is parametrised, promote to its class if (a is not cls_a) and not a._is_parametrised: # noqa: SLF001 a = cls_a if (b is not cls_b) and not b._is_parametrised: # noqa: SLF001 b = cls_b # An interval or literal constraint implies a class iff every such constraint # implies that class if b is cls_b: return issubclass(cls_a, cls_b) # No interval constraint nor literal constraint can be implied by a class if a is cls_a: return False # Deal with interval constraints if isinstance(a, IntervalConstraint) and isinstance(b, IntervalConstraint): return _implies_for_intervals(a, b) # Deal with literal constraints if isinstance(a, LiteralStrConstraint) and isinstance(b, LiteralStrConstraint): return _implies_for_literals(a, b) # Interval constraints do not imply literal constraints, or vice versa return False