Skip to content

Commit

Permalink
WIP: support typing.NewType
Browse files Browse the repository at this point in the history
  • Loading branch information
delfick committed Sep 17, 2023
1 parent 66cbd48 commit 8004554
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 9 deletions.
86 changes: 86 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------

Expand Down
4 changes: 3 additions & 1 deletion strcs/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
54 changes: 49 additions & 5 deletions strcs/disassemble/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class Missing(MissingType):
"""

score = Score(
type_alias_name="",
union=(),
annotated=False,
typevars=(),
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -179,13 +189,21 @@ 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)
or (self.optional and o is None)
):
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:
Expand Down Expand Up @@ -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:
"""
Expand All @@ -330,16 +355,21 @@ 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.
Otherwise return ``type(self.extracted)``
This is memoized.
"""
if self.type_alias:
return self.type_alias

origin = tp.get_origin(self.extracted)
if isinstance(origin, type):
return origin
Expand All @@ -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:
"""
Expand All @@ -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:
Expand All @@ -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", ...]:
Expand Down
2 changes: 1 addition & 1 deletion strcs/disassemble/_instance_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion strcs/disassemble/_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion strcs/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions tests/disassemble/test_instance_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down
Loading

0 comments on commit 8004554

Please sign in to comment.