diff --git a/spec-draft.rst b/spec-draft.rst new file mode 100644 index 0000000..26fb707 --- /dev/null +++ b/spec-draft.rst @@ -0,0 +1,63 @@ + +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. + + +:: + + = ... + | 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[)> +] + + | Union[)>] + | Union[)> +] + + | GetAttr[, ] + | 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[, ] + | not + | and + | or + # Do we want these next two? + | any()>) + | all()>) + + = Property[, ] + + = + T , + | * , + + + = [ T + * ] + = + for in IterUnion + | for , in DirProperties + # TODO: callspecs + # TODO: variadic args (tuples, callables) + = + if + + + +``type-for(T)`` and ``variadic-type-arg(T)`` are parameterized grammar +rules, which can take different diff --git a/tests/test_call.py b/tests/test_call.py index ae4abc7..031ab6e 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -9,7 +9,7 @@ def func[C: next.CallSpec]( *args: C.args, **kwargs: C.kwargs ) -> next.NewProtocol[ - [next.Property[c.name, int] for c in next.CallSpecKwargs[C]] + *[next.Property[c.name, int] for c in next.CallSpecKwargs[C]] ]: ... diff --git a/tests/test_qblike.py b/tests/test_qblike.py new file mode 100644 index 0000000..b0e7fae --- /dev/null +++ b/tests/test_qblike.py @@ -0,0 +1,112 @@ +import textwrap + +from typemap.type_eval import eval_call, eval_typing +from typemap import typing as next + +from . import format_helper + + +class Property[T]: + pass + + +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] + ] +] + +# Conditional type alias! +type FilterLinks[T] = ( + Link[PropsOnly[next.GetArg[T, 0]]] if next.IsSubtype[T, Link] else T +) + + +# Basic filtering +class Tgt2: + pass + + +class Tgt: + name: Property[str] + tgt2: Link[Tgt2] + + +class A: + x: Property[int] + y: Property[bool | None] + z: Link[Tgt] + w: Property[list[str]] + + +def select[C: next.CallSpec]( + __rcv: A, *args: C.args, **kwargs: C.kwargs +) -> next.NewProtocol[ + [ + next.Property[ + c.name, + FilterLinks[next.GetAttr[A, c.name]], + ] + for c in next.CallSpecKwargs[C] + ] +]: ... + + +def test_qblike_1(): + ret = eval_call( + select, + A(), + x=True, + w=True, + ) + fmt = format_helper.format_class(ret) + + assert fmt == textwrap.dedent("""\ + class select[...]: + x: tests.test_qblike.Property[int] + w: tests.test_qblike.Property[list[str]] + """) + + +def test_qblike_2(): + ret = eval_typing(PropsOnly[A]) + fmt = format_helper.format_class(ret) + + assert fmt == textwrap.dedent("""\ + class PropsOnly[tests.test_qblike.A]: + x: tests.test_qblike.Property[int] + y: tests.test_qblike.Property[bool | None] + w: tests.test_qblike.Property[list[str]] + """) + + +def test_qblike_3(): + ret = eval_call( + select, + A(), + x=True, + w=True, + z=True, + ) + fmt = format_helper.format_class(ret) + + assert fmt == textwrap.dedent("""\ + class select[...]: + x: tests.test_qblike.Property[int] + w: tests.test_qblike.Property[list[str]] + z: tests.test_qblike.Link[PropsOnly[tests.test_qblike.Tgt]] + """) + + tgt = eval_typing(next.GetAttr[ret, "z"].__args__[0]) + fmt = format_helper.format_class(tgt) + + assert fmt == textwrap.dedent("""\ + class PropsOnly[tests.test_qblike.Tgt]: + name: tests.test_qblike.Property[str] + """) diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 98842a4..9923bbc 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -55,12 +55,47 @@ class Final(Mine, Ordinary, Wrapper[float], AnotherBase[float], Last[int]): type AllOptional[T] = next.NewProtocol[ - [next.Property[p.name, p.type | None] for p in next.DirProperties[T]] + *[next.Property[p.name, p.type | None] for p in next.DirProperties[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 Prims[T] = next.NewProtocol[ + *[ + next.Property[name, typ] + for name, typ in next.DirProperties[T] + if next.IsSubtype[typ, int | str] + ] +] + + +type NoLiterals[T] = next.NewProtocol[ + *[ + next.Property[ + p.name, + typing.Union[ + *[ + t + for t in next.IterUnion[p.type] + # 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] + ] + ], + ] + for p in next.DirProperties[T] + ] +] + + def test_type_dir_1(): d = eval_typing(Final) @@ -92,3 +127,41 @@ class AllOptional[tests.test_type_dir.Final]: x: tests.test_type_dir.Wrapper[int | None] | None ordinary: str | None """) + + +def test_type_dir_3(): + d = eval_typing(Capitalize[Final]) + + assert format_helper.format_class(d) == textwrap.dedent("""\ + class Capitalize[tests.test_type_dir.Final]: + LAST: int | typing.Literal[True] + III: str | int | typing.Literal['gotcha!'] + T: dict[str, str | int | typing.Literal['gotcha!']] + KKK: ~K + X: tests.test_type_dir.Wrapper[int | None] + ORDINARY: str + """) + + +def test_type_dir_4(): + d = eval_typing(Prims[Final]) + + assert format_helper.format_class(d) == textwrap.dedent("""\ + class Prims[tests.test_type_dir.Final]: + last: int | typing.Literal[True] + ordinary: str + """) + + +def test_type_dir_5(): + d = eval_typing(NoLiterals[Final]) + + assert format_helper.format_class(d) == textwrap.dedent("""\ + class NoLiterals[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 + """) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index 26b4a45..be32c2b 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -1,5 +1,6 @@ import textwrap import typing +import unittest from typemap import typing as next from typemap.type_eval import eval_typing @@ -22,15 +23,16 @@ class F_int(F[int]): type MapRecursive[A] = next.NewProtocol[ - [ + *[ ( next.Property[p.name, OrGotcha[p.type]] - if p.type is not A + if not next.IsSubtype[p.type, A] else next.Property[p.name, OrGotcha[MapRecursive[A]]] ) + # XXX: type language - concatenating DirProperties is sketchy for p in (next.DirProperties[A] + next.DirProperties[F_int]) - ] - + [next.Property["control", float]] # noqa: F821 + ], + next.Property[typing.Literal["control"], float], ] @@ -60,3 +62,15 @@ class MapRecursive[tests.test_type_eval.Recursive]: fff: int | typing.Literal['gotcha!'] control: float """) + + +# XXX: should this work??? +# probably not? +@unittest.skip +def test_eval_types_3(): + evaled = eval_typing(F[bool]) + + assert format_helper.format_class(evaled) == textwrap.dedent("""\ + class F[bool]: + fff: bool + """) diff --git a/typemap/type_eval/__init__.py b/typemap/type_eval/__init__.py index 80690a4..fabbaee 100644 --- a/typemap/type_eval/__init__.py +++ b/typemap/type_eval/__init__.py @@ -1,6 +1,6 @@ from ._eval_call import eval_call from ._eval_typing import eval_typing, _get_current_context +from ._subtype import issubtype -__all__ = ("eval_typing", "eval_call", "_get_current_context") -1 +__all__ = ("eval_typing", "eval_call", "issubtype", "_get_current_context") diff --git a/typemap/type_eval/_subtype.py b/typemap/type_eval/_subtype.py new file mode 100644 index 0000000..f7ed7dc --- /dev/null +++ b/typemap/type_eval/_subtype.py @@ -0,0 +1,85 @@ +# import annotationlib + +# import contextlib +# import contextvars +# import dataclasses +# import functools +# import inspect +# import sys +# import types +import typing + + +# from . import _eval_type +from . import _typing_inspect + + +__all__ = ("issubtype",) + + +def issubtype(lhs: typing.Any, rhs: typing.Any) -> bool: + # TODO: Need to handle a lot of 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(issubtype(lhs, r) for r in typing.get_args(rhs)) + elif _typing_inspect.is_union_type(lhs): + return all(issubtype(t, rhs) for t in typing.get_args(lhs)) + + 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 issubtype(type(typing.get_args(lhs)[0]), rhs) + + # C[A] <:? D + elif bool( + _typing_inspect.is_generic_alias(lhs) + # 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) + # C <:? D[A] + elif bool( + _typing_inspect.is_valid_isinstance_arg(lhs) + and _typing_inspect.is_generic_alias(rhs) + ): + return issubtype(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: What to do about C[A] <:? D[B]??? + + # TODO: Protocols??? + + # TODO: We will need to have some sort of hook to support runtime + # checking of typechecker extensions. + # + # We could have restrictions if we are willing to document them. + + # This will probably fail + return issubclass(lhs, rhs) diff --git a/typemap/type_eval/_typing_inspect.py b/typemap/type_eval/_typing_inspect.py new file mode 100644 index 0000000..66a60ed --- /dev/null +++ b/typemap/type_eval/_typing_inspect.py @@ -0,0 +1,136 @@ +# SPDX-PackageName: gel-python +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright Gel Data Inc. and the contributors. + + +import typing + +from typing import ( + Annotated, + Any, + ClassVar, + ForwardRef, + Literal, + TypeGuard, + TypeVar, + Union, + get_args, + get_origin, +) +from typing import _GenericAlias, _SpecialGenericAlias # type: ignore [attr-defined] # noqa: PLC2701 +from typing_extensions import TypeAliasType, TypeVarTuple, Unpack +from types import GenericAlias, UnionType + + +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] + + +def is_generic_alias(t: Any) -> TypeGuard[GenericAlias]: + return isinstance(t, (GenericAlias, _GenericAlias, _SpecialGenericAlias)) + + +def is_valid_type_arg(t: Any) -> bool: + return isinstance(t, type) or ( + is_generic_alias(t) and get_origin(t) is not Unpack # type: ignore [comparison-overlap] + ) + + +# In Python 3.10 isinstance(tuple[int], type) is True, but +# issubclass will fail if you pass such type to it. +def is_valid_isinstance_arg(t: Any) -> typing.TypeGuard[type[Any]]: + return isinstance(t, type) and not is_generic_alias(t) + + +def is_type_alias(t: Any) -> TypeGuard[TypeAliasType]: + return isinstance(t, TypeAliasType) and not is_generic_alias(t) + + +def is_type_var(t: Any) -> bool: + return type(t) is TypeVar + + +if (TypingTypeVarTuple := getattr(typing, "TypeVarTuple", None)) is not None: + + def is_type_var_tuple(t: Any) -> bool: + tt = type(t) + return tt is TypeVarTuple or tt is TypingTypeVarTuple + + def is_type_var_or_tuple(t: Any) -> bool: + tt = type(t) + return tt is TypeVar or tt is TypeVarTuple or tt is TypingTypeVarTuple +else: + + def is_type_var_tuple(t: Any) -> bool: + return type(t) is TypeVarTuple + + def is_type_var_or_tuple(t: Any) -> bool: + tt = type(t) + return tt is TypeVar or tt is TypeVarTuple + + +def is_type_var_tuple_unpack(t: Any) -> TypeGuard[GenericAlias]: + return ( + is_generic_alias(t) + and get_origin(t) is Unpack # type: ignore [comparison-overlap] + and is_type_var_tuple(get_args(t)[0]) + ) + + +def is_type_var_or_tuple_unpack(t: Any) -> bool: + return is_type_var(t) or is_type_var_tuple_unpack(t) + + +def is_generic_type_alias(t: Any) -> TypeGuard[GenericAlias]: + return is_generic_alias(t) and isinstance(get_origin(t), TypeAliasType) + + +def is_annotated(t: Any) -> TypeGuard[Annotated[Any, ...]]: + return is_generic_alias(t) and get_origin(t) is Annotated # type: ignore [comparison-overlap] + + +def is_forward_ref(t: Any) -> TypeGuard[ForwardRef]: + return isinstance(t, ForwardRef) + + +def contains_forward_refs(t: Any) -> bool: + if isinstance(t, (ForwardRef, str)): + # A direct ForwardRef or a PEP563/649 postponed annotation + return True + elif isinstance(t, TypeAliasType): + # PEP 695 type alias: unwrap and recurse + return contains_forward_refs(t.__value__) + elif args := get_args(t): + # Generic type: unwrap and recurse + return any(contains_forward_refs(arg) for arg in args) + else: + # No forward refs. + return False + + +def is_union_type(t: Any) -> TypeGuard[UnionType]: + return ( + (is_generic_alias(t) and get_origin(t) is Union) # type: ignore [comparison-overlap] + or isinstance(t, UnionType) + ) + + +def is_optional_type(t: Any) -> TypeGuard[UnionType]: + return is_union_type(t) and type(None) in get_args(t) + + +def is_literal(t: Any) -> bool: + return is_generic_alias(t) and get_origin(t) is Literal # type: ignore [comparison-overlap] + + +__all__ = ( + "is_annotated", + "is_classvar", + "is_forward_ref", + "is_generic_alias", + "is_generic_type_alias", + "is_literal", + "is_optional_type", + "is_type_alias", + "is_union_type", +) diff --git a/typemap/typing.py b/typemap/typing.py index 7de8c92..8bb8744 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -7,6 +7,9 @@ from typemap import type_eval +_SpecialForm: typing.Any = typing._SpecialForm + + @dataclass(frozen=True) class CallSpec: pass @@ -33,7 +36,7 @@ class _CallKwarg: name: str -@typing._SpecialForm # type: ignore[call-arg] +@_SpecialForm def CallSpecKwargs(self, spec: _CallSpecWrapper) -> list[_CallKwarg]: ff = types.FunctionType( spec._func.__code__, @@ -56,9 +59,17 @@ def CallSpecKwargs(self, spec: _CallSpecWrapper) -> list[_CallKwarg]: ################################################################## +def _from_literal(val): + if isinstance(val, typing._LiteralGenericAlias): # type: ignore[attr-defined] + val = val.__args__[0] + return val + + class PropertyMeta(type): - def __getitem__(cls, val: tuple[str, type]): - return cls(name=val[0], type=val[1]) + 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) @dataclass(frozen=True) @@ -70,43 +81,108 @@ class Property(metaclass=PropertyMeta): ################################################################## -class DirPropertiesMeta(type): - def __getitem__(cls, tp): - o = type_eval.eval_typing(tp) - hints = typing.get_type_hints(o, include_extras=True) - return [Property(n, t) for n, t in hints.items()] +# I want to experiment with this being a tuple. +class _OutProperty(typing.NamedTuple): + name: str + type: type -class DirProperties(metaclass=DirPropertiesMeta): - pass +@_SpecialForm +def DirProperties(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()] + + +################################################################## + +# 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 IterUnion(self, tp): + if isinstance(tp, types.UnionType): + return tp.__args__ + else: + return [tp] ################################################################## -class NewProtocolMeta(type): - def __getitem__(cls, val: list[Property]): - dct: dict[str, object] = {} - dct["__annotations__"] = {prop.name: prop.type for prop in val} +@_SpecialForm +def GetAttr(self, arg): + # TODO: Unions, the prop missing, etc! + lhs, prop = arg + # XXX: extras? + return typing.get_type_hints(lhs)[prop] + - module_name = __name__ - name = "NewProtocol" +@_SpecialForm +def GetArg(self, arg): + tp, idx = arg + args = typing.get_args(tp) + try: + return args[idx] + except IndexError: + return typing.Never - # If the type evaluation context - ctx = type_eval._get_current_context() - if ctx.current_alias: - if isinstance(ctx.current_alias, types.GenericAlias): - name = str(ctx.current_alias) - else: - name = f"{ctx.current_alias.__name__}[...]" - module_name = ctx.current_alias.__module__ - dct["__module__"] = module_name +################################################################## - mcls: type = type(typing.cast(type, typing.Protocol)) - cls = mcls(name, (typing.Protocol,), dct) - return cls +@_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) -class NewProtocol(metaclass=NewProtocolMeta): - pass + +################################################################## + + +class _StringLiteralOp: + def __init__(self, op: typing.Callable[[str], str]): + self.op = op + + def __getitem__(self, arg): + return typing.Literal[self.op(_from_literal(arg))] + + +Uppercase = _StringLiteralOp(op=str.upper) +Lowercase = _StringLiteralOp(op=str.lower) +Capitalize = _StringLiteralOp(op=str.capitalize) +Uncapitalize = _StringLiteralOp(op=lambda s: s[0:1].lower() + s[1:]) + + +################################################################## + + +@_SpecialForm +def NewProtocol(self, val: typing.Sequence[Property]): + dct: dict[str, object] = {} + dct["__annotations__"] = {prop.name: prop.type for prop in val} + + module_name = __name__ + name = "NewProtocol" + + # If the type evaluation context + ctx = type_eval._get_current_context() + if ctx.current_alias: + if isinstance(ctx.current_alias, types.GenericAlias): + name = str(ctx.current_alias) + else: + name = f"{ctx.current_alias.__name__}[...]" + module_name = ctx.current_alias.__module__ + + dct["__module__"] = module_name + + mcls: type = type(typing.cast(type, typing.Protocol)) + cls = mcls(name, (typing.Protocol,), dct) + return cls