Skip to content

Commit

Permalink
get_type_hints
Browse files Browse the repository at this point in the history
  • Loading branch information
KotlinIsland committed Nov 19, 2024
1 parent fc45b1a commit da94223
Show file tree
Hide file tree
Showing 2 changed files with 173 additions and 0 deletions.
141 changes: 141 additions & 0 deletions basedtyping/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"as_functiontype",
"ForwardRef",
"BASEDMYPY_TYPE_CHECKING",
"get_type_hints",
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -743,3 +744,143 @@ def _type_check(arg: object, msg: str) -> object:
if not callable(arg):
raise TypeError(f"{msg} Got {arg!r:.100}.")
return arg


if sys.version_info < (3, 9):

def _strip_annotations(x: object) -> object:
return x
else:
_strip_annotations = typing._strip_annotations # type: ignore[attr-defined]


def get_type_hints( # type: ignore[no-any-explicit]
obj: object
| Callable[..., object]
| FunctionType[..., object]
| types.BuiltinFunctionType[..., object]
| types.MethodType
| types.ModuleType
| types.WrapperDescriptorType
| types.MethodWrapperType
| types.MethodDescriptorType,
globalns: dict[str, object] | None = None,
localns: dict[str, object] | None = None,
include_extras: bool = False, # noqa: FBT001, FBT002
) -> dict[str, object]:
"""Return type hints for an object.
same as `typing.get_type_hints` except adds the class to the scope:
```py
class Base:
def __init_subclass__(cls):
get_type_hints(cls)
class A(Base):
a: A
```
This is often the same as obj.__annotations__, but it handles
forward references encoded as string literals and recursively replaces all
'Annotated[T, ...]' with 'T' (unless 'include_extras=True').
The argument may be a module, class, method, or function. The annotations
are returned as a dictionary. For classes, annotations include also
inherited members.
TypeError is raised if the argument is not of a type that can contain
annotations, and an empty dictionary is returned if no annotations are
present.
BEWARE -- the behavior of globalns and localns is counterintuitive
(unless you are familiar with how eval() and exec() work). The
search order is locals first, then globals.
- If no dict arguments are passed, an attempt is made to use the
globals from obj (or the respective module's globals for classes),
and these are also used as the locals. If the object does not appear
to have globals, an empty dictionary is used. For classes, the search
order is globals first then locals.
- If one dict argument is passed, it is used for both globals and
locals.
- If two dict arguments are passed, they specify globals and
locals, respectively.
"""
if getattr(obj, "__no_type_check__", None): # type: ignore[no-any-expr]
return {}
# Classes require a special treatment.
if isinstance(obj, type): # type: ignore[no-any-expr]
hints = {}
for base in reversed(obj.__mro__):
if globalns is None:
base_globals = getattr(sys.modules.get(base.__module__, None), "__dict__", {}) # type: ignore[no-any-expr]
else:
base_globals = globalns
ann = base.__dict__.get("__annotations__", {}) # type: ignore[no-any-expr]
if isinstance(ann, types.GetSetDescriptorType): # type: ignore[no-any-expr]
ann = {} # type: ignore[no-any-expr]
base_locals = dict(vars(base)) if localns is None else localns # type: ignore[no-any-expr]
if localns is None and globalns is None:
# This is surprising, but required. Before Python 3.10,
# get_type_hints only evaluated the globalns of
# a class. To maintain backwards compatibility, we reverse
# the globalns and localns order so that eval() looks into
# *base_globals* first rather than *base_locals*.
# This only affects ForwardRefs.
base_globals, base_locals = base_locals, base_globals
if base is obj:
base_locals[obj.__name__] = obj # type: ignore[no-any-expr]
for name, value in ann.items(): # type: ignore[no-any-expr]
if value is None: # type: ignore[no-any-expr]
value = type(None)
if isinstance(value, str): # type: ignore[no-any-expr]
if sys.version_info < (3, 9):
value = ForwardRef(value, is_argument=False)
else:
value = ForwardRef(value, is_argument=False, is_class=True)
value = typing._eval_type(value, base_globals, base_locals, recursive_guard=1) # type: ignore[attr-defined, no-any-expr]
hints[name] = value # type: ignore[no-any-expr]

return hints if include_extras else {k: _strip_annotations(t) for k, t in hints.items()} # type: ignore[no-any-expr]

if globalns is None:
if isinstance(obj, types.ModuleType): # type: ignore[no-any-expr]
globalns = obj.__dict__
else:
nsobj = obj
# Find globalns for the unwrapped object.
while hasattr(nsobj, "__wrapped__"):
nsobj = nsobj.__wrapped__ # type: ignore[no-any-expr]
globalns = getattr(nsobj, "__globals__", {}) # type: ignore[no-any-expr]
if localns is None:
localns = globalns
elif localns is None:
localns = globalns
hints = getattr(obj, "__annotations__", None) # type: ignore[assignment, no-any-expr]
if hints is None: # type: ignore[no-any-expr, redundant-expr]
# Return empty annotations for something that _could_ have them.
if isinstance(obj, typing._allowed_types): # type: ignore[ unreachable]
return {}
raise TypeError(f"{obj!r} is not a module, class, method, " "or function.")
hints = dict(hints) # type: ignore[no-any-expr]
for name, value in hints.items(): # type: ignore[no-any-expr]
if value is None: # type: ignore[no-any-expr]
value = type(None)
if isinstance(value, str): # type: ignore[no-any-expr]
# class-level forward refs were handled above, this must be either
# a module-level annotation or a function argument annotation
# TODO: roll our own ForwardRef
# https://github.com/KotlinIsland/basedtyping/issues/126
if sys.version_info < (3, 9):
value = ForwardRef(value, is_argument=not isinstance(obj, types.ModuleType)) # type: ignore[no-any-expr]
else:
value = ForwardRef(
value,
is_argument=not isinstance(cast(object, obj), types.ModuleType),
is_class=False,
)
hints[name] = typing._eval_type(value, globalns, localns) # type: ignore[no-any-expr, attr-defined]
return hints if include_extras else {k: _strip_annotations(t) for k, t in hints.items()} # type: ignore[no-any-expr]
32 changes: 32 additions & 0 deletions tests/test_get_type_hints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

import re
from typing import Literal, Union

from typing_extensions import override

from basedtyping import get_type_hints


def test_get_type_hints():
result: object = None

class Base:
@override
def __init_subclass__(cls):
nonlocal result
result = get_type_hints(cls)

class A(Base):
a: A

assert result == {"a": A}


def test_get_type_hints_based():
class A:
a: Union[re.RegexFlag.ASCII, re.RegexFlag.DOTALL]

assert get_type_hints(A) == {
"a": Union[Literal[re.RegexFlag.ASCII], Literal[re.RegexFlag.DOTALL]] # noqa: PYI030
}

0 comments on commit da94223

Please sign in to comment.