#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ---------------------------------------------------------------------
# Copyright (c) Merchise Autrement [~º/~] and Contributors
# All rights reserved.
#
# This is free software; you can do what the LICENCE file allows you to.
#
'''Provides Python 3k forward-compatible (`3107`:pep:) annotations.'''
from __future__ import (division as _py3_division,
print_function as _py3_print,
absolute_import as _py3_abs_import)
from re import compile as _regex_compile
from ast import parse as _ast_parse
from xoutil.future.functools import partial
_ast_parse = partial(_ast_parse, filename="<annotations>", mode="eval")
from xoutil.decorator.meta import decorator
__all__ = ['annotate']
_SIGNATURE = _regex_compile(r'''(?ixm)
\( # Required opening for the argumens
(?P<args>(.)*)
\)\s* # Required close
(?:->\s*(?P<return>.+))?$
''')
_ARG_SEP = _regex_compile(r'(?im)^\*{0,2}(?P<argname>[_\w\d]+)\s*:')
def _split_signature(signature):
from xoutil.eight import string_types as strs
signature = (signature.strip() if isinstance(signature, strs) else '')
if signature:
matches = _SIGNATURE.match(signature)
return matches.group('args'), matches.group('return')
else:
return '', None
def _parse_signature(signature):
def _split_annotation_expression(expr):
match = _ARG_SEP.match(expr)
if not match:
raise SyntaxError('Invalid signature expression %r' % expr)
argname = match.group('argname')
expr = expr[match.end():].lstrip()
if not argname:
raise SyntaxError('The signature %r is not valid' % expr)
try:
# This is a hack to help not implement an expression parser for
# Python
node = _ast_parse(expr)
return argname, node, '' # We consumed the whole expression
except SyntaxError as error:
# This probably will be a:
# ..., varname: expr...
# ^
offset = error.offset
while offset > 0 and expr[offset] != ',':
offset -= 1
if offset > 0 and expr[offset] == ',':
return (argname, _ast_parse(expr[:offset]),
expr[offset + 1:].lstrip())
else:
raise
class l(object): # noqa: E742
'''A locals implementation that skip some levels up in order to
protect annotation's own locals.
'''
def __init__(self, init={}, skip_levels=5):
import sys
# XXX: This code is very fragile, but is the "right" thing to do
# in order not to leak implementation-related local variables.
# Any lower number will yield wrong results. For instance if
# skip_levels is 2, in the following case::
#
# >>> args = 'args'
# >>> @annotate('(a: args)')
# ... def d():
# ... pass
#
# The annotation for `a` would actually get the tuple
# containing the string signature cause in its own
# implementation `annotate` uses an `args` local variable::
#
# >>> d.__annotations__
# {'a': ('(a: args)',)}
#
# XXX: In fact, I should check that this does not create memory
# references cycles with frames and stuff as noticed in the
# CPython documentation; notwithstanding that, python's garbage
# collector may get rid of unreachable objects, even with loops.
self.f = f = sys._getframe(skip_levels)
self.f_globals = f.f_globals
self.d = dict(init)
def __getitem__(self, key):
from xoutil.symbols import Unset
from xoutil.future.itertools import dict_update_new
from xoutil.eight import python_version
d = self.d
res = d.get(key, Unset)
f = self.f
if res is Unset and f:
f_globals = self.f_globals
if python_version == 3:
# FIXME: This modifies f_globals! Use f_builtins of the
# frame.
# In Py3k (at least Python 3.2) builtins are not directly
# in f_globals but inside a __builtins__ key.
builtins = f_globals.get('__builtins__', {})
dict_update_new(f_globals, builtins)
while f and res is Unset:
dict_update_new(d, f.f_locals)
res = d.get(key, Unset)
f = self.f = f.f_back
if res is Unset and f_globals:
dict_update_new(d, f_globals)
res = d.get(key, Unset)
# At this point there's no use to keep the reference to
# frames since we have reached back to the global context,
# so it's best to clear of reference to the last frame
# in order to keep this CPython-friendly.
self.f = None
self.f_globals = None
if res:
return res
else:
raise KeyError
args, return_annotation = _split_signature(signature)
while args:
arg, expr, args = _split_annotation_expression(args)
code = compile(expr, '', 'eval')
# Don't put our globals but, just calling-frames globals
yield arg, eval(code, None, l())
if return_annotation:
yield 'return', eval(return_annotation, globals(), l())
[docs]@decorator
def annotate(func, signature=None, **keyword_annotations):
'''Annotates a function with a Python 3k forward-compatible
``__annotations__`` mapping.
See `3107`:pep: for more details about annotations.
:param signature: A string with the annotated signature of the
decorated function.
This string should follow the annotations syntax in `3107`:pep:. But
there are several deviations from the PEP text:
- There's no support for the full syntax of Python 2 expressions; in
particular nested arguments are not supported since they are
deprecated and are not valid in Py3k.
- Specifying defaults is no supported (nor needed). Defaults are
placed in the signature of the function.
- In the string it makes no sense to put an argument without an
annotation, so this will raise an exception (SyntaxError).
:param keyword_annotations: These are each mapped to a single annotation.
Since you can't include the 'return' keyword argument for the
annotation related with the return of the function, we provide several
alternatives: if any of the following keywords arguments is provided
(tested in the given order): 'return_annotation', '_return',
'__return'; then it will be considered the 'return' annotation, the
rest will be regarded as other annotations.
In any of the previous cases, you may provide more (or less) annotations
than possible by following the PEP syntax. This is not considered an error,
since the PEP allows annotations to be modified by others means.
If you provide a signature string **and** keywords annotations, the
keywords will take precedence over the signature::
>>> @annotate('() -> list', return_annotation=tuple)
... def otherfunction():
... pass
>>> otherfunction.__annotations__.get('return') is tuple
True
When parsing the `signature` the locals and globals in the context of the
declaration are taken into account::
>>> interface = object # let's mock of ourselves
>>> class ISomething(interface):
... pass
>>> @annotate('(a: ISomething) -> ISomething')
... def somewhat(a):
... return a
>>> somewhat.__annotations__.get('a') # doctest: +ELLIPSIS
<class '...ISomething'>
'''
from xoutil.objects import pop_first_of
func.__annotations__ = annotations = getattr(func, '__annotations__', {})
if signature:
annotations.update({argname: value
for argname, value in _parse_signature(signature)})
probes = ('return_annotation', '_return', '__return')
return_annotation_kwarg = pop_first_of(keyword_annotations, *probes)
if return_annotation_kwarg:
annotations['return'] = return_annotation_kwarg
annotations.update(keyword_annotations)
return func