Module refinery.lib.annotations
This module exposes a method for backwards-compatible evaluation of modern type annotations. Starting with Python 3.10, this module only forwards standard library functions, but in earlier versions, annotations are converted to backwards-compatible expressions before evaluation. For example, consider the following modern type annotation:
list[list[int] | str | bool | float] | dict[str, int]
While this is valid as a type annotation with a __future__
import, it does not evaluate as a
Python expression at runtime before 3.10. Therefore, it would be transformed into the following
anntoation before evaluation:
Union[List[Union[List[int], str, bool, float]], Dict[str, int]]
This backwards compatibility layer can be removed as soon as refinery has raised its minimum Python version requirement to Python 3.10 or higher.
Expand source code Browse git
"""
This module exposes a method for backwards-compatible evaluation of modern type annotations.
Starting with Python 3.10, this module only forwards standard library functions, but in earlier
versions, annotations are converted to backwards-compatible expressions before evaluation. For
example, consider the following modern type annotation:
list[list[int] | str | bool | float] | dict[str, int]
While this is valid as a type annotation with a `__future__` import, it does not evaluate as a
Python expression at runtime before 3.10. Therefore, it would be transformed into the following
anntoation before evaluation:
Union[List[Union[List[int], str, bool, float]], Dict[str, int]]
This backwards compatibility layer can be removed as soon as refinery has raised its minimum
Python version requirement to Python 3.10 or higher.
"""
from __future__ import annotations
import sys
import typing
__pdoc__ = {
'evaluate': (
'The same as `eval` on Python 3.10 and beyond, otherwise a backwards-compatibility layer '
'that converts modern type hints back to compatible expressions.'
),
'get_type_hints': (
'Implements the same functionality as `typing.get_type_hints` but uses `evaluate` rather '
'than `eval` when the Python version is below 3.10.'
),
}
__all__ = ['get_type_hints', 'evaluate']
if sys.version_info >= (3, 10):
get_type_hints = typing.get_type_hints
evaluate = eval
else:
import ast
if sys.version_info >= (3, 9):
def _index(n):
return n
elif typing.TYPE_CHECKING:
def _index(n: _T) -> _T:
return typing.cast(_T, ast.Index(value=n))
_T = typing.TypeVar('_T', bound=ast.expr)
else:
def _index(n):
return ast.Index(value=n)
_TYPING_LOOKUP = {}
_TYPING_MODULE = '_imp_typing'
def _into_typing(name: str):
try:
alias = _TYPING_LOOKUP[(name := name.casefold())]
except KeyError:
for t in dir(typing):
if t.casefold() == name:
_TYPING_LOOKUP[name] = alias = t
break
else:
_TYPING_LOOKUP[name] = alias = None
return alias
def get_type_hints(obj):
if getattr(obj, '__no_type_check__', None):
return {}
if isinstance(obj, type):
hints = {}
for base in reversed(obj.__mro__):
gns: dict = sys.modules[base.__module__].__dict__
ann: dict = base.__dict__.get('__annotations__', {})
for name, value in ann.items():
hints[name] = evaluate(value, gns)
return hints
root = obj
while hasattr(root, '__wrapped__'):
root = root.__wrapped__
globalns = getattr(root, '__globals__', {})
hints = getattr(obj, '__annotations__', None)
if hints is None:
return {}
if not isinstance(hints, dict):
hints = dict(hints)
for name, value in hints.items():
hints[name] = evaluate(value, globalns)
return hints
def evaluate(annotation: str | None, globalns: dict | None = None, localns: dict | None = None):
if annotation is None:
return type(None)
if not isinstance(annotation, str):
return annotation
def _types(attr: str):
return ast.Attribute(
ctx=ast.Load(),
value=ast.Name(id=_TYPING_MODULE, ctx=ast.Load()),
attr=attr
)
class T(ast.NodeTransformer):
def visit_Subscript(self, node):
node.value = self.visit(node.value)
node.slice = self.visit(node.slice)
if isinstance(node.value, ast.Name):
if downgrade := _into_typing(node.value.id):
node.value = _types(downgrade)
return node
def visit_Call(self, node: ast.Call):
# do not descend into calls
return node
def visit_BinOp(self, node):
def collect(n: ast.expr):
if isinstance(n, ast.BinOp) and isinstance(n.op, ast.BitOr):
yield from collect(n.left)
yield from collect(n.right)
else:
yield self.visit(n)
if not isinstance(node.op, ast.BitOr):
return self.generic_visit(node)
return ast.Subscript(
value=_types('Union'),
slice=_index(
ast.Tuple(elts=list(collect(node)), ctx=ast.Load())),
ctx=ast.Load()
)
if annotation.startswith('Param['):
if not globalns or 'Param' not in globalns:
raise LookupError
try:
tree = ast.parse(annotation, mode='eval')
body = T().visit(tree.body)
ast.fix_missing_locations(body)
code = compile(ast.Expression(body=body), '[annotation]', 'eval')
globalns = globalns or {}
globalns[_TYPING_MODULE] = typing
return eval(code, globalns, localns)
except Exception:
raise
Functions
def get_type_hints(obj, globalns=None, localns=None, include_extras=False)
-
Implements the same functionality as
typing.get_type_hints
but useseval()
rather thaneval
when the Python version is below 3.10.Expand source code
def get_type_hints(obj, globalns=None, localns=None, include_extras=False): """Return type hints for an object. 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): return {} # Classes require a special treatment. if isinstance(obj, type): hints = {} for base in reversed(obj.__mro__): if globalns is None: base_globals = getattr(sys.modules.get(base.__module__, None), '__dict__', {}) else: base_globals = globalns ann = base.__dict__.get('__annotations__', {}) if isinstance(ann, types.GetSetDescriptorType): ann = {} base_locals = dict(vars(base)) if localns is None else localns 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 for name, value in ann.items(): if value is None: value = type(None) if isinstance(value, str): value = ForwardRef(value, is_argument=False, is_class=True) value = _eval_type(value, base_globals, base_locals, base.__type_params__) hints[name] = value return hints if include_extras else {k: _strip_annotations(t) for k, t in hints.items()} if globalns is None: if isinstance(obj, types.ModuleType): globalns = obj.__dict__ else: nsobj = obj # Find globalns for the unwrapped object. while hasattr(nsobj, '__wrapped__'): nsobj = nsobj.__wrapped__ globalns = getattr(nsobj, '__globals__', {}) if localns is None: localns = globalns elif localns is None: localns = globalns hints = getattr(obj, '__annotations__', None) if hints is None: # Return empty annotations for something that _could_ have them. if isinstance(obj, _allowed_types): return {} else: raise TypeError('{!r} is not a module, class, method, ' 'or function.'.format(obj)) hints = dict(hints) type_params = getattr(obj, "__type_params__", ()) for name, value in hints.items(): if value is None: value = type(None) if isinstance(value, str): # class-level forward refs were handled above, this must be either # a module-level annotation or a function argument annotation value = ForwardRef( value, is_argument=not isinstance(obj, types.ModuleType), is_class=False, ) hints[name] = _eval_type(value, globalns, localns, type_params) return hints if include_extras else {k: _strip_annotations(t) for k, t in hints.items()}
def evaluate(source, globals=None, locals=None, /)
-
The same as
eval
on Python 3.10 and beyond, otherwise a backwards-compatibility layer that converts modern type hints back to compatible expressions.