diff --git a/README.rst b/README.rst index d6392a3..8006833 100644 --- a/README.rst +++ b/README.rst @@ -11,6 +11,92 @@ Install from pypi:: Documentation at https://strcs.readthedocs.io/ +Example +------- + +.. code-block:: python + + import typing as tp + + import attrs + + import strcs + + reg = strcs.CreateRegister() + creator = reg.make_decorator() + + Number = tp.NewType("Number", int) + Word = tp.NewType("Word", str) + + + @attrs.define(frozen=True) + class Maths(strcs.MetaAnnotation): + multiply: int + + def calculate(self, val: int) -> Number: + return Number(val * self.multiply) + + + class Thing: + pass + + + @attrs.define + class Config: + thing: tp.Annotated[Thing, strcs.FromMeta("thing")] + words: list[Word] + some_number: tp.Annotated[Number, Maths(multiply=2)] + contrived1: str + contrived2: str + some_other_number: int = 16 + + + @creator(Number) + def create_number(val: object, /, annotation: Maths) -> strcs.ConvertResponse[Number]: + if not isinstance(val, int): + return None + + return annotation.calculate(val) + + + @creator(Word) + def create_word(val: object, /, word_prefix: str = "") -> strcs.ConvertResponse[Word]: + if not isinstance(val, str): + return None + + return Word(f"{word_prefix}{val}") + + + @creator(Config) + def create_config(val: object, /) -> strcs.ConvertResponse[Config]: + if not isinstance(val, dict): + return None + + result = dict(val) + if "contrived" in result: + contrived = result.pop("contrived") + result["contrived1"], result["contrived2"] = contrived.split("_") + + return result + + + thing = Thing() + meta = strcs.Meta({"thing": thing, "word_prefix": "the_prefix__"}) + + config = reg.create( + Config, + {"words": ["one", "two"], "some_number": 20, "contrived": "stephen_bob"}, + meta=meta, + ) + print(config) + assert isinstance(config, Config) + assert config.thing is thing + assert config.words == ["the_prefix__one", "the_prefix__two"] + assert config.some_number == 40 + assert config.some_other_number == 16 + assert config.contrived1 == "stephen" + assert config.contrived2 == "bob" + Development ----------- diff --git a/strcs/decorator.py b/strcs/decorator.py index 52a8966..9fefc17 100644 --- a/strcs/decorator.py +++ b/strcs/decorator.py @@ -81,6 +81,7 @@ def create_thing(value: object, /) -> strcs.ConvertResponse[Thing]: from .disassemble import Type, TypeCache, instantiate from .meta import Meta from .not_specified import NotSpecified, NotSpecifiedMeta +from .standard import builtin_types if tp.TYPE_CHECKING: from .register import CreateRegister @@ -196,7 +197,8 @@ def __call__(self, create_args: "CreateArgs") -> T: converter = create_args.converter if self.assume_unchanged_converted and want.is_type_for(value): - return tp.cast(T, value) + if want.origin_type not in builtin_types: + return tp.cast(T, value) try: args = ArgsExtractor( diff --git a/strcs/disassemble/_base.py b/strcs/disassemble/_base.py index 4f6085c..7d8d991 100644 --- a/strcs/disassemble/_base.py +++ b/strcs/disassemble/_base.py @@ -63,6 +63,7 @@ class Missing(MissingType): """ score = Score( + type_alias_name="", union=(), annotated=False, typevars=(), @@ -81,7 +82,10 @@ class Missing(MissingType): "The original object being wrapped" extracted: T - "The extracted type if this object is optional or annotated" + "The extracted type if this object is optional or annotated or a ``typing.NewType`` object" + + type_alias: tp.NewType | None + "The type alias used to reference this type if created with one" optional_inner: bool "True when the object is an annotated optional" @@ -128,12 +132,18 @@ def create( if annotations is None and optional_outer: extracted, annotated, annotations = extract_annotation(typ) + type_alias: tp.NewType | None = None + if isinstance(extracted, tp.NewType): + type_alias = extracted + extracted = extracted.__supertype__ + constructor = tp.cast(tp.Callable[..., Type[U]], cls) made = constructor( cache=cache, original=original, extracted=extracted, + type_alias=type_alias, optional_inner=optional_inner, optional_outer=optional_outer, annotated=annotated, @@ -179,6 +189,11 @@ def __eq__(self, o: object) -> tp.TypeGuard["Type"]: if isinstance(o, Type): o = o.original + other_alias: tp.NewType | None = None + if isinstance(o, tp.NewType): + other_alias = o + o = other_alias.__supertype__ + if ( o == self.original or (self.is_annotated and o == self.extracted) @@ -186,6 +201,9 @@ def __eq__(self, o: object) -> tp.TypeGuard["Type"]: ): return True + if self.type_alias is not None and other_alias == self.type_alias: + return True + if type(o) in union_types: return len(set(tp.get_args(o)) - set(self.relevant_types)) == 0 else: @@ -311,6 +329,13 @@ def is_annotated(self) -> bool: """ return self.annotations is not None + @property + def is_type_alias(self) -> bool: + """ + True if this object is a ``typing.NewType`` object + """ + return self.type_alias is not None + @property def optional(self) -> bool: """ @@ -330,9 +355,11 @@ def mro(self) -> "MRO": return MRO.create(self.extracted, type_cache=self.cache) @memoized_property - def origin(self) -> type: + def origin(self) -> type | tp.NewType: """ - If ``typing.get_origin(self.extracted)`` is a python type, then return that. + if this type was created using a ``tp.NewType`` object, then that is returned. + + Otherwise if ``typing.get_origin(self.extracted)`` is a python type, then return that. Otherwise if ``self.extracted`` is a python type then return that. @@ -340,6 +367,9 @@ def origin(self) -> type: This is memoized. """ + if self.type_alias: + return self.type_alias + origin = tp.get_origin(self.extracted) if isinstance(origin, type): return origin @@ -349,6 +379,20 @@ def origin(self) -> type: return type(self.extracted) + @memoized_property + def origin_type(self) -> type: + """ + Gets the result of ``self.origin``. If the result is a ``tp.NewType`` then the + type represented by that alias is returned, otherwise the origin is. + + This is memoized. + """ + origin = self.origin + if isinstance(origin, tp.NewType): + return origin.__supertype__ + else: + return origin + @memoized_property def is_union(self) -> bool: """ @@ -366,7 +410,7 @@ def without_optional(self) -> object: This is memoized. """ - return self.reassemble(self.extracted, with_optional=False) + return self.reassemble(self.type_alias or self.extracted, with_optional=False) @memoized_property def without_annotation(self) -> object: @@ -375,7 +419,7 @@ def without_annotation(self) -> object: This is memoized. """ - return self.reassemble(self.extracted, with_annotation=False) + return self.reassemble(self.type_alias or self.extracted, with_annotation=False) @memoized_property def nonoptional_union_types(self) -> tuple["Type", ...]: diff --git a/strcs/disassemble/_instance_check.py b/strcs/disassemble/_instance_check.py index 39c10b1..5c3145c 100644 --- a/strcs/disassemble/_instance_check.py +++ b/strcs/disassemble/_instance_check.py @@ -143,7 +143,7 @@ class Meta(InstanceCheck.Meta): Meta.union_types = tp.cast(tuple[type[InstanceCheck]], check_against) Checker = _checker_union(disassembled, check_against, Meta) else: - check_against_single: type | None = disassembled.origin + check_against_single: type | None = disassembled.origin_type if Meta.extracted is None: check_against_single = None diff --git a/strcs/disassemble/_score.py b/strcs/disassemble/_score.py index c4628ff..74801f7 100644 --- a/strcs/disassemble/_score.py +++ b/strcs/disassemble/_score.py @@ -75,6 +75,9 @@ class Score: type is more complex than another. """ + type_alias_name: str + "Name provided to the type if it's a ``typing.NewType`` object" + # The order of the fields matter annotated_union: tuple["Score", ...] = attrs.field(init=False) """ @@ -122,12 +125,15 @@ def create(cls, typ: "Type") -> "Score": Used to create a score for a given :class:`strcs.Type`. This is used by the ``score`` property on the :class:`strcs.Type` object. """ return cls( + type_alias_name=( + "" if (alias := typ.type_alias) is None else getattr(alias, "__name__", "") + ), union=tuple(ut.score for ut in typ.nonoptional_union_types), typevars=tuple(tv.score for tv in typ.mro.all_vars), typevars_filled=tuple(tv is not _get_type().Missing for tv in typ.mro.all_vars), optional=typ.optional, annotated=typ.is_annotated, - origin_mro=tuple(ScoreOrigin.create(t) for t in typ.origin.__mro__), + origin_mro=tuple(ScoreOrigin.create(t) for t in typ.origin_type.__mro__), ) def __attrs_post_init__(self) -> None: diff --git a/strcs/register.py b/strcs/register.py index 886f90c..c6d9dc6 100644 --- a/strcs/register.py +++ b/strcs/register.py @@ -163,7 +163,7 @@ class Decorator(tp.Generic[T]): register: tp.ClassVar[CreateRegister] - def __init__(self, typ: object, *, assume_unchanged_converted=True): + def __init__(self, typ: object, *, assume_unchanged_converted: bool = True): self.original = typ self.assume_unchanged_converted = assume_unchanged_converted diff --git a/tests/disassemble/test_instance_check.py b/tests/disassemble/test_instance_check.py index 74109df..755b340 100644 --- a/tests/disassemble/test_instance_check.py +++ b/tests/disassemble/test_instance_check.py @@ -288,6 +288,56 @@ class Other: assert db.checkable.Meta.without_optional == Mine assert db.checkable.Meta.without_annotation == Mine + it "can find instances and subclasses of NewType objects", type_cache: strcs.TypeCache: + + class Mine: + pass + + MineT = tp.NewType("MineT", Mine) + + db = Type.create(MineT, expect=MineT, cache=type_cache) + assert not isinstance(23, db.checkable) + assert not isinstance(23.4, db.checkable) + assert not isinstance("asdf", db.checkable) + assert isinstance(Mine(), db.checkable) + + class Child(Mine): + pass + + assert isinstance(Child(), db.checkable) + assert issubclass(Child, db.checkable) + + class MyInt(int): + pass + + assert not issubclass(MyInt, db.checkable) + + class Other: + pass + + assert not isinstance(Other(), db.checkable) + assert not issubclass(Other, db.checkable) + + assert isinstance(db.checkable, InstanceCheckMeta) + assert isinstance(db.checkable, type) + assert isinstance(db.checkable, Type.create(type, cache=type_cache).checkable) + + assert issubclass(db.checkable, InstanceCheck) + assert not issubclass(db.checkable, type) + assert not issubclass(type, db.checkable) + assert not issubclass(Type.create(type, cache=type_cache).checkable, db.checkable) + assert not issubclass(db.checkable, Type.create(type, cache=type_cache).checkable) + assert not isinstance(db.checkable, int) + assert not isinstance(db.checkable, Type.create(int, cache=type_cache).checkable) + assert not issubclass(db.checkable, Other) + assert not issubclass(db.checkable, Type.create(Other, cache=type_cache).checkable) + + assert db.checkable.Meta.typ == MineT + assert db.checkable.Meta.original == MineT + assert not db.checkable.Meta.optional + assert db.checkable.Meta.without_optional == MineT + assert db.checkable.Meta.without_annotation == MineT + it "can instantiate the provided type", type_cache: strcs.TypeCache: checkable = Type.create(dict[str, bool], cache=type_cache).checkable made = tp.cast(tp.Callable, checkable)([("1", True), ("2", False)]) diff --git a/tests/scenarios/test_scenario10.py b/tests/scenarios/test_scenario10.py new file mode 100644 index 0000000..8494934 --- /dev/null +++ b/tests/scenarios/test_scenario10.py @@ -0,0 +1,83 @@ +# coding: spec + +import typing as tp + +import attrs + +import strcs + +reg = strcs.CreateRegister() +creator = reg.make_decorator() + +Number = tp.NewType("Number", int) +Word = tp.NewType("Word", str) + + +@attrs.define(frozen=True) +class Maths(strcs.MetaAnnotation): + multiply: int + + def calculate(self, val: int) -> Number: + return Number(val * self.multiply) + + +class Thing: + pass + + +@attrs.define +class Config: + thing: tp.Annotated[Thing, strcs.FromMeta("thing")] + words: list[Word] + some_number: tp.Annotated[Number, Maths(multiply=2)] + contrived1: str + contrived2: str + some_other_number: int = 16 + + +@creator(Number) +def create_number(val: object, /, annotation: Maths) -> strcs.ConvertResponse[Number]: + if not isinstance(val, int): + return None + + return annotation.calculate(val) + + +@creator(Word) +def create_word(val: object, /, word_prefix: str = "") -> strcs.ConvertResponse[Word]: + if not isinstance(val, str): + return None + + return Word(f"{word_prefix}{val}") + + +@creator(Config) +def create_config(val: object, /) -> strcs.ConvertResponse[Config]: + if not isinstance(val, dict): + return None + + result = dict(val) + if "contrived" in result: + contrived = result.pop("contrived") + result["contrived1"], result["contrived2"] = contrived.split("_") + + return result + + +describe "scenario10": + it "works on type aliases": + thing = Thing() + meta = strcs.Meta({"thing": thing, "word_prefix": "the_prefix__"}) + + config = reg.create( + Config, + {"words": ["one", "two"], "some_number": 20, "contrived": "stephen_bob"}, + meta=meta, + ) + assert isinstance(config, Config) + assert config.thing is thing + assert config.words == ["the_prefix__one", "the_prefix__two"] + assert config.some_number == 40 + assert config.some_other_number == 16 + assert config.contrived1 == "stephen" + assert config.contrived2 == "bob"