Skip to content

Commit

Permalink
Base coverage for constraints engine.
Browse files Browse the repository at this point in the history
  • Loading branch information
seandstewart committed Jun 27, 2023
1 parent b53835b commit 96100d8
Show file tree
Hide file tree
Showing 9 changed files with 584 additions and 21 deletions.
6 changes: 5 additions & 1 deletion src/typical/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,11 @@ def isstructuredtype(t: type[Any]) -> bool:
isfixedtuple(t)
or isnamedtuple(t)
or istypeddict(t)
or (not isstdlibsubtype(t) and not isuniontype(t) and not isliteral(t))
or (
not isstdlibsubtype(inspection.origin(t))
and not isuniontype(t)
and not isliteral(t)
)
)


Expand Down
1 change: 1 addition & 0 deletions src/typical/core/constraints/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def constrained_wrapper(
mod = inspect.getmodule(frame)
module = mod and mod.__name__ or module
qualname = f"{module}.{name}"
cls_ = type(name, (parent,), {"__qualname__": qualname})
else:
# Otherwise, we need to determine the "parent" type to validate against
# in the new constructor.
Expand Down
5 changes: 2 additions & 3 deletions src/typical/core/constraints/array/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,8 @@ class AbstractArrayAssertion(assertions.AbstractAssertions[_AT]):

def __repr__(self):
return (
"<(",
f"{self.__class__.__name__} ",
f"min_items={self.min_items!r}, max_items={self.max_items!r}" ")>",
f"<({self.__class__.__name__} "
f"min_items={self.min_items!r}, max_items={self.max_items!r})>"
)

def __init__(
Expand Down
4 changes: 2 additions & 2 deletions src/typical/core/constraints/core/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ class NotInstanceAssertionsValidator(AbstractInstanceValidator[VT]):
)

def _get_closure(self) -> ValidatorProtocol[VT]:
def nullable_isinstance_assertions_validator(
def not_instance_assertions_validator(
value: Any,
*,
__precheck=self.precheck,
Expand All @@ -329,7 +329,7 @@ def nullable_isinstance_assertions_validator(
retval = __precheck(value)
return __assertion(retval), retval

return nullable_isinstance_assertions_validator
return not_instance_assertions_validator


# This check is commutative with IS.
Expand Down
36 changes: 31 additions & 5 deletions src/typical/core/constraints/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def itervalidate(
):
# Pin the items validator to the local ns for faster lookup.
ivalidator = self.items.validate
# Pin the lazy repr function to the local ns for faster looup.
# Pin the lazy repr function to the local ns for faster lookup.
irepr = classes.collectionrepr
# Return an iterator which validates each entry in the array.
yield from (
Expand All @@ -181,14 +181,14 @@ def validate_fields(
# Grab the iterator validator for these fields.
it = self.itervalidate(value, path=path, exhaustive=exhaustive)
validated = {
e[0]: e[1]
for e in it
f: v
for f, v in it
if (
# If the return value from iteration is an error,
# collect it in the errors mapping
# rather than in the final output.
not isinstance(e, error.ConstraintValueError)
or errors.update(**{e.path: e})
not isinstance(v, error.ConstraintValueError)
or errors.update(**{v.path: v})
)
}
# If we have errors, exit with an error.
Expand Down Expand Up @@ -304,6 +304,32 @@ def itervalidate(self, value: _TT, *, path: str, exhaustive: TrueOrFalseT = Fals
for i, (v, validate) in enumerate(it)
)

def validate_fields(
self, value: Any, *, path: str, exhaustive: TrueOrFalseT = False
):
# Collect the errors into a mapping of path->error
errors: dict[str, error.ConstraintValueError] = {}
# Grab the iterator validator for these fields.
it = self.itervalidate(value, path=path, exhaustive=exhaustive)
validated = (
*(
e
for e in it
if (
# If the return value from iteration is an error,
# collect it in the errors mapping
# rather than in the final output.
not isinstance(e, error.ConstraintValueError)
or errors.update(**{e.path: e})
)
),
)
# If we have errors, exit with an error.
if errors:
return self.error(value, path=path, raises=not exhaustive, **errors)
# Otherwise, return the validated field->value map.
return validated


_StructuredValidatorT = Union[
validators.IsInstanceValidator[VT], validators.NullableIsInstanceValidator[VT]
Expand Down
15 changes: 8 additions & 7 deletions src/typical/core/constraints/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import functools
import inspect
import typing
from typing import Any, Callable, Collection, Hashable, TypeVar, Union, cast
from typing import Any, Callable, Hashable, TypeVar, Union, cast

from typical import checks, inspection
from typical.compat import ForwardRef, Generic, Protocol
Expand All @@ -30,14 +30,14 @@ class ConstraintsFactory:
def __init__(self):
self.__visited = set()
self._RESOLUTION_STACK = {
checks.isstructuredtype: self._from_user_type,
lambda t: t in self.NOOP: self._from_undeclared_type,
checks.isenumtype: self._from_enum_type,
checks.isliteral: self._from_literal_type,
checks.isuniontype: self._from_union_type,
checks.istexttype: self._from_text_type,
lambda t: checks.issubclass(t, bool): self._from_bool_type,
checks.isnumbertype: self._from_number_type,
checks.isstructuredtype: self._from_user_type,
checks.ismappingtype: self._from_mapping_type,
checks.iscollectiontype: self._from_array_type,
}
Expand Down Expand Up @@ -135,7 +135,7 @@ def build(
ot = inspection.origin(t)
handler = self._from_strict_type
for check, factory in self._RESOLUTION_STACK.items():
if check(ot):
if check(t) or check(ot):
handler = factory # type: ignore[assignment]
break
cv = handler(
Expand Down Expand Up @@ -520,20 +520,21 @@ def _from_user_type(
validator: validators.AbstractValidator
if checks.istupletype(t) and not isnamedtuple:
hints = inspection.cached_type_hints(t)
cvs = (*(self.build(h, cls=cls) for h in hints),)
cvs = (*(self.build(h, cls=cls) for h in hints.values()),) # type: ignore[arg-type]
assertion_cls = structured.get_assertion_cls(
has_fields=False, is_tuple=True
)
assertion = assertion_cls(fields=frozenset(range(len(cvs))), size=len(cvs))
constraints = types.ArrayConstraints(
type=Collection, nullable=nullable, default=default
type=tuple,
nullable=nullable,
default=default,
min_items=len(cvs) + 1,
)
validator = array.get_validator(
constraints=constraints,
return_if_instance=False,
nullable=nullable,
readonly=readonly,
writeonly=writeonly,
)
cv = engine.StructuredTupleConstraintValidator(
constraints=constraints,
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/core/constraints/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

import pytest

from typical import constraints


def test_constrained_new():
# Given
@constraints.constrained(max_length=10)
class ShortStr(str):
...

# When/Then
with pytest.raises(constraints.error.ConstraintValueError):
ShortStr("1234567891011")


def test_constrained_init():
# Given
@constraints.constrained(max_items=1)
class SmallMap(dict):
...

# When/Then
with pytest.raises(constraints.error.ConstraintValueError):
SmallMap({"foo": 1, "bar": 2})


def test_constrained_builtin():
# Given
ShortStr = constraints.constrained(str, max_length=10)
# When/Then
with pytest.raises(constraints.error.ConstraintValueError):
ShortStr("1234567891011")


def test_nested_constraints():
# Given
@constraints.constrained(max_length=10)
class ShortStr(str):
...

@constraints.constrained(max_items=1, values=ShortStr, keys=ShortStr)
class SmallMap(dict):
...

# When/Then
with pytest.raises(constraints.error.ConstraintValueError):
SmallMap({"foo": 1})
Loading

0 comments on commit 96100d8

Please sign in to comment.