diff --git a/spec-draft.rst b/spec-draft.rst index 26fb707..afbf6df 100644 --- a/spec-draft.rst +++ b/spec-draft.rst @@ -3,45 +3,37 @@ Grammar specification of the extensions to the type language. It's important that there be a clearly specified type language for the type-level computation---we can't just be using some poorly specified subset of all Python. +TODO: +- Look into TupleTypeVar stuff for iteration + +Big Q: what should be an error and what should return Never? + :: = ... | if else - # Create NewProtocols and Unions using for loops. - # They can take either a single list comprehension as an - # argument, or starred list comprehensions can be included - # in the argument list. - - # TODO: NewProtocol needs a way of doing bases also... - # TODO: Should probably support Callable, TypedDict, etc - | NewProtocol[)>] - | NewProtocol[)> +] + # Types with variadic arguments can have + # *[... for t in ...] arguments + | [)> +] - | Union[)>] - | Union[)> +] - - | GetAttr[, ] + # This is syntax because taking an int literal makes it a + # special form. | GetArg[, ] - # String manipulation operations for string Literal types. - # We can put more in, but this is what typescript has. - | Uppercase[] | Lowercase[] - | Capitalize[] | Uncapitalize[] # Type conditional checks are just boolean compositions of # subtype checking. = - IsSubtype[, ] + Is[, ] | not | and | or - # Do we want these next two? - | any()>) - | all()>) - = Property[, ] + # Do we want these next two? Probably not. + | Any[)>] + | All[)>] = T , @@ -50,14 +42,117 @@ It's important that there be a clearly specified type language for the type-leve = [ T + * ] = - for in IterUnion - | for , in DirProperties - # TODO: callspecs - # TODO: variadic args (tuples, callables) + # Iterate over a tuple type + for in Iter[] = if +``type-for(T)`` is a parameterized grammar rule, which can take +different types. Not sure if we actually need this though---now it is +only used for Any/All. + +--- + +# TODO: NewProtocol needs a way of doing bases also... +# TODO: New TypedDict setup +* ``NewProtocol[*Ps: Member]`` + +* ``Members[T]`` produces a ``tuple`` of ``Member`` types. +* ``Member[N: Literal[str], T, Q: Quals, D]`` +# These names are too long -- but we can't do ``Type`` !! +* ``GetName[T: Member]`` +* ``GetType[T: Member]`` +* ``GetQuals[T: Member]`` +* ``GetDefiner[T: Member]`` +* Could we also put the defining type there?? + +--- + +* ``GetAttr[T, S: Literal[str]]`` + +# TODO: how to deal with special forms like Callable and tuple[T, ...] +* ``GetArgs[T]`` - returns a tuple containing all of the type arguments +* ``FromUnion[T]`` - returns a tuple containing all of the union + elements, or a 1-ary tuple containing T if it is not a union. + +# TODO: How to do IsUnion? + + +String manipulation operations for string Literal types. +We can put more in, but this is what typescript has. +``Slice`` and ``Concat`` are a poor man's literal template. +We can actually implement the case functions in terms of them and a +bunch of conditionals. + + +* ``Slice[S: Literal[str], Start: Literal[int | None], End: Literal[int | None]]`` +* ``Concat[S1: Literal[str], S2: Literal[str]]`` + +* ``Uppercase[S: Literal[str]]`` +* ``Lowercase[S: Literal[str]]`` +* ``Capitalize[S: Literal[str]]`` +* ``Uncapitalize[S: Literal[str]]`` + + + +------------------------------------------------------------------------- + + +Big open questions? + +1. +Can we actually implement Is (IsSubtype) at runtime in a satisfactory way? + - Could we slightly dodge the question by *not* adding the evaluation library to the standard library, and letting the operations be opaque. + + Then we would promise to have a third-party library, which would need to be "fit for purpose" for people to want to use, but would be free of the burden of being canonical? + + There is a lot that needs to happen, like protocols and variance inference and +callable subtyping (which might require matching against type vars...) + + - I think we probably *can't* try to put it in the standard library. I think it would by nature bless the implementation with some degree of canonicity that I'm not sure we can back up. Different typecheckers don't always match on subtyping behavior, *and* it sometimes depends on config flags (like strict_optional in mypy). *And* we could imagine a bunch of other config flags: whether to be strict about argument names in protocols, for example. + + - We can instead have something simpler, which I will call ``Matches``. ``Matches`` would do *simple* checking of the *head* of types, essentially, without looking at type parameters. It would still lift over unions and would check literals. + Honestly this is basically what is currently implemented for the examples, so it is probably good enough. + + It's unsatisfying, though. + +2. +How do we deal with modifiers? ClassVar, Final, Required, ReadOnly + - One option is to treat them not as types by as *modifiers* and have them + in a separate field where they are a union of Literals. + So ``x: Final[ClassVar[int]]`` would appear in ``Attrs`` as + ``Member[Literal['x'], int, Literal['Final' | 'ClassVar']]`` + + This is kind of unsatisfying but I think it's probably right. + We could also have a ``MemberUpdate[M: Member, T]`` that updates + the type of a member but preserves its name and modifiers. + + - + + +3. +How do we deal with Callables? We need to support extended callable syntax basically. +Or something like it. + +4. +What do we do about ``Members`` on built-in types? ``typing.get_type_hints(int)`` returns ``{}`` but mypy will not agree! + +An object of an empty user-defined class has 29 entries in ``dir`` (all dunders), and ``object()`` has 24. (In 3.14. In 3.12, it was 27 for the user-defined object). + +5. +Polymorphic callables? How do we represent their type and how do we construct their type? + +What does TS do here? - TS has full impredactive polymorphic functions. You can do System F stuff. + +===== + +This proposal is less "well-typed" than typescript... (Well-kinded, maybe?) +Typescript has better typechecking at the alias definition site: +For ``P[K]``, ``K`` needs to have ``keyof P``... -``type-for(T)`` and ``variadic-type-arg(T)`` are parameterized grammar -rules, which can take different +Oh, we could maybe do better but it would require some new machinery. +* ``KeyOf[T]`` - literal keys of ``T`` +* ``Member[T]``, when statically checking a type alias, could be treated as having some type like ``tuple[Member[KeyOf[T], object???, str], ...]`` +* ``GetAttr[T, S: KeyOf[T]]`` - but this isn't supported yet. TS supports it. +* We would also need to do context sensitive type bound inference diff --git a/tests/test_call.py b/tests/test_call.py index 031ab6e..2138404 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -1,15 +1,22 @@ import textwrap from typemap.type_eval import eval_call -from typemap import typing as next +from typemap.typing import ( + CallSpec, + NewProtocol, + Member, + GetName, + Iter, + CallSpecKwargs, +) from . import format_helper -def func[C: next.CallSpec]( +def func[C: CallSpec]( *args: C.args, **kwargs: C.kwargs -) -> next.NewProtocol[ - *[next.Property[c.name, int] for c in next.CallSpecKwargs[C]] +) -> NewProtocol[ + *[Member[GetName[c], int] for c in Iter[CallSpecKwargs[C]]] ]: ... diff --git a/tests/test_qblike.py b/tests/test_qblike.py index b0e7fae..ac8833f 100644 --- a/tests/test_qblike.py +++ b/tests/test_qblike.py @@ -1,7 +1,19 @@ import textwrap from typemap.type_eval import eval_call, eval_typing -from typemap import typing as next +from typemap.typing import ( + NewProtocol, + Iter, + Attrs, + Is, + GetType, + CallSpec, + Member, + GetName, + GetAttr, + CallSpecKwargs, + GetArg, +) from . import format_helper @@ -14,18 +26,12 @@ class Link[T]: pass -type PropsOnly[T] = next.NewProtocol[ - [ - next.Property[p.name, p.type] - for p in next.DirProperties[T] - if next.IsSubtype[p.type, Property] - ] +type PropsOnly[T] = NewProtocol[ + *[p for p in Iter[Attrs[T]] if Is[GetType[p], Property]] ] # Conditional type alias! -type FilterLinks[T] = ( - Link[PropsOnly[next.GetArg[T, 0]]] if next.IsSubtype[T, Link] else T -) +type FilterLinks[T] = Link[PropsOnly[GetArg[T, 0]]] if Is[T, Link] else T # Basic filtering @@ -45,15 +51,15 @@ class A: w: Property[list[str]] -def select[C: next.CallSpec]( +def select[C: CallSpec]( __rcv: A, *args: C.args, **kwargs: C.kwargs -) -> next.NewProtocol[ - [ - next.Property[ - c.name, - FilterLinks[next.GetAttr[A, c.name]], +) -> NewProtocol[ + *[ + Member[ + GetName[c], + FilterLinks[GetAttr[A, GetName[c]]], ] - for c in next.CallSpecKwargs[C] + for c in Iter[CallSpecKwargs[C]] ] ]: ... @@ -103,7 +109,7 @@ class select[...]: z: tests.test_qblike.Link[PropsOnly[tests.test_qblike.Tgt]] """) - tgt = eval_typing(next.GetAttr[ret, "z"].__args__[0]) + tgt = eval_typing(GetAttr[ret, "z"].__args__[0]) fmt = format_helper.format_class(tgt) assert fmt == textwrap.dedent("""\ diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 9923bbc..40f861d 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -1,13 +1,24 @@ -import typing import textwrap +from typing import TypeVar, Literal, Union from typemap.type_eval import eval_typing -from typemap import typing as next +from typemap.typing import ( + NewProtocol, + Member, + GetName, + GetType, + Iter, + Attrs, + Members, + FromUnion, + Uppercase, + Is, +) from . import format_helper -type OrGotcha[K] = K | typing.Literal["gotcha!"] +type OrGotcha[K] = K | Literal["gotcha!"] type StrForInt[X] = (str | OrGotcha[X]) if X is int else (X | OrGotcha[X]) @@ -21,11 +32,15 @@ class AnotherBase[I]: class Base[T]: - K = typing.TypeVar("K") + # This K is dodgy + K = TypeVar("K") t: dict[str, StrForInt[T]] kkk: K + def foo(self, a: T | None, b: int = 0) -> dict[str, T]: + pass + def base[Z](self, a: T | Z | None, b: K) -> dict[str, T | Z]: pass @@ -47,51 +62,78 @@ class Mine(Wrapper[int]): class Last[O]: - last: O | typing.Literal[True] + last: O | Literal[True] class Final(Mine, Ordinary, Wrapper[float], AnotherBase[float], Last[int]): pass -type AllOptional[T] = next.NewProtocol[ - *[next.Property[p.name, p.type | None] for p in next.DirProperties[T]] +type AllOptional[T] = NewProtocol[ + *[Member[GetName[p], GetType[p] | None] for p in Iter[Attrs[T]]] ] type OptionalFinal = AllOptional[Final] -type Capitalize[T] = next.NewProtocol[ - [ - next.Property[next.Uppercase[p.name], p.type] - for p in next.DirProperties[T] - ] +type Capitalize[T] = NewProtocol[ + *[Member[Uppercase[GetName[p]], GetType[p]] for p in Iter[Attrs[T]]] ] -type Prims[T] = next.NewProtocol[ +type Prims[T] = NewProtocol[ + *[p for p in Iter[Attrs[T]] if Is[GetType[p], int | str]] +] + +type NoLiterals1[T] = NewProtocol[ *[ - next.Property[name, typ] - for name, typ in next.DirProperties[T] - if next.IsSubtype[typ, int | str] + Member[ + GetName[p], + Union[ + *[ + t + for t in Iter[FromUnion[GetType[p]]] + # XXX: 'typing.Literal' is not *really* a type... + # Maybe we can't do this, which maybe is fine. + if not Is[t, Literal] + ] + ], + ] + for p in Iter[Attrs[T]] ] ] -type NoLiterals[T] = next.NewProtocol[ +# Try to implement IsLiteral. This is basically what is recommended +# for doing it in TS. +# XXX: This doesn't work in python! We can subtype str! +type IsLiteral[T] = ( + Literal[True] + if ( + (Is[T, str] and not Is[str, T]) + or (Is[T, bytes] and not Is[bytes, T]) + or (Is[T, bool] and not Is[bool, T]) + or (Is[T, int] and not Is[int, T]) + # XXX: enum, None + ) + else Literal[False] +) + +type NoLiterals2[T] = NewProtocol[ *[ - next.Property[ - p.name, - typing.Union[ + Member[ + GetName[p], + Union[ *[ t - for t in next.IterUnion[p.type] + for t in Iter[FromUnion[GetType[p]]] # XXX: 'typing.Literal' is not *really* a type... # Maybe we can't do this, which maybe is fine. - if not next.IsSubtype[t, typing.Literal] + # if not IsSubtype[t, Literal] + if not Is[IsLiteral[t], Literal[True]] ] ], ] - for p in next.DirProperties[T] + for p in Iter[Attrs[T]] ] ] @@ -107,6 +149,7 @@ class Final: kkk: ~K x: tests.test_type_dir.Wrapper[int | None] ordinary: str + def foo(self, a: int | None, b: int = 0) -> dict[str, int]: ... def base[Z](self, a: int | Z | None, b: ~K) -> dict[str, int | Z]: ... def cbase(cls, a: int | None, b: ~K) -> dict[str, int]: ... def sbase[Z](cls, a: int | Literal['gotcha!'] | Z | None, b: ~K) -> dict[str, int | Z]: ... @@ -116,8 +159,8 @@ def sbase[Z](cls, a: int | Literal['gotcha!'] | Z | None, b: ~K) -> dict[str, in def test_type_dir_2(): d = eval_typing(OptionalFinal) - # XXX: `DirProperties` skips methods, true to its name. Perhaps we just need - # `Dir` that would iterate over everything + # XXX: `Atrs` skips methods, true to its name. Perhaps we just need + # `Members` that would iterate over everything assert format_helper.format_class(d) == textwrap.dedent("""\ class AllOptional[tests.test_type_dir.Final]: last: int | typing.Literal[True] | None @@ -154,10 +197,11 @@ class Prims[tests.test_type_dir.Final]: def test_type_dir_5(): - d = eval_typing(NoLiterals[Final]) + global fuck + d = eval_typing(NoLiterals1[Final]) assert format_helper.format_class(d) == textwrap.dedent("""\ - class NoLiterals[tests.test_type_dir.Final]: + class NoLiterals1[tests.test_type_dir.Final]: last: int iii: str | int t: dict[str, str | int | typing.Literal['gotcha!']] @@ -165,3 +209,33 @@ class NoLiterals[tests.test_type_dir.Final]: x: tests.test_type_dir.Wrapper[int | None] ordinary: str """) + + +def test_type_dir_6(): + d = eval_typing(NoLiterals2[Final]) + + assert format_helper.format_class(d) == textwrap.dedent("""\ + class NoLiterals2[tests.test_type_dir.Final]: + last: int + iii: str | int + t: dict[str, str | int | typing.Literal['gotcha!']] + kkk: ~K + x: tests.test_type_dir.Wrapper[int | None] + ordinary: str + """) + + +def test_type_dir_7(): + d = eval_typing(Members[Final]) + foo = next(iter(m for m in Iter[d] if m.__args__[0].__args__[0] == "foo")) + # XXX: drop self? + assert ( + str(foo) + == "\ +typemap.typing.Member[typing.Literal['foo'], \ +typing.Callable[[\ +typemap.typing.Param[typing.Literal['self'], typing.Any, typing.Never], \ +typemap.typing.Param[typing.Literal['a'], int | None, typing.Never], \ +typemap.typing.Param[typing.Literal['b'], int, typing.Literal['=']]], \ +dict[str, int]], typing.Literal['ClassVar'], typing.Never]" + ) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index be32c2b..1cc5506 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -1,17 +1,25 @@ import textwrap -import typing import unittest - -from typemap import typing as next +from typing import Literal + +from typemap.typing import ( + NewProtocol, + Member, + GetName, + GetType, + Iter, + Attrs, + Is, +) from typemap.type_eval import eval_typing from . import format_helper -type A[T] = T | None | typing.Literal[False] +type A[T] = T | None | Literal[False] type B = A[int] -type OrGotcha[K] = K | typing.Literal["gotcha!"] +type OrGotcha[K] = K | Literal["gotcha!"] class F[T]: @@ -22,24 +30,31 @@ class F_int(F[int]): pass -type MapRecursive[A] = next.NewProtocol[ +type ConcatTuples[A, B] = tuple[ + *[x for x in Iter[A]], + *[x for x in Iter[B]], +] + +type MapRecursive[A] = NewProtocol[ *[ ( - next.Property[p.name, OrGotcha[p.type]] - if not next.IsSubtype[p.type, A] - else next.Property[p.name, OrGotcha[MapRecursive[A]]] + Member[GetName[p], OrGotcha[GetType[p]]] + if not Is[GetType[p], A] + else Member[GetName[p], OrGotcha[MapRecursive[A]]] ) - # XXX: type language - concatenating DirProperties is sketchy - for p in (next.DirProperties[A] + next.DirProperties[F_int]) + # XXX: This next line *ought* to work, but we haven't + # implemented it yet. + # for p in Iter[*Attrs[A], *Attrs[F_int]] + for p in Iter[ConcatTuples[Attrs[A], Attrs[F_int]]] ], - next.Property[typing.Literal["control"], float], + Member[Literal["control"], float], ] class Recursive: n: int m: str - t: typing.Literal[False] + t: Literal[False] a: Recursive diff --git a/typemap/type_eval/__init__.py b/typemap/type_eval/__init__.py index fabbaee..8973014 100644 --- a/typemap/type_eval/__init__.py +++ b/typemap/type_eval/__init__.py @@ -1,6 +1,14 @@ from ._eval_call import eval_call -from ._eval_typing import eval_typing, _get_current_context +from ._eval_typing import eval_typing, _get_current_context, _EvalProxy from ._subtype import issubtype +from ._subsim import issubsimilar -__all__ = ("eval_typing", "eval_call", "issubtype", "_get_current_context") +__all__ = ( + "eval_typing", + "eval_call", + "issubtype", + "issubsimilar", + "_EvalProxy", + "_get_current_context", +) diff --git a/typemap/type_eval/_eval_typing.py b/typemap/type_eval/_eval_typing.py index 29f1f37..e68ebaa 100644 --- a/typemap/type_eval/_eval_typing.py +++ b/typemap/type_eval/_eval_typing.py @@ -19,6 +19,13 @@ __all__ = ("eval_typing",) +# Base type for the proxy classes we generate to hold __annotations__ +class _EvalProxy: + # Make sure __origin__ doesn't show up at runtime... + if typing.TYPE_CHECKING: + __origin__: type + + @dataclasses.dataclass class EvalContext: seen: dict[Any, Any] @@ -101,10 +108,11 @@ def _eval_type_type(obj: type, ctx: EvalContext): if isinstance(obj, type) and issubclass(obj, typing.Generic): ret = type( obj.__name__, - (typing.cast(type, typing.Protocol),), + (_EvalProxy,), { "__module__": obj.__module__, "__name__": obj.__name__, + "__origin__": obj, }, ) diff --git a/typemap/type_eval/_subsim.py b/typemap/type_eval/_subsim.py new file mode 100644 index 0000000..8304d8d --- /dev/null +++ b/typemap/type_eval/_subsim.py @@ -0,0 +1,90 @@ +import typing + + +from . import _typing_inspect + + +__all__ = ("issubsimilar",) + + +def issubsimilar(lhs: typing.Any, rhs: typing.Any) -> bool: + # TODO: Need to handle some cases + + # N.B: All of the 'bool's in these are because black otherwise + # formats the two-conditional chains in an unconscionably bad way. + + # Unions first + if _typing_inspect.is_union_type(rhs): + return any(issubsimilar(lhs, r) for r in typing.get_args(rhs)) + elif _typing_inspect.is_union_type(lhs): + return all(issubsimilar(t, rhs) for t in typing.get_args(lhs)) + + # For _EvalProxy's just blow through them, since we don't yet care + # about the attribute types here. + elif _typing_inspect.is_eval_proxy(lhs): + return issubsimilar(lhs.__origin__, rhs) + elif _typing_inspect.is_eval_proxy(rhs): + return issubsimilar(lhs, rhs.__origin__) + + elif bool( + _typing_inspect.is_valid_isinstance_arg(lhs) + and _typing_inspect.is_valid_isinstance_arg(rhs) + ): + return issubclass(lhs, rhs) + + # literal <:? literal + elif bool( + _typing_inspect.is_literal(lhs) and _typing_inspect.is_literal(rhs) + ): + rhs_args = set(typing.get_args(rhs)) + return all(lv in rhs_args for lv in typing.get_args(lhs)) + + # XXX: This case is kind of a hack, to support NoLiterals. + elif rhs is typing.Literal: + return _typing_inspect.is_literal(lhs) + + # literal <:? type + elif _typing_inspect.is_literal(lhs): + return all(issubsimilar(type(x), rhs) for x in typing.get_args(lhs)) + + # C[A] <:? D + elif bool( + _typing_inspect.is_generic_alias(lhs) + and _typing_inspect.is_valid_isinstance_arg(rhs) + ): + return issubsimilar(_typing_inspect.get_origin(lhs), rhs) + + # C <:? D[A] + elif bool( + _typing_inspect.is_valid_isinstance_arg(lhs) + and _typing_inspect.is_generic_alias(rhs) + ): + return issubsimilar(lhs, _typing_inspect.get_origin(rhs)) + + # C[A] <:? D[B] -- just match the heads! + elif bool( + _typing_inspect.is_generic_alias(lhs) + and _typing_inspect.is_generic_alias(rhs) + ): + return issubsimilar( + _typing_inspect.get_origin(lhs), _typing_inspect.get_origin(rhs) + ) + + # XXX: I think this is probably wrong, but a test currently has + # an unbound type variable... + elif _typing_inspect.is_type_var(lhs): + return lhs is rhs + + # TODO: and we will we need to infer variance ourselves with the new syntax + + # TODO: Protocols??? + + # Check behavior? + # TODO: Annotated + # TODO: tuple + # TODO: Callable + # TODO: Any + # TODO: TypedDict + + # This will often fail -- eventually should return False + return issubclass(lhs, rhs) diff --git a/typemap/type_eval/_subtype.py b/typemap/type_eval/_subtype.py index f7ed7dc..a80313c 100644 --- a/typemap/type_eval/_subtype.py +++ b/typemap/type_eval/_subtype.py @@ -1,16 +1,12 @@ -# import annotationlib - -# import contextlib -# import contextvars -# import dataclasses -# import functools -# import inspect -# import sys -# import types +# XXX: This is the start of an implementation of issubtype, but +# honestly it is still mostly the same as issubsimilar. I'm preserving +# it for now and might still expand it some. +# Largely the value of it is in the TODO comments I guess. + + import typing -# from . import _eval_type from . import _typing_inspect @@ -20,6 +16,9 @@ def issubtype(lhs: typing.Any, rhs: typing.Any) -> bool: # TODO: Need to handle a lot of cases! + # TODO: We will probably need to carry a context around, + # and maybe recursively invoke eval_typing? + # N.B: All of the 'bool's in these are because black otherwise # formats the two-conditional chains in an unconscionably bad way. @@ -29,6 +28,14 @@ def issubtype(lhs: typing.Any, rhs: typing.Any) -> bool: elif _typing_inspect.is_union_type(lhs): return all(issubtype(t, rhs) for t in typing.get_args(lhs)) + # For _EvalProxy's just blow through them, since we don't yet care + # about the attribute types here. + # TODO: But we'll need to once we support Protocols?? + elif _typing_inspect.is_eval_proxy(lhs): + return issubtype(lhs.__origin__, rhs) + elif _typing_inspect.is_eval_proxy(rhs): + return issubtype(lhs, rhs.__origin__) + elif bool( _typing_inspect.is_valid_isinstance_arg(lhs) and _typing_inspect.is_valid_isinstance_arg(rhs) @@ -48,18 +55,15 @@ def issubtype(lhs: typing.Any, rhs: typing.Any) -> bool: # literal <:? type elif _typing_inspect.is_literal(lhs): - return issubtype(type(typing.get_args(lhs)[0]), rhs) + return all(issubtype(type(x), rhs) for x in typing.get_args(lhs)) # C[A] <:? D elif bool( _typing_inspect.is_generic_alias(lhs) - # and _typing_inspect.is_valid_isinstance_arg(rhs) + and _typing_inspect.is_valid_isinstance_arg(rhs) ): - # print(lhs) - # breakpoint() - return issubclass(lhs.__origin__, rhs) - # return issubtype(lhs.__origin__, rhs) - # return issubtype(_typing_inspect.get_origin(lhs), rhs) + return issubtype(_typing_inspect.get_origin(lhs), rhs) + # C <:? D[A] elif bool( _typing_inspect.is_valid_isinstance_arg(lhs) @@ -73,9 +77,20 @@ def issubtype(lhs: typing.Any, rhs: typing.Any) -> bool: return lhs is rhs # TODO: What to do about C[A] <:? D[B]??? + # TODO: and we will we need to infer variance ourselves with the new syntax # TODO: Protocols??? + # TODO: tuple + + # TODO: Callable -- oh no, and callable needs + + # TODO: Any + + # TODO: Annotated + + # TODO: TypedDict + # TODO: We will need to have some sort of hook to support runtime # checking of typechecker extensions. # diff --git a/typemap/type_eval/_typing_inspect.py b/typemap/type_eval/_typing_inspect.py index 66a60ed..3da6c61 100644 --- a/typemap/type_eval/_typing_inspect.py +++ b/typemap/type_eval/_typing_inspect.py @@ -21,6 +21,8 @@ from typing_extensions import TypeAliasType, TypeVarTuple, Unpack from types import GenericAlias, UnionType +from . import _eval_typing + def is_classvar(t: Any) -> bool: return t is ClassVar or (is_generic_alias(t) and get_origin(t) is ClassVar) # type: ignore [comparison-overlap] @@ -123,6 +125,10 @@ def is_literal(t: Any) -> bool: return is_generic_alias(t) and get_origin(t) is Literal # type: ignore [comparison-overlap] +def is_eval_proxy(t: Any) -> TypeGuard[type[_eval_typing._EvalProxy]]: + return isinstance(t, type) and issubclass(t, _eval_typing._EvalProxy) + + __all__ = ( "is_annotated", "is_classvar", diff --git a/typemap/typing.py b/typemap/typing.py index 8bb8744..ad05100 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -5,11 +5,17 @@ import typing from typemap import type_eval +from typemap.type_eval import _typing_inspect _SpecialForm: typing.Any = typing._SpecialForm +class _NoCacheSpecialForm(_SpecialForm, _root=True): # type: ignore[call-arg] + def __getitem__(self, parameters): + return self._getitem(self, parameters) + + @dataclass(frozen=True) class CallSpec: pass @@ -31,13 +37,8 @@ def kwargs(self) -> None: pass -@dataclass(frozen=True) -class _CallKwarg: - name: str - - @_SpecialForm -def CallSpecKwargs(self, spec: _CallSpecWrapper) -> list[_CallKwarg]: +def CallSpecKwargs(self, spec: _CallSpecWrapper): ff = types.FunctionType( spec._func.__code__, spec._func.__globals__, @@ -53,62 +54,171 @@ def CallSpecKwargs(self, spec: _CallSpecWrapper) -> list[_CallKwarg]: sig = inspect.signature(ff) bound = sig.bind(*spec._args, **spec._kwargs) - return [_CallKwarg(name=name) for name in bound.kwargs] + # TODO: Get the real type instead of Never + return tuple[ # type: ignore[misc] + *[ + Member[ + typing.Literal[name], # type: ignore[valid-type] + typing.Never, + ] + for name in bound.kwargs + ] + ] ################################################################## def _from_literal(val): - if isinstance(val, typing._LiteralGenericAlias): # type: ignore[attr-defined] + val = type_eval.eval_typing(val) + if _typing_inspect.is_literal(val): val = val.__args__[0] return val -class PropertyMeta(type): - def __getitem__(cls, val: tuple[str | types.GenericAlias, type]): - name, type = val - # We allow str or Literal so that string literals work too - return cls(name=_from_literal(name), type=type) +class Member[N: str, T, Q: str = typing.Never, D = typing.Never]: + pass -@dataclass(frozen=True) -class Property(metaclass=PropertyMeta): - name: str - type: type +type GetName[T: Member] = GetArg[T, 0] # type: ignore[valid-type] +type GetType[T: Member] = GetArg[T, 1] # type: ignore[valid-type] +type GetQuals[T: Member] = GetArg[T, 2] # type: ignore[valid-type] +type GetDefiner[T: Member] = GetArg[T, 3] # type: ignore[valid-type] ################################################################## -# I want to experiment with this being a tuple. -class _OutProperty(typing.NamedTuple): - name: str - type: type +def get_annotated_type_hints(cls, **kwargs): + """Get the type hints for a cls annotated with definition site. + + This traverses the mro and finds the definition site for each annotation. + """ + ohints = typing.get_type_hints(cls, **kwargs) + hints = {} + for acls in cls.__mro__: + if not hasattr(acls, "__annotations__"): + continue + for k in acls.__annotations__: + if k not in hints: + hints[k] = ohints[k], acls + + # Stop early if we are done. + if len(hints) == len(ohints): + break + return hints @_SpecialForm -def DirProperties(self, tp): +def Attrs(self, tp): # TODO: Support unions o = type_eval.eval_typing(tp) - hints = typing.get_type_hints(o, include_extras=True) - return [_OutProperty(typing.Literal[n], t) for n, t in hints.items()] + hints = get_annotated_type_hints(o, include_extras=True) + + return tuple[ + *[ + Member[typing.Literal[n], t, typing.Never, d] + for n, (t, d) in hints.items() + ] + ] + + +class Param[N: str | None, T, Q: str = typing.Never]: + pass + + +def _function_type(func, *, is_method): + root = inspect.unwrap(func) + sig = inspect.signature(root) + # XXX: __type_params__!!! + + empty = inspect.Parameter.empty + + def _ann(x): + return typing.Any if x is empty else x + + params = [] + for _i, p in enumerate(sig.parameters.values()): + # XXX: what should we do about self? + # should we track classmethod/staticmethod somehow? + # if i == 0 and is_method: + # continue + has_name = p.kind in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + quals = [] + if p.kind == inspect.Parameter.VAR_POSITIONAL: + quals.append("*") + if p.kind == inspect.Parameter.VAR_KEYWORD: + quals.append("**") + if p.default is not empty: + quals.append("=") + params.append( + Param[ + typing.Literal[p.name if has_name else None], + _ann(p.annotation), + typing.Literal[*quals] if quals else typing.Never, + ] + ) + + return typing.Callable[params, _ann(sig.return_annotation)] + + +@_SpecialForm +def Members(self, tp): + # TODO: Support unions + o = type_eval.eval_typing(tp) + hints = get_annotated_type_hints(o, include_extras=True) + + attrs = [ + Member[typing.Literal[n], t, typing.Never, d] + for n, (t, d) in hints.items() + ] + + for name, attr in o.__dict__.items(): + if isinstance(attr, (types.FunctionType, types.MethodType)): + if attr is typing._no_init_or_replace_init: + continue + + # XXX: populate the source field + attrs.append( + Member[ + typing.Literal[name], + _function_type(attr, is_method=True), + typing.Literal["ClassVar"], + ] + ) + + return tuple[*attrs] ################################################################## -# IDEA: If we wanted to be more like typescript, we could make this -# the only acceptable argument to an `in` loop (and possibly rename it -# Iter?). We'd maybe drop DirProperties and use KeyOf or something -# instead... + +@_SpecialForm +def Iter(self, tp): + tp = type_eval.eval_typing(tp) + if ( + _typing_inspect.is_generic_alias(tp) + and tp.__origin__ is tuple + and (not tp.__args__ or tp.__args__[-1] is not Ellipsis) + ): + return tp.__args__ + else: + # XXX: Or should we return []? + raise TypeError( + f"Invalid type argument to Iter: {tp} is not a fixed-length tuple" + ) @_SpecialForm -def IterUnion(self, tp): +def FromUnion(self, tp): + tp = type_eval.eval_typing(tp) if isinstance(tp, types.UnionType): - return tp.__args__ + return tuple[*tp.__args__] else: - return [tp] + return tuple[tp] ################################################################## @@ -119,13 +229,14 @@ def GetAttr(self, arg): # TODO: Unions, the prop missing, etc! lhs, prop = arg # XXX: extras? - return typing.get_type_hints(lhs)[prop] + name = _from_literal(type_eval.eval_typing(prop)) + return typing.get_type_hints(type_eval.eval_typing(lhs))[name] @_SpecialForm def GetArg(self, arg): tp, idx = arg - args = typing.get_args(tp) + args = typing.get_args(type_eval.eval_typing(tp)) try: return args[idx] except IndexError: @@ -138,14 +249,28 @@ def GetArg(self, arg): @_SpecialForm def IsSubtype(self, arg): lhs, rhs = arg - # return type_eval.issubtype( - # type_eval.eval_typing(lhs), type_eval.eval_typing(rhs) - # ) - return type_eval.issubtype(lhs, rhs) + return type_eval.issubtype( + type_eval.eval_typing(lhs), + type_eval.eval_typing(rhs), + ) + + +@_SpecialForm +def IsSubSimilar(self, arg): + lhs, rhs = arg + return type_eval.issubsimilar( + type_eval.eval_typing(lhs), + type_eval.eval_typing(rhs), + ) + + +Is = IsSubSimilar ################################################################## +# TODO: unions! Slice, Concat + class _StringLiteralOp: def __init__(self, op: typing.Callable[[str], str]): @@ -164,10 +289,27 @@ def __getitem__(self, arg): ################################################################## -@_SpecialForm -def NewProtocol(self, val: typing.Sequence[Property]): +# XXX: We definitely can't use the normal _SpecialForm cache here +# directly, since we depend on the context's current_alias. +# Maybe we can add that to the cache, though. +# (Or maybe we need to never use the cache??) +@_NoCacheSpecialForm +def NewProtocol(self, val: Member | tuple[Member, ...]): + if not isinstance(val, tuple): + val = (val,) + + etyps = [type_eval.eval_typing(t) for t in val] + dct: dict[str, object] = {} - dct["__annotations__"] = {prop.name: prop.type for prop in val} + dct["__annotations__"] = { + # XXX: Should eval_typing on the etyps evaluate the arguments?? + _from_literal(type_eval.eval_typing(typing.get_args(prop)[0])): + # XXX: We maybe (probably?) want to eval_typing the RHS, but + # we have infinite recursion issues in test_eval_types_2... + # type_eval.eval_typing(typing.get_args(prop)[1]) + typing.get_args(prop)[1] + for prop in etyps + } module_name = __name__ name = "NewProtocol"