diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index e10179f..30c345d 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -2,7 +2,6 @@ import typing from typing import Literal, Never, TypeVar, Union -import pytest from typemap.type_eval import eval_typing from typemap.typing import ( @@ -286,15 +285,33 @@ def test_type_dir_7(): assert d is int +class Simple[T]: + simple: T + + +class Funny[T](Simple[list[T]]): + pass + + +class Funny2(Funny[int]): + pass + + +def test_type_dir_8(): + d = eval_typing(Funny2) + + assert format_helper.format_class(d) == textwrap.dedent("""\ + class Funny2: + simple: list[int] + """) + + def _get_member(members, name): return next( iter(m for m in members.__args__ if m.__args__[0].__args__[0] == name) ) -@pytest.mark.xfail( - reason="Members does not actually report the correct origin class!" -) def test_type_members_attr_(): d = eval_typing(Members[Final]) member = _get_member(d, "ordinary") @@ -307,7 +324,7 @@ def test_type_members_func_1a(): d = eval_typing(Members[Final]) member = _get_member(d, "foo") assert typing.get_origin(member) is Member - name, typ, quals, _origin = typing.get_args(member) + name, typ, quals, origin = typing.get_args(member) assert name == typing.Literal["foo"] assert quals == typing.Literal["ClassVar"] @@ -315,23 +332,13 @@ def test_type_members_func_1a(): str(typ) == "\ typing.Callable[[\ -typemap.typing.Param[typing.Literal['self'], tests.test_type_dir.Final, typing.Never], \ +typemap.typing.Param[typing.Literal['self'], tests.test_type_dir.Base[int], typing.Never], \ typemap.typing.Param[typing.Literal['a'], int | None, typing.Never], \ typemap.typing.Param[typing.Literal['b'], int, typing.Literal['keyword', \ 'default']]], \ dict[str, int]]" ) - -@pytest.mark.xfail( - reason="Members does not actually report the correct origin class!" -) -def test_type_members_func_1b(): - # This should be merged up with 1a once it is fixed - d = eval_typing(Members[Final]) - member = _get_member(d, "foo") - assert typing.get_origin(member) is Member - _, _, _, origin = typing.get_args(member) assert origin.__name__ == "Base[int]" @@ -346,7 +353,7 @@ def test_type_members_func_2(): assert ( str(typ) == "\ -classmethod[tests.test_type_dir.Final, tuple[typemap.typing.Param[typing.Literal['a'], int | None, typing.Never], typemap.typing.Param[typing.Literal['b'], ~K, typing.Never]], dict[str, int]]" +classmethod[tests.test_type_dir.Base[int], tuple[typemap.typing.Param[typing.Literal['a'], int | None, typing.Never], typemap.typing.Param[typing.Literal['b'], ~K, typing.Never]], dict[str, int]]" ) diff --git a/typemap/type_eval/_apply_generic.py b/typemap/type_eval/_apply_generic.py index 4fd60fb..fa13d26 100644 --- a/typemap/type_eval/_apply_generic.py +++ b/typemap/type_eval/_apply_generic.py @@ -4,7 +4,11 @@ import types import typing +from typing import _GenericAlias as typing_GenericAlias # type: ignore [attr-defined] # noqa: PLC2701 + + from . import _eval_typing +from . import _typing_inspect if typing.TYPE_CHECKING: from typing import Any @@ -17,6 +21,7 @@ class Boxed: args: dict[Any, Any] str_args: dict[str, Any] = dataclasses.field(init=False) + mro: list[Boxed] = dataclasses.field(init=False) def __post_init__(self): object.__setattr__( @@ -24,6 +29,17 @@ def __post_init__(self): "str_args", {str(k): v for k, v in self.args.items()}, ) + object.__setattr__( + self, + "mro", + _compute_mro(self), + ) + + def alias_type(self): + if self.args: + return self.cls[*self.args.values()] + else: + return self.cls def __repr__(self): return f"Boxed<{self.cls} {self.args}>" @@ -44,8 +60,17 @@ def dump(self, *, _level: int = 0): b.dump(_level=_level + 1) +def substitute(ty, args): + if ty in args: + return args[ty] + elif isinstance(ty, (typing_GenericAlias, types.GenericAlias)): + return ty.__origin__[*[substitute(t, args) for t in ty.__args__]] + else: + return ty + + def box(cls: type[Any]) -> Boxed: - def _box(cls: type[Any], args: dict[str, Any]) -> Boxed: + def _box(cls: type[Any], args: dict[Any, Any]) -> Boxed: boxed_bases: list[Boxed] = [] orig_bases = cls.__dict__.get("__orig_bases__") @@ -60,14 +85,12 @@ def _box(cls: type[Any], args: dict[str, Any]) -> Boxed: if issubclass(base, typing.Generic): if base_params := getattr(base, "__parameters__", None): + # obase should be _GenericAlias... boxed_args = {} for param, arg in zip( base_params, obase.__args__, strict=True ): - if arg in args: - boxed_args[param] = args[arg] - else: - boxed_args[param] = arg + boxed_args[param] = substitute(arg, args) else: boxed_args = {} @@ -79,23 +102,25 @@ def _box(cls: type[Any], args: dict[str, Any]) -> Boxed: if isinstance(cls, (typing._GenericAlias, types.GenericAlias)): # type: ignore[attr-defined] # XXX this feels out of place, `box()` needs to only accept types. + # this never gets activated now, but I want to basically + # support this later -sully args = dict( zip(cls.__origin__.__parameters__, cls.__args__, strict=True) ) cls = cls.__origin__ else: if params := getattr(cls, "__parameters__", None): - args = {p: p for p in params} + args = {p: _typing_inspect.param_default(p) for p in params} else: args = {} return _box(cls, args) -def merge_boxed_mro(seqs: list[list[Boxed]]) -> list[Boxed]: - res: list[Boxed] = [] +def merge_boxed_mro[T](seqs: list[list[T]]) -> list[T]: + res: list[T] = [] i = 0 - cand: Boxed | None = None + cand: T | None = None while 1: nonemptyseqs = [seq for seq in seqs if seq] if not nonemptyseqs: @@ -119,13 +144,7 @@ def merge_boxed_mro(seqs: list[list[Boxed]]) -> list[Boxed]: def _compute_mro(C: Boxed) -> list[Boxed]: - return merge_boxed_mro( - [[C]] + list(map(_compute_mro, C.bases)) + [list(C.bases)] - ) - - -def compute_mro(C: type[Any]) -> list[Boxed]: - return _compute_mro(box(C)) + return merge_boxed_mro([[C]] + [b.mro for b in C.bases] + [list(C.bases)]) def make_func( @@ -159,87 +178,159 @@ def make_func( return new_func -def apply(cls: type[Any]) -> dict[str, Any]: - mro_boxed = compute_mro(cls) - +def _get_local_defns(boxed: Boxed) -> tuple[dict[str, Any], dict[str, Any]]: annos: dict[str, Any] = {} dct: dict[str, Any] = {} - for boxed in reversed(mro_boxed): - if af := getattr(boxed.cls, "__annotate__", None): - # Class has annotations, let's resolve generic arguments - - args = tuple( - types.CellType( - boxed.cls.__dict__ - if name == "__classdict__" - else boxed.str_args[name] - ) - for name in af.__code__.co_freevars - ) + if af := getattr(boxed.cls, "__annotate__", None): + # Class has annotations, let's resolve generic arguments - ff = types.FunctionType( - af.__code__, af.__globals__, af.__name__, None, args + args = tuple( + types.CellType( + boxed.cls.__dict__ + if name == "__classdict__" + else boxed.str_args[name] ) - rr = ff(annotationlib.Format.VALUE) - - if rr: - for k, v in rr.items(): - if isinstance(v, str): - # Handle cases where annotation is explicitly a string, - # e.g.: - # - # class Foo[X]: - # x: "Foo[X | None]" - - annos[k] = eval(v, af.__globals__, boxed.str_args) - else: - annos[k] = v - elif af := getattr(boxed.cls, "__annotations__", None): - annos.update(af) - - for name, orig in boxed.cls.__dict__.items(): - if name in typing.EXCLUDED_ATTRIBUTES: # type: ignore[attr-defined] - continue - - stuff = inspect.unwrap(orig) - - if isinstance(stuff, types.FunctionType): - if af := getattr(stuff, "__annotate__", None): - params = dict( - zip( - map(str, stuff.__type_params__), - stuff.__type_params__, - strict=True, - ) - ) + for name in af.__code__.co_freevars + ) - args = tuple( - types.CellType( - boxed.cls.__dict__ - if name == "__classdict__" - else params[name] - if name in params - else boxed.str_args[name] - ) - for name in af.__code__.co_freevars + ff = types.FunctionType( + af.__code__, af.__globals__, af.__name__, None, args + ) + rr = ff(annotationlib.Format.VALUE) + + if rr: + for k, v in rr.items(): + if isinstance(v, str): + # Handle cases where annotation is explicitly a string, + # e.g.: + # + # class Foo[X]: + # x: "Foo[X | None]" + + annos[k] = eval(v, af.__globals__, boxed.str_args) + else: + annos[k] = v + elif af := getattr(boxed.cls, "__annotations__", None): + annos.update(af) + + for name, orig in boxed.cls.__dict__.items(): + if name in typing.EXCLUDED_ATTRIBUTES: # type: ignore[attr-defined] + continue + + stuff = inspect.unwrap(orig) + + if isinstance(stuff, types.FunctionType): + if af := getattr(stuff, "__annotate__", None): + params = dict( + zip( + map(str, stuff.__type_params__), + stuff.__type_params__, + strict=True, ) + ) - ff = types.FunctionType( - af.__code__, af.__globals__, af.__name__, None, args + args = tuple( + types.CellType( + boxed.cls.__dict__ + if name == "__classdict__" + else params[name] + if name in params + else boxed.str_args[name] ) - rr = ff(annotationlib.Format.VALUE) + for name in af.__code__.co_freevars + ) + + ff = types.FunctionType( + af.__code__, af.__globals__, af.__name__, None, args + ) + rr = ff(annotationlib.Format.VALUE) + + dct[name] = make_func(orig, rr) + elif af := getattr(stuff, "__annotations__", None): + dct[name] = stuff + + return annos, dct - dct[name] = make_func(orig, rr) - elif af := getattr(stuff, "__annotations__", None): - dct[name] = stuff - for k, v in annos.items(): - annos[k] = _eval_typing.eval_typing(v) +def _type_repr(t: Any) -> str: + if isinstance(t, type): + if t.__module__ == "builtins": + return t.__qualname__ + else: + return f"{t.__module__}.{t.__qualname__}" + else: + return repr(t) - for k, v in dct.items(): - dct[k] = _eval_typing.eval_typing(v) - dct["__annotations__"] = annos - dct["__generalized_mro__"] = mro_boxed - return dct +def apply( + cls: type[Any], ctx: _eval_typing.EvalContext +) -> type[_eval_typing._EvalProxy]: + cls_boxed = box(cls) + mro_boxed = cls_boxed.mro + + # TODO: I think we want to create the whole mro chain... + # before we evaluate the contents? + + # FIXME: right now we flatten out all the attributes... but should we?? + # XXX: Yeah, a lot of work is put into copying everything into every + # class and it is not worth it, at all. + + new = {} + + # Run through the mro and populate everything + for boxed in reversed(mro_boxed): + # We create it early so we can add it to seen, to handle recursion + # XXX: currently we are doing this even for types with no generics... + # that simplifies the flow... - probably keep it this way until + # we stop flattening attributes into every class + name = boxed.cls.__name__ + cboxed: Any + + args = tuple(boxed.args.values()) + args_str = ", ".join(_type_repr(a) for a in args) + fullname = f"{name}[{args_str}]" if args_str else name + cboxed = type( + fullname, + (_eval_typing._EvalProxy,), + { + "__module__": boxed.cls.__module__, + "__name__": fullname, + "__origin__": boxed.cls, + "__local_args__": args, + }, + ) + ctx.seen[boxed.alias_type()] = new[boxed] = cboxed + + annos: dict[str, Any] = {} + dct: dict[str, Any] = {} + sources: dict[str, Any] = {} + + cboxed.__local_annotations__, cboxed.__local_defns__ = _get_local_defns( + boxed + ) + for base in reversed(boxed.mro): + cbase = new[base] + annos.update(cbase.__local_annotations__) + dct.update(cbase.__local_defns__) # uh. + for k in [*cbase.__local_annotations__, *cbase.__local_defns__]: + sources[k] = cbase + + cboxed.__defn_names__ = set(dct) + cboxed.__annotations__ = annos + cboxed.__defn_sources__ = sources + cboxed.__generalized_mro__ = [new[b] for b in boxed.mro] + + for k, v in dct.items(): + setattr(cboxed, k, v) + + # Run through the mro again and evaluate everything + for cboxed in new.values(): + for k, v in cboxed.__annotations__.items(): + cboxed.__annotations__[k] = _eval_typing._eval_types(v, ctx=ctx) + + for k in cboxed.__defn_names__: + v = cboxed.__dict__[k] + setattr(cboxed, k, _eval_typing._eval_types(v, ctx=ctx)) + + return new[cls_boxed] diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 23c29e6..65ba807 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -54,6 +54,9 @@ def get_annotated_type_hints(cls, **kwargs): for acls in cls.__mro__: if not hasattr(acls, "__annotations__"): continue + # XXX: This is super janky; we should just use the real mro + # and not flatten things + sources = getattr(acls, "__defn_sources__", {}) for k in acls.__annotations__: if k not in hints: quals = set() @@ -73,7 +76,7 @@ def get_annotated_type_hints(cls, **kwargs): else: break - hints[k] = ty, tuple(sorted(quals)), acls + hints[k] = ty, tuple(sorted(quals)), sources.get(k, acls) # Stop early if we are done. if len(hints) == len(ohints): @@ -84,6 +87,7 @@ def get_annotated_type_hints(cls, **kwargs): def get_annotated_method_hints(tp): hints = {} for ptp in reversed(tp.mro()): + sources = getattr(ptp, "__defn_sources__", {}) for name, attr in ptp.__dict__.items(): if isinstance( attr, @@ -97,10 +101,11 @@ def get_annotated_method_hints(tp): if attr is typing._no_init_or_replace_init: continue + rtp = sources.get(name, ptp) hints[name] = ( - _function_type(attr, receiver_type=ptp), + _function_type(attr, receiver_type=rtp), ("ClassVar",), - ptp, + rtp, ) return hints @@ -302,9 +307,9 @@ def _get_raw_args(tp, base_head, ctx) -> typing.Any: # Scan the fully-annotated MRO to find the base elif gen_mro := getattr(evaled, "__generalized_mro__", None): - for box in gen_mro: - if box.cls is base_head: - return tuple(box.args.values()) + for anc in gen_mro: + if _typing_inspect.get_head(anc) is base_head: + return anc.__local_args__ return None else: @@ -423,10 +428,7 @@ def _get_defaults(base_head): return (typing.Any, SpecialFormEllipsis) if params := _get_params(base_head): - return tuple( - typing.Any if t.__default__ == typing.NoDefault else t.__default__ - for t in params - ) + return tuple(_typing_inspect.param_default(p) for p in params) return (typing.Any,) * arity diff --git a/typemap/type_eval/_eval_typing.py b/typemap/type_eval/_eval_typing.py index 7862d24..bab4cce 100644 --- a/typemap/type_eval/_eval_typing.py +++ b/typemap/type_eval/_eval_typing.py @@ -9,7 +9,7 @@ import types import typing -from typing import _GenericAlias # type: ignore [attr-defined] # noqa: PLC2701 +from typing import _GenericAlias as typing_GenericAlias # type: ignore [attr-defined] # noqa: PLC2701 if typing.TYPE_CHECKING: @@ -256,29 +256,13 @@ def _eval_func( @_eval_types_impl.register def _eval_type_type(obj: type, ctx: EvalContext): if isinstance(obj, type) and issubclass(obj, typing.Generic): - ret = type( - obj.__name__, - (_EvalProxy,), - { - "__module__": obj.__module__, - "__name__": obj.__name__, - "__origin__": obj, - }, - ) - - # Need to add it to `seen` to handle recursion - ctx.seen[obj] = ret try: - ns = _apply_generic.apply(obj) + return _apply_generic.apply(obj, ctx) except Exception: - ctx.seen.pop(obj) + # XXX: should apply handle this? + ctx.seen.pop(obj, None) raise - for k, v in ns.items(): - setattr(ret, k, v) - - return ret - return obj @@ -298,7 +282,12 @@ def _eval_type_alias(obj: typing.TypeAliasType, ctx: EvalContext): @_eval_types_impl.register -def _eval_types_generic(obj: types.GenericAlias, ctx: EvalContext): +def _eval_applied_type_alias(obj: types.GenericAlias, ctx: EvalContext): + """Eval a types.GenericAlias -- typically an applied type alias + + This is typically an application of a type alias... except it can + also be an application of a built-in type (like list, tuple, dict) + """ new_args = tuple(_eval_types(arg, ctx) for arg in obj.__args__) new_obj = obj.__origin__[new_args] # type: ignore[index] @@ -331,9 +320,10 @@ def _eval_types_generic(obj: types.GenericAlias, ctx: EvalContext): @_eval_types_impl.register -def _eval_typing_generic(obj: _GenericAlias, ctx: EvalContext): +def _eval_applied_class(obj: typing_GenericAlias, ctx: EvalContext): + """Eval a typing._GenericAlias -- an applied user-defined class""" # generic *classes* are typing._GenericAlias while generic type - # aliases are # types.GenericAlias? Why in the world. + # aliases are types.GenericAlias? Why in the world. if func := _eval_funcs.get(obj.__origin__): new_args = tuple(_eval_types(arg, ctx) for arg in obj.__args__) ret = func(*new_args, ctx=ctx) diff --git a/typemap/type_eval/_typing_inspect.py b/typemap/type_eval/_typing_inspect.py index ed3b82e..f6195ae 100644 --- a/typemap/type_eval/_typing_inspect.py +++ b/typemap/type_eval/_typing_inspect.py @@ -149,6 +149,10 @@ def is_eval_proxy(t: Any) -> TypeGuard[type[_eval_typing._EvalProxy]]: return isinstance(t, type) and issubclass(t, _eval_typing._EvalProxy) +def param_default(p) -> Any: + return Any if p.__default__ == typing.NoDefault else p.__default__ + + __all__ = ( "is_annotated", "is_forward_ref",