From a649b741e7c0b98e61f8b9c2bb8e7628256482e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kriszti=C3=A1n=20Sz=C5=B1cs?= Date: Tue, 10 Sep 2024 00:26:21 +0200 Subject: [PATCH 1/3] feat(patterns): support `As/Is` annotations like `Is[int]` and `As[int]` The coercion system is also stricter now disallowing lossy coercions to str/int/float/bool. --- README.md | 15 ++- koerce/__init__.py | 9 ++ koerce/annots.py | 150 ++++++++++++++++++---------- koerce/builders.py | 14 ++- koerce/patterns.py | 183 ++++++++++++++++++++++------------ koerce/tests/test_annots.py | 143 +++++++++++++++++++------- koerce/tests/test_patterns.py | 125 ++++++++++++++++------- koerce/tests/test_sugar.py | 13 ++- koerce/tests/test_y.py | 2 +- 9 files changed, 461 insertions(+), 193 deletions(-) diff --git a/README.md b/README.md index b6b241d..3bb6080 100644 --- a/README.md +++ b/README.md @@ -383,19 +383,30 @@ assert match(As(MyNumber[float]), 8).value == 8.0 first argument to a pattern using the `koerce.pattern()` function: ```py -from koerce import pattern +from koerce import pattern, As, Is assert pattern(int, allow_coercion=False) == Is(int) assert pattern(int, allow_coercion=True) == As(int) assert match(int, 1, allow_coercion=False) == 1 assert match(int, 1.1, allow_coercion=False) is NoMatch -assert match(int, 1.1, allow_coercion=True) == 1 +# lossy coercion is not allowed +assert match(int, 1.1, allow_coercion=True) is NoMatch # default is allow_coercion=False assert match(int, 1.1) is NoMatch ``` +`As[typehint]` and `Is[typehint]` can be used to create patterns: + +```py +from koerce import Pattern, As, Is + +assert match(As[int], '1') == 1 +assert match(Is[int], 1) == 1 +assert match(Is[int], '1') is NoMatch +``` + ### `If` patterns for conditionals Allows conditional matching based on the value of the object, diff --git a/koerce/__init__.py b/koerce/__init__.py index c2ecb4b..820de5f 100644 --- a/koerce/__init__.py +++ b/koerce/__init__.py @@ -55,6 +55,15 @@ def namespace(module): return p, d +def replace(matcher): + """More convenient syntax for replacing a value with the output of a function.""" + + def decorator(replacer): + return Replace(matcher, replacer) + + return decorator + + class NoMatch: __slots__ = () diff --git a/koerce/annots.py b/koerce/annots.py index 6eddd30..2665c89 100644 --- a/koerce/annots.py +++ b/koerce/annots.py @@ -83,12 +83,14 @@ def __init__( if kind is _VAR_POSITIONAL: self.pattern = TupleOf(pattern) elif kind is _VAR_KEYWORD: + # TODO(kszucs): remove FrozenDict? self.pattern = FrozenDictOf(_any, pattern) else: self.pattern = _ensure_pattern(pattern) # validate that the default value matches the pattern if default is not EMPTY: + # TODO(kszucs): try/except MatchError raise an error indicating that the default value doesn't match the pattern self.default_ = self.pattern.match(default, {}) else: self.default_ = default @@ -475,7 +477,7 @@ def annotated(_1=None, _2=None, _3=None, **kwargs): func, arg_patterns=patterns or kwargs, return_pattern=return_pattern, - allow_coercion=False, + allow_coercion=True, ) pat: Pattern = PatternMap( {name: param.pattern for name, param in sig.parameters.items()} @@ -550,12 +552,12 @@ def varkwargs(pattern=_any, typehint=EMPTY): @cython.cclass class AnnotableSpec: # make them readonly - initable: cython.bint - hashable: cython.bint - immutable: cython.bint - signature: Signature - attributes: dict[str, Attribute] - hasattribs: cython.bint + initable = cython.declare(cython.bint, visibility="readonly") + hashable = cython.declare(cython.bint, visibility="readonly") + immutable = cython.declare(cython.bint, visibility="readonly") + signature = cython.declare(Signature, visibility="readonly") + attributes = cython.declare(dict[str, Attribute], visibility="readonly") + hasattribs = cython.declare(cython.bint, visibility="readonly") def __init__( self, @@ -594,10 +596,11 @@ def new(self, cls: type, args: tuple[Any, ...], kwargs: dict[str, Any]): this = cls.__new__(cls) for name, param in self.signature.parameters.items(): __setattr__(this, name, param.pattern.match(bound[name], ctx)) - if self.hasattribs: - self.init_attributes(this) + # TODO(kszucs): test order ot precomputes and attributes calculations if self.hashable: self.init_precomputes(this) + if self.hasattribs: + self.init_attributes(this) return this @cython.cfunc @@ -621,48 +624,98 @@ def init_precomputes(self, this) -> cython.void: __setattr__(this, "__precomputed_hash__", hashvalue) -class AnnotableMeta(type): +class AbstractMeta(type): + """Base metaclass for many of the ibis core classes. + + Enforce the subclasses to define a `__slots__` attribute and provide a + `__create__` classmethod to change the instantiation behavior of the class. + + Support abstract methods without extending `abc.ABCMeta`. While it provides + a reduced feature set compared to `abc.ABCMeta` (no way to register virtual + subclasses) but avoids expensive instance checks by enforcing explicit + subclassing. + """ + + __slots__ = () + + def __new__(metacls, clsname, bases, dct, **kwargs): + # # enforce slot definitions + # dct.setdefault("__slots__", ()) + + # construct the class object + cls = super().__new__(metacls, clsname, bases, dct, **kwargs) + + # calculate abstract methods existing in the class + abstracts = { + name + for name, value in dct.items() + if getattr(value, "__isabstractmethod__", False) + } + for parent in bases: + for name in getattr(parent, "__abstractmethods__", set()): + value = getattr(cls, name, None) + if getattr(value, "__isabstractmethod__", False): + abstracts.add(name) + + # set the abstract methods for the class + cls.__abstractmethods__ = frozenset(abstracts) + + return cls + + +# TODO(kszucs): cover immutable inheritance additivity with tests +class AnnotableMeta(AbstractMeta): def __new__( metacls, clsname, bases, dct, initable=None, - hashable=False, - immutable=False, + hashable=None, + immutable=None, allow_coercion=True, **kwargs, ): - traits = [] - if initable is None: - # this flag is handled in AnnotableSpec - initable = "__init__" in dct or "__new__" in dct - if hashable: - if not immutable: - raise ValueError("Only immutable classes can be hashable") - traits.append(Hashable) - if immutable: - traits.append(Immutable) - - # inherit signature from parent classes - abstracts: set = set() + # inherit annotable specifications from parent classes + spec: AnnotableSpec signatures: list = [] attributes: dict[str, Attribute] = {} + is_initable: cython.bint + is_hashable: cython.bint = hashable is True + is_immutable: cython.bint = immutable is True + if initable is None: + is_initable = "__init__" in dct or "__new__" in dct + else: + is_initable = initable for parent in bases: try: # noqa: SIM105 - signatures.append(parent.__signature__) - except AttributeError: - pass - try: # noqa: SIM105 - attributes.update(parent.__attributes__) + spec = parent.__spec__ except AttributeError: - pass - try: # noqa: SIM105 - abstracts.update(parent.__abstractmethods__) - except AttributeError: - pass + continue + is_initable |= spec.initable + is_hashable |= spec.hashable + is_immutable |= spec.immutable + signatures.append(spec.signature) + attributes.update(spec.attributes) + + # create the base classes for the new class + traits: list[type] = [] + if is_immutable and immutable is False: + raise ValueError( + "One of the base classes is immutable so the child class cannot be mutable" + ) + if is_hashable and hashable is False: + raise ValueError( + "One of the base classes is hashable so this child class must be hashable" + ) + if is_hashable and not is_immutable: + raise ValueError("Only immutable classes can be hashable") + if hashable: + traits.append(Hashable) + if immutable: + traits.append(Immutable) - # collection type annotations and convert them to patterns + # collect type annotations and convert them to patterns slots: list[str] = list(dct.pop("__slots__", [])) module: str | None = dct.pop("__module__", None) qualname: str = dct.pop("__qualname__", clsname) @@ -688,7 +741,6 @@ def __new__( namespace: dict[str, Any] = {} parameters: dict[str, Parameter] = {} - abstractmethods: set = set() for name, value in dct.items(): if isinstance(value, Parameter): parameters[name] = value @@ -697,8 +749,6 @@ def __new__( attributes[name] = value slots.append(name) else: - if getattr(value, "__isabstractmethod__", False): - abstractmethods.add(name) namespace[name] = value # merge the annotations with the parent annotations @@ -706,9 +756,9 @@ def __new__( argnames = tuple(signature.parameters.keys()) bases = tuple(traits) + bases spec = AnnotableSpec( - initable=initable, - hashable=hashable, - immutable=immutable, + initable=is_initable, + hashable=is_hashable, + immutable=is_immutable, signature=signature, attributes=attributes, ) @@ -722,17 +772,7 @@ def __new__( __slots__=tuple(slots), __spec__=spec, ) - klass = super().__new__(metacls, clsname, bases, namespace, **kwargs) - - # check whether the inherited abstract methods are implemented by - # any of the parent classes, basically recalculating the abstractmethods - for name in abstracts: - value = getattr(klass, name, None) - if getattr(value, "__isabstractmethod__", False): - abstractmethods.add(name) - klass.__abstractmethods__ = frozenset(abstractmethods) - - return klass + return super().__new__(metacls, clsname, bases, namespace, **kwargs) def __call__(cls, *args, **kwargs): spec: AnnotableSpec = cython.cast(AnnotableSpec, cls.__spec__) @@ -781,10 +821,10 @@ def __init__(self, **kwargs): spec: AnnotableSpec = self.__spec__ for name, value in kwargs.items(): __setattr__(self, name, value) - if spec.hasattribs: - spec.init_attributes(self) if spec.hashable: spec.init_precomputes(self) + if spec.hasattribs: + spec.init_attributes(self) def __setattr__(self, name, value) -> None: spec: AnnotableSpec = self.__spec__ diff --git a/koerce/builders.py b/koerce/builders.py index 45901a8..bc2aead 100644 --- a/koerce/builders.py +++ b/koerce/builders.py @@ -47,6 +47,9 @@ def __getitem__(self, name): def __call__(self, *args, **kwargs): return Deferred(Call(self, *args, **kwargs)) + # def __contains__(self, item): + # return Deferred(Binop(operator.contains, self, item)) + def __invert__(self) -> Deferred: return Deferred(Unop(operator.invert, self)) @@ -149,6 +152,16 @@ def __rxor__(self, other: Any) -> Deferred: @cython.cclass class Builder: + # TODO(kszucs): cover with tests + @staticmethod + def __coerce__(value): + if isinstance(value, Builder): + return value + elif isinstance(value, Deferred): + return value._builder + else: + raise ValueError(f"Cannot coerce {type(value).__name__!r} to Builder") + def apply(self, ctx: Context): return self.build(ctx) @@ -225,7 +238,6 @@ def build(self, ctx: Context): return self.value -@cython.final @cython.cclass class Var(Builder): """Retrieve a value from the context. diff --git a/koerce/patterns.py b/koerce/patterns.py index 152e3ac..1abebde 100644 --- a/koerce/patterns.py +++ b/koerce/patterns.py @@ -11,6 +11,7 @@ Any, ClassVar, ForwardRef, + Generic, Literal, Optional, TypeVar, @@ -32,6 +33,8 @@ is_typehint, ) +T = TypeVar("T") + Context = dict[str, Any] @@ -94,10 +97,15 @@ def from_typehint( if allow_coercion: if hasattr(annot, "__coerce__"): return AsCoercible(annot) - with suppress(TypeError): - return AsType(annot) - with suppress(TypeError): - return AsDispatch(annot) + elif issubclass(annot, bool): + return AsBool() + elif issubclass(annot, int): + return AsInt() + elif issubclass(annot, (float, list, tuple, dict, set)): + return AsBuiltin(annot) + else: + with suppress(TypeError): + return AsType(annot) return IsType(annot) elif isinstance(annot, TypeVar): # if the typehint is a type variable we try to construct a @@ -120,8 +128,10 @@ def from_typehint( return IsTypeLazy(annot.__forward_arg__) else: raise TypeError(f"Cannot create validator from annotation {annot!r}") - # elif origin is CoercedTo: - # return CoercedTo(args[0]) + elif origin is Is: + return Pattern.from_typehint(args[0], allow_coercion=False) + elif origin is As: + return Pattern.from_typehint(args[0], allow_coercion=True) elif origin is Literal: # for literal types we check the value against the literal values return IsIn(args) @@ -177,11 +187,8 @@ def from_typehint( elif isinstance(origin, GenericMeta): # construct a validator for the generic type, see the specific # Generic* validators for more details - if allow_coercion: - if hasattr(origin, "__coerce__") and args: - return AsCoercibleGeneric(annot) - with suppress(TypeError): - return AsType(annot) + if allow_coercion and hasattr(origin, "__coerce__") and args: + return AsCoercibleGeneric(annot) return IsGeneric(annot) else: raise TypeError( @@ -198,7 +205,8 @@ def match(self, value, ctx: Context): ... def describe(self, value, reason) -> str: ... - def __repr__(self) -> str: ... + def __repr__(self) -> str: + return f"{self.__class__.__name__}()" def __eq__(self, other) -> bool: return type(self) is type(other) and self.equals(other) @@ -278,9 +286,6 @@ def __iter__(self) -> SomeOf: @cython.final @cython.cclass class Anything(Pattern): - def __repr__(self) -> str: - return "Anything()" - def equals(self, other: Anything) -> bool: return True @@ -299,9 +304,6 @@ def match(self, value, ctx: Context): @cython.final @cython.cclass class Nothing(Pattern): - def __repr__(self) -> str: - return "Nothing()" - def equals(self, other: Nothing) -> bool: return True @@ -438,9 +440,12 @@ def match(self, value, ctx: Context): raise MatchError(self, value) -@cython.ccall -def Is(type_: Any): - return Pattern.from_typehint(type_, allow_coercion=False) +class Is(Generic[T]): + def __new__(cls, type_) -> Pattern: + if isinstance(type_, tuple): + return IsType(type_) + else: + return Pattern.from_typehint(type_, allow_coercion=False) @cython.final @@ -708,61 +713,76 @@ def match(self, value, ctx: Context): raise MatchError(self, value) -@cython.ccall -def As(type_: Any) -> Pattern: - return Pattern.from_typehint(type_, allow_coercion=True) +class As(Generic[T]): + def __new__(cls, type_) -> Self: + return Pattern.from_typehint(type_, allow_coercion=True) @cython.final @cython.cclass -class AsType(Pattern): - type_: Any - _registry: ClassVar[tuple[type, ...]] = ( - int, - str, - float, - tuple, - list, - dict, - ) +class AsBool(Pattern): + def equals(self, other: AsBool) -> bool: + return True - def __init__(self, type_: Any): - if not issubclass(type_, self._registry): - raise TypeError(f"Doesn't know how to coerce {type_}") - self.type_ = type_ + def describe(self, value, reason) -> str: + return f"Cannot losslessly convert {value!r} to a boolean." - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.type_!r})" + @cython.cfunc + def match(self, value, ctx: Context): + if isinstance(value, bool): + # Check if the value is already a boolean + return value + if value is None: + raise MatchError(self, value) + if isinstance(value, int): + # Allow conversion only for values clearly boolean-like (0, 1, "true", "false", etc.) + if value == 0: + return False + elif value == 1: + return True + if isinstance(value, str): + lowered = value.lower() + if lowered == "true" or lowered == "1": + return True + elif lowered == "false" or lowered == "0": + return False + raise MatchError(self, value) - def equals(self, other: AsType) -> bool: - return self.type_ == other.type_ + +@cython.final +@cython.cclass +class AsInt(Pattern): + def equals(self, other: AsInt) -> bool: + return True def describe(self, value, reason) -> str: - if reason == "is-none": - return f"passed value is None and cannot be coerced to {self.type_!r}" - elif reason == "failed-to-coerce": - return f"failed to construct an instance using `{self.type_.__name__}({value!r})`" - else: - raise ValueError(f"Unknown reason: {reason}") + return f"Cannot losslessly convert {value!r} to an integer." @cython.cfunc def match(self, value, ctx: Context): - if isinstance(value, self.type_): + if isinstance(value, int): + # Check if the value is already an integer return value - elif value is None: - raise MatchError(self, value, "is-none") - - try: - return self.type_(value) - except ValueError as exc: - raise MatchError(self, value, "failed-to-coerce") from exc + if value is None: + raise MatchError(self, value) + if isinstance(value, float) and value.is_integer(): + # Check if it's a float but an integer in essence (e.g., 5.0 -> 5) + return int(value) + if isinstance(value, str): + # Check if it's a string representation of an integer + try: + # Check if converting to int and back doesn't change the value + if float(value).is_integer(): + return int(value) + except ValueError: + pass + raise MatchError(self, value) @cython.final @cython.cclass -class AsDispatch(Pattern): +class AsType(Pattern): _registry: ClassVar[dict[type, Any]] = {} - type_: Any func: Any @@ -794,12 +814,12 @@ def lookup(cls, type_: Any): cls._registry[type_] = impl return impl - raise TypeError(f"Could not find a custom coerce implementation for {type_}") + raise TypeError(f"Could not find a coerce implementation for {type_}") def __repr__(self) -> str: - return f"{self.__class__.__name__}()" + return f"{self.__class__.__name__}({self.type_!r})" - def equals(self, other: AsDispatch) -> bool: + def equals(self, other: AsType) -> bool: return self.type_ == other.type_ and self.func == other.func def describe(self, value, reason) -> str: @@ -815,6 +835,35 @@ def match(self, value, ctx: Context): raise MatchError(self, value) from exc +@cython.final +@cython.cclass +class AsBuiltin(Pattern): + type_: Any + + def __init__(self, type_: Any): + self.type_ = type_ + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.type_!r})" + + def equals(self, other: AsBuiltin) -> bool: + return self.type_ == other.type_ + + def describe(self, value, reason) -> str: + return f"`{value!r}` cannot be coerced to builtin type {self.type_!r}" + + @cython.cfunc + def match(self, value, ctx: Context): + if isinstance(value, self.type_): + return value + if value is None: + raise MatchError(self, value) + try: + return self.type_(value) + except Exception as exc: + raise MatchError(self, value) from exc + + @cython.final @cython.cclass class AsCoercible(Pattern): @@ -1827,9 +1876,11 @@ def equals(self, other: Length) -> bool: def describe(self, value, reason) -> str: if reason == "too-short": - return f"{value!r} is too short, expected at least {self.at_least} elements" + return ( + f"`{value!r}` is too short, expected at least {self.at_least} elements" + ) elif reason == "too-long": - return f"{value!r} is too long, expected at most {self.at_most} elements" + return f"`{value!r}` is too long, expected at most {self.at_most} elements" else: raise ValueError(f"Unknown reason: {reason}") @@ -2148,7 +2199,11 @@ def __init__(self, fields, **options): self.pattern2 = pattern(pattern2, **options) def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.field1!r}={self.pattern1!r}, {self.field2!r}={self.pattern2!r})" + return ( + f"{self.__class__.__name__}(" + f"{self.field1!r}={self.pattern1!r}, " + f"{self.field2!r}={self.pattern2!r})" + ) def equals(self, other: PatternMap2) -> bool: return ( diff --git a/koerce/tests/test_annots.py b/koerce/tests/test_annots.py index 19d22eb..04b7bf0 100644 --- a/koerce/tests/test_annots.py +++ b/koerce/tests/test_annots.py @@ -22,15 +22,16 @@ from koerce._internal import ( EMPTY, + AbstractMeta, Annotable, AnnotableMeta, Anything, - AsType, + As, FrozenDictOf, Hashable, Immutable, + Is, IsType, - MappingOf, MatchError, Option, Parameter, @@ -753,9 +754,7 @@ def test(a: float, b: float, *args: int): assert test(1.0, 2.0, 3, 4) == 10.0 assert test(1.0, 2.0, 3, 4, 5) == 15.0 - - with pytest.raises(MatchError): - test(1.0, 2.0, 3, 4, 5, 6.0) + assert test(1.0, 2.0, 3, 4, 5, 6.0) == 21.0 def test_annotated_function_with_varkwargs(): @@ -765,9 +764,7 @@ def test(a: float, b: float, **kwargs: int): assert test(1.0, 2.0, c=3, d=4) == 10.0 assert test(1.0, 2.0, c=3, d=4, e=5) == 15.0 - - with pytest.raises(MatchError): - test(1.0, 2.0, c=3, d=4, e=5, f=6.0) + assert test(1.0, 2.0, c=3, d=4, e=5, f=6.0) == 21.0 # def test_multiple_validation_failures(): @@ -791,28 +788,28 @@ def func(a: int, b: str) -> str: ... def func(a: int, b: str, c: str = "0") -> str: ... sig = Signature.from_callable(func, allow_coercion=True) - assert sig.parameters["a"].pattern == AsType(int) - assert sig.parameters["b"].pattern == AsType(str) - assert sig.parameters["c"].pattern == AsType(str) - assert sig.return_pattern == AsType(str) + assert sig.parameters["a"].pattern == As(int) + assert sig.parameters["b"].pattern == Is(str) + assert sig.parameters["c"].pattern == Is(str) + assert sig.return_pattern == IsType(str) def func(a: int, b: str, *args): ... sig = Signature.from_callable(func) - assert sig.parameters["a"].pattern == AsType(int) - assert sig.parameters["b"].pattern == AsType(str) + assert sig.parameters["a"].pattern == As(int) + assert sig.parameters["b"].pattern == Is(str) assert sig.parameters["args"].pattern == TupleOf(Anything()) assert sig.return_pattern == Anything() def func(a: int, b: str, c: str = "0", *args, **kwargs: int) -> float: ... sig = Signature.from_callable(func) - assert sig.parameters["a"].pattern == AsType(int) - assert sig.parameters["b"].pattern == AsType(str) - assert sig.parameters["c"].pattern == AsType(str) + assert sig.parameters["a"].pattern == As(int) + assert sig.parameters["b"].pattern == Is(str) + assert sig.parameters["c"].pattern == Is(str) assert sig.parameters["args"].pattern == TupleOf(Anything()) - assert sig.parameters["kwargs"].pattern == FrozenDictOf(Anything(), AsType(int)) - assert sig.return_pattern == AsType(float) + assert sig.parameters["kwargs"].pattern == FrozenDictOf(Anything(), As(int)) + assert sig.return_pattern == As(float) def test_annotated_with_class(): @@ -839,23 +836,51 @@ def test_annotated_with_dataclass(): class InventoryItem: name: str unit_price: float - quantity_on_hand: int = 0 + quantity_on_hand: int = 10 - item = InventoryItem("widget", 3.0, 10) - assert item.name == "widget" - assert item.unit_price == 3.0 - assert item.quantity_on_hand == 10 + @annotated + @dataclass + class InventoryItemStrict: + name: str + unit_price: Is[float] + quantity_on_hand: Is[int] = 0 - item = InventoryItem("widget", 3.0) + @annotated + @dataclass + class InventoryItemLoose: + name: str + unit_price: As[float] + quantity_on_hand: As[int] = 10 + + items = [ + InventoryItem("widget", 3.0, 10), + InventoryItem("widget", 3.0), + InventoryItem("widget", "3.0", 10), + InventoryItem("widget", 3.0, "10"), + InventoryItemLoose("widget", 3.0, 10), + InventoryItemLoose("widget", 3.0), + InventoryItemLoose("widget", "3.0", 10), + InventoryItemLoose("widget", 3.0, "10"), + ] + for item in items: + assert item.name == "widget" + assert item.unit_price == 3.0 + assert item.quantity_on_hand == 10 + + with pytest.raises(MatchError): + InventoryItem("widget", 3.0, "10.1") + with pytest.raises(MatchError): + InventoryItem("widget", 3.0, 10.1) + + item = InventoryItemStrict("widget", 3.0, 10) assert item.name == "widget" assert item.unit_price == 3.0 - assert item.quantity_on_hand == 0 + assert item.quantity_on_hand == 10 with pytest.raises(MatchError): - InventoryItem("widget", "3.0", 10) - + InventoryItemStrict("widget", "3.0", 10) with pytest.raises(MatchError): - InventoryItem("widget", 3.0, "10") + InventoryItemStrict("widget", 3.0, "10") ################################################## @@ -1563,14 +1588,14 @@ class Op(Annotable): arg = argument(Anything()) class StringOp(Op): - arg = argument(AsType(str)) # new overridden slot + arg = argument(IsType(str)) # new overridden slot class StringSplit(StringOp): - sep = argument(AsType(str)) # new slot + sep = argument(IsType(str)) # new slot class StringJoin(StringOp): __slots__ = ("_memoize",) # new slot - sep = argument(AsType(str)) # new overridden slot + sep = argument(IsType(str)) # new overridden slot assert Op.__slots__ == ("_cache", "arg") assert StringOp.__slots__ == ("arg",) @@ -1928,6 +1953,37 @@ class Example(Annotable): # snapshot.assert_match(str(exc_info.value), target) +def test_abstract_meta(): + class Foo(metaclass=AbstractMeta): + @abstractmethod + def foo(self): ... + + @property + @abstractmethod + def bar(self): ... + + assert not issubclass(type(Foo), ABCMeta) + assert issubclass(type(Foo), AbstractMeta) + assert Foo.__abstractmethods__ == frozenset({"foo", "bar"}) + + with pytest.raises(TypeError, match="Can't instantiate abstract class .*Foo.*"): + Foo() + + class Bar(Foo): + def foo(self): + return 1 + + @property + def bar(self): + return 2 + + bar = Bar() + assert bar.foo() == 1 + assert bar.bar == 2 + assert isinstance(bar, Foo) + assert Bar.__abstractmethods__ == frozenset() + + def test_annotable_supports_abstractmethods(): class Foo(Annotable): @abstractmethod @@ -1989,7 +2045,7 @@ def test_annotable_with_custom_init(): class MyInit(Annotable): a = argument(int) - b = argument(AsType(str)) + b = argument(IsType(str)) c = optional(float, default=0.0) def __init__(self, a, b, c): @@ -2001,10 +2057,11 @@ def __init__(self, a, b, c): def called_with(self): return (self.a, self.b, self.c) + assert MyInit.__spec__.initable is True with pytest.raises(MatchError): MyInit(1, 2, 3) - mi = MyInit(1, 2, 3.3) + mi = MyInit(1, "2", 3.3) assert called_with == (1, "2", 3.3) assert isinstance(mi, MyInit) assert mi.a == 1 @@ -2035,3 +2092,21 @@ class Quux(Annotable): assert Quux().bar.x == 2 assert Quux(Bar(3)).bar.x == 3 + + +def test_annotable_spec_flags(): + assert Annotable.__spec__.initable is False + assert Annotable.__spec__.immutable is False + assert Annotable.__spec__.hashable is False + + +def test_user_model(): + class User(Annotable): + id: int + name: str = "Jane Doe" + age: int | None = None + children: list[str] = [] + + assert User.__spec__.initable is False + assert User.__spec__.immutable is False + assert User.__spec__.hashable is False diff --git a/koerce/tests/test_patterns.py b/koerce/tests/test_patterns.py index 1ddf9f2..a9e8ac4 100644 --- a/koerce/tests/test_patterns.py +++ b/koerce/tests/test_patterns.py @@ -27,9 +27,11 @@ AnyOf, Anything, As, + AsBool, + AsBuiltin, AsCoercible, AsCoercibleGeneric, - AsDispatch, + AsInt, AsType, Call, CallableWith, @@ -77,7 +79,7 @@ from koerce.utils import FrozenDict -@AsDispatch.register(datetime) +@AsType.register(datetime) def as_datetime(cls, value: Any) -> datetime: if isinstance(value, datetime): return value @@ -91,6 +93,9 @@ class Min: def __init__(self, min): self.min = min + def __eq__(self, other): + return isinstance(other, Min) and self.min == other.min + def __call__(self, value, **context): if value >= self.min: return value @@ -118,10 +123,9 @@ def test_nothing(value): (Anything(), None, None, None), (Anything(), None, "three", "three"), (Anything(), 1, None, 1), - (AsType(int), 11, None, 11), - (AsType(int), None, None, None), - (AsType(int), None, 18, 18), - (AsType(str), None, "caracal", "caracal"), + (As(int), 11, None, 11), + (As(int), None, None, None), + (As(int), None, 18, 18), ], ) def test_option(inner, default, value, expected): @@ -334,7 +338,7 @@ def test_is_instance(): def test_as_instance(): - assert As(int) == AsType(int) + assert As(int) == AsInt() assert As(MyCoercible[int, float]) == AsCoercibleGeneric(MyCoercible[int, float]) @@ -393,18 +397,71 @@ class MyGenericInt(Generic[T]): def __coerce__(cls, other, T): return cls(T(other)) - assert isinstance(As(int), AsType) + assert isinstance(As(int), AsInt) assert isinstance(As(MyInt), AsCoercible) assert isinstance(As(MyGenericInt[int]), AsCoercibleGeneric) -def test_as_type(): - p = AsType(int) +def test_as_int(): + p = AsInt() assert p.apply(1) == 1 + assert p.apply(1.0) == 1 assert p.apply("1") == 1 - msg = re.escape("failed to construct an instance using `int('foo')`") - with pytest.raises(MatchError, match=msg): + + with pytest.raises( + MatchError, match=re.escape("Cannot losslessly convert 'foo' to an integer.") + ): + p.apply("foo") + with pytest.raises( + MatchError, match="Cannot losslessly convert 1.1 to an integer." + ): + p.apply(1.1) + + +def test_as_bool(): + p = AsBool() + assert p.apply(True) is True + assert p.apply(False) is False + assert p.apply(1) is True + assert p.apply(0) is False + assert p.apply("1") is True + assert p.apply("0") is False + assert p.apply("True") is True + assert p.apply("False") is False + assert p.apply("true") is True + assert p.apply("false") is False + + with pytest.raises( + MatchError, match="Cannot losslessly convert 'foo' to a boolean." + ): + p.apply("foo") + + +def test_is_as_typehints(): + p = Pattern.from_typehint(Is[int], allow_coercion=False) + assert p.apply(1) == 1 + with pytest.raises(MatchError, match="is not an instance of"): + p.apply(1.0) + + p = Pattern.from_typehint(Is[int], allow_coercion=True) + assert p.apply(1) == 1 + with pytest.raises(MatchError, match="is not an instance of"): + p.apply(1.0) + + p = Pattern.from_typehint(As[int], allow_coercion=False) + assert p.apply(1) == 1 + assert p.apply("1") == 1 + with pytest.raises(MatchError): + p.apply("foo") + + p = Pattern.from_typehint(As[int], allow_coercion=True) + assert p.apply(1) == 1 + assert p.apply("1") == 1 + assert p.apply(1.0) == 1 + with pytest.raises(MatchError): p.apply("foo") + with pytest.raises(MatchError): + p.apply("1.11") def test_coerced_to(): @@ -548,8 +605,8 @@ def negative(_): with pytest.raises(MatchError, match="`1.0` is not an instance of "): p.apply(1.0) - p = AllOf(IsType(int), AsType(float), AsType(str)) - assert p.apply(1) == "1.0" + p = AllOf(IsType(int), AsBuiltin(float)) + assert p.apply(1) == 1.0 with pytest.raises(MatchError, match="`1.0` is not an instance of "): p.apply(1.0) with pytest.raises(MatchError, match="`'1'` is not an instance of "): @@ -935,11 +992,11 @@ def test_pattern_list(): p.apply([1, 2, "3", 4]) # subpattern is a simple pattern - p = PatternList([1, 2, AsType(int), SomeOf(...)]) + p = PatternList([1, 2, AsInt(), SomeOf(...)]) assert p.apply([1, 2, 3.0, 4.0, 5.0]) == [1, 2, 3, 4.0, 5.0] # subpattern is a sequence - p = PatternList([1, 2, 3, SomeOf(AsType(int), at_least=1)]) + p = PatternList([1, 2, 3, SomeOf(AsInt(), at_least=1)]) assert p.apply([1, 2, 3, 4.0, 5.0]) == [1, 2, 3, 4, 5] @@ -1340,7 +1397,6 @@ def test_pattern_decorator(): Callable[[str, int], str], CallableWith((IsType(str), IsType(int)), IsType(str)), ), - # (Callable, InstanceOf(CallableABC)), ], ) def test_pattern_from_typehint_no_coercion(annot, expected): @@ -1350,28 +1406,27 @@ def test_pattern_from_typehint_no_coercion(annot, expected): @pytest.mark.parametrize( ("annot", "expected"), [ - (int, AsType(int)), - (str, AsType(str)), - (bool, AsType(bool)), - (Optional[int], Option(AsType(int))), - (Optional[Union[str, int]], Option(AnyOf(AsType(str), AsType(int)))), - (Union[int, str], AnyOf(AsType(int), AsType(str))), - # (Annotated[int, Min(3)], AllOf(AsType(int), Min(3))), - (list[int], SequenceOf(AsType(int), list)), + (int, AsInt()), + (bool, AsBool()), + (Optional[int], Option(AsInt())), + (Optional[Union[str, int]], Option(AnyOf(IsType(str), AsInt()))), + (Union[int, str], AnyOf(AsInt(), IsType(str))), + (Annotated[int, Min(3)], AllOf(AsInt(), Min(3))), + (list[int], SequenceOf(AsInt(), list)), ( tuple[int, float, str], - PatternList((AsType(int), AsType(float), AsType(str)), type_=tuple), + PatternList((AsInt(), AsBuiltin(float), IsType(str)), type_=tuple), ), - (tuple[int, ...], TupleOf(AsType(int))), + (tuple[int, ...], TupleOf(AsInt())), ( dict[str, float], - DictOf(AsType(str), AsType(float)), + DictOf(IsType(str), AsBuiltin(float)), ), - (FrozenDict[str, int], MappingOf(AsType(str), AsType(int), FrozenDict)), + (FrozenDict[str, int], MappingOf(IsType(str), AsInt(), FrozenDict)), (Literal["alpha", "beta", "gamma"], IsIn(("alpha", "beta", "gamma"))), ( Callable[[str, int], str], - CallableWith((AsType(str), AsType(int)), AsType(str)), + CallableWith((IsType(str), AsInt()), IsType(str)), ), ], ) @@ -1387,7 +1442,7 @@ def test_pattern_from_typehint_uniontype(): assert validator == AnyOf(IsType(str), IsType(int), IsType(float)) validator = Pattern.from_typehint(str | int | float, allow_coercion=True) - assert validator == AnyOf(AsType(str), AsType(int), AsType(float)) + assert validator == AnyOf(IsType(str), AsInt(), AsBuiltin(float)) def test_pattern_from_coercible_typehint_disable_coercion(): @@ -1541,7 +1596,7 @@ def f(x): # plain types are converted to InstanceOf patterns assert pattern(int) == IsType(int) assert pattern(int, allow_coercion=False) == IsType(int) - assert pattern(int, allow_coercion=True) == AsType(int) + assert pattern(int, allow_coercion=True) == AsInt() # no matter whether the type implements the coercible protocol or not assert pattern(MyNegativeInt) == IsType(MyNegativeInt) assert pattern(MyNegativeInt, allow_coercion=True) == AsCoercible(MyNegativeInt) @@ -1555,7 +1610,7 @@ def f(x): # sequence typehints are converted to the appropriate sequence checkers assert pattern(List[int], allow_coercion=True) == ListOf( - AsType(int), allow_coercion=True + AsInt(), allow_coercion=True ) assert pattern(List[int], allow_coercion=False) == ListOf( IsType(int), allow_coercion=False @@ -1563,7 +1618,7 @@ def f(x): # spelled out sequences construct a more advanced pattern sequence assert pattern([int, str, 1], allow_coercion=True) == PatternList( - [AsType(int), AsType(str), EqValue(1)] + [AsInt(), IsType(str), EqValue(1)] ) assert pattern([int, str, 1], allow_coercion=False) == PatternList( [IsType(int), IsType(str), EqValue(1)] @@ -1663,7 +1718,7 @@ class OtherClass(metaclass=OtherMeta): ... def test_as_dispatch(): - p = AsDispatch(datetime) + p = AsType(datetime) assert p.apply(datetime(2021, 1, 1)) == datetime(2021, 1, 1) assert p.apply("2021-01-01") == datetime(2021, 1, 1) with pytest.raises( diff --git a/koerce/tests/test_sugar.py b/koerce/tests/test_sugar.py index b42f2e8..c3b0265 100644 --- a/koerce/tests/test_sugar.py +++ b/koerce/tests/test_sugar.py @@ -13,6 +13,7 @@ match, namespace, pattern, + replace, var, ) @@ -23,7 +24,8 @@ def test_match_strictness(): assert match(int, 1, allow_coercion=False) == 1 assert match(int, 1.1, allow_coercion=False) is NoMatch - assert match(int, 1.1, allow_coercion=True) == 1 + # not lossless + assert match(int, 1.1, allow_coercion=True) is NoMatch # default is allow_coercion=False assert match(int, 1.1) is NoMatch @@ -65,3 +67,12 @@ def test_namespace(): point_builder = builder(point_deferred) assert isinstance(point_deferred, Deferred) assert point_builder == Call(Point, 1, 2) + + +def test_replace_decorator(): + @replace(int) + def sub(_): + return _ - 1 + + assert match(sub, 1) == 0 + assert match(sub, 2) == 1 diff --git a/koerce/tests/test_y.py b/koerce/tests/test_y.py index 894f3ae..ceabdd8 100644 --- a/koerce/tests/test_y.py +++ b/koerce/tests/test_y.py @@ -9,7 +9,7 @@ pydantic = pytest.importorskip("pydantic") msgspec = pytest.importorskip("msgspec") -from ibis.common.grounds import Annotable as IAnnotable +# from ibis.common.grounds import Annotable as IAnnotable from pydantic import BaseModel, validate_call from pydantic_core import SchemaValidator from typing_extensions import TypeVar From 176e3a7e49df63bb6d0090e1ae5548569de9a575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kriszti=C3=A1n=20Sz=C5=B1cs?= Date: Tue, 10 Sep 2024 15:58:56 +0200 Subject: [PATCH 2/3] test(annots): cover inheritance of AnnotableSpec flags --- koerce/annots.py | 7 +++--- koerce/tests/test_annots.py | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/koerce/annots.py b/koerce/annots.py index 2665c89..203285d 100644 --- a/koerce/annots.py +++ b/koerce/annots.py @@ -663,7 +663,6 @@ def __new__(metacls, clsname, bases, dct, **kwargs): return cls -# TODO(kszucs): cover immutable inheritance additivity with tests class AnnotableMeta(AbstractMeta): def __new__( metacls, @@ -701,15 +700,15 @@ def __new__( # create the base classes for the new class traits: list[type] = [] if is_immutable and immutable is False: - raise ValueError( + raise TypeError( "One of the base classes is immutable so the child class cannot be mutable" ) if is_hashable and hashable is False: - raise ValueError( + raise TypeError( "One of the base classes is hashable so this child class must be hashable" ) if is_hashable and not is_immutable: - raise ValueError("Only immutable classes can be hashable") + raise TypeError("Only immutable classes can be hashable") if hashable: traits.append(Hashable) if immutable: diff --git a/koerce/tests/test_annots.py b/koerce/tests/test_annots.py index 04b7bf0..8d92e9d 100644 --- a/koerce/tests/test_annots.py +++ b/koerce/tests/test_annots.py @@ -2100,6 +2100,50 @@ def test_annotable_spec_flags(): assert Annotable.__spec__.hashable is False +def test_annotable_spec_flag_inheritance(): + class A(Annotable): + pass + + class B(Annotable, immutable=True): + pass + + assert A.__spec__.initable is False + assert A.__spec__.immutable is False + assert A.__spec__.hashable is False + assert B.__spec__.initable is False + assert B.__spec__.immutable is True + assert B.__spec__.hashable is False + + class C(A, B): + pass + + assert C.__spec__.initable is False + assert C.__spec__.immutable is True + assert C.__spec__.hashable is False + + class D(B, A, hashable=True): + pass + + assert D.__spec__.initable is False + assert D.__spec__.immutable is True + assert D.__spec__.hashable is True + + with pytest.raises(TypeError): + + class E(A, B, immutable=False): + pass + + with pytest.raises(TypeError): + + class F(D, hashable=False): + pass + + with pytest.raises(TypeError): + + class G(D, immutable=False): + pass + + def test_user_model(): class User(Annotable): id: int From 7fd30afe8f421a018818dacd0ced8a4705729ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kriszti=C3=A1n=20Sz=C5=B1cs?= Date: Tue, 10 Sep 2024 16:01:19 +0200 Subject: [PATCH 3/3] feat(builders): support coercion from Builder and Deferred instances --- koerce/builders.py | 5 ++--- koerce/tests/test_builders.py | 8 ++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/koerce/builders.py b/koerce/builders.py index bc2aead..35dac30 100644 --- a/koerce/builders.py +++ b/koerce/builders.py @@ -152,13 +152,12 @@ def __rxor__(self, other: Any) -> Deferred: @cython.cclass class Builder: - # TODO(kszucs): cover with tests @staticmethod - def __coerce__(value): + def __coerce__(value) -> Builder: if isinstance(value, Builder): return value elif isinstance(value, Deferred): - return value._builder + return cython.cast(Deferred, value)._builder else: raise ValueError(f"Cannot coerce {type(value).__name__!r} to Builder") diff --git a/koerce/tests/test_builders.py b/koerce/tests/test_builders.py index 6c1a77c..0c36552 100644 --- a/koerce/tests/test_builders.py +++ b/koerce/tests/test_builders.py @@ -18,6 +18,7 @@ Item, Just, Map, + Builder, Seq, Unop, Var, @@ -510,3 +511,10 @@ def test_deferred_is_not_truthy(obj): TypeError, match="The truth value of Deferred objects is not defined" ): bool(obj) + + +def test_builder_coercion(): + assert Builder.__coerce__(Deferred(Var("a"))) == Var("a") + assert Builder.__coerce__(Var("a")) == Var("a") + with pytest.raises(ValueError): + Builder.__coerce__(1)