Source code for sigtools.support

# vim: set fileencoding=utf-8
# sigtools - Collection of Python modules for manipulating function signatures
# Copyright (C) 2013-2022 Yann Kaiser
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

"""
`sigtools.support`: Utilities for use in interactive sessions and unit tests
----------------------------------------------------------------------------

"""

import __future__
import re
import sys
import itertools
from warnings import warn

from sigtools import _util, modifiers, signatures, specifiers

__all__ = [
    's', 'f', 'read_sig', 'func_code', 'make_func', 'func_from_sig',
    'make_up_callsigs', 'bind_callsig', 'sort_callsigs',
    'assert_func_sig_coherent',
    ]

try:
    zip = itertools.izip
except AttributeError:
    pass

re_paramname = re.compile(
    r'^'
    r'\s*([^:=]+)'      # param name
    r'\s*(?::(.+?))?'    # annotation
    r'\s*(?:=(.+))?'   # default value
    r'$')
re_posoarg = re.compile(r'^<(.*)>$')

[docs]def read_sig(sig_str, ret=_util.UNSET, *, use_modifiers_annotate=False, use_modifiers_posoargs=sys.version_info < (3, 8), use_modifiers_kwoargs=False, ): """Reads a string representation of a signature and returns a tuple `func_code` can understand.""" names = [] return_annotation = ret annotations = {} posoarg_n = [] kwoarg_n = [] params = [] found_star = False varargs = None varkwargs = None chevron_index = None default_index = None for i, param in enumerate(sig_str.split(',')): if not param: continue arg, annotation, default = re_paramname.match(param).groups() insert = arg is_posoarg = re_posoarg.match(arg) if is_posoarg: name = arg = is_posoarg.group(1) insert = arg if use_modifiers_posoargs: posoarg_n.append(name) else: chevron_index = i else: name = arg.lstrip('*') if annotation: if use_modifiers_annotate: annotations[name.lstrip('*')] = annotation else: insert = f"{insert}: {annotation}" if default: if default_index is None: if found_star: default_index = i - 1 else: default_index = i insert = f'{insert}={default}' if arg == '/': if use_modifiers_posoargs: posoarg_n.extend(names) else: params.append("/") chevron_index = None elif arg.startswith('*'): found_star = True if chevron_index is not None and not use_modifiers_posoargs: params.insert(chevron_index + 1, "/") chevron_index = None if name: params.append(insert) if arg.startswith('**'): varkwargs = name else: varargs = name elif not use_modifiers_kwoargs: params.append("*") elif found_star: if not use_modifiers_kwoargs: params.append(insert) names.append(name) else: kwoarg_n.append(arg) names.append(name) if not default and default_index is not None: params.insert(default_index, insert) default_index += 1 else: if params and params[-1].startswith('*'): params.insert(-1, insert) else: params.append(insert) else: params.append(insert) names.append(name) if chevron_index is not None and not use_modifiers_posoargs: params.insert(chevron_index + 1, "/") if varargs: names.append(varargs) if varkwargs: names.append(varkwargs) return ( names, return_annotation, annotations, posoarg_n, kwoarg_n, ', '.join(params), use_modifiers_annotate)
[docs]def func_code(names, return_annotation, annotations, posoarg_n, kwoarg_n, params, use_modifiers_annotate, pre='', name='func', ): """Formats the code to construct a function to `read_sig`'s design.""" code = [pre] if use_modifiers_annotate and return_annotation is not _util.UNSET and annotations: annotation_args = ', '.join( f'{key}={value}' for key, value in annotations.items()) code.append(f'@modifiers.annotate({return_annotation}, {annotation_args})') elif use_modifiers_annotate and return_annotation is not _util.UNSET: code.append(f'@modifiers.annotate({return_annotation})') elif annotations: annotation_args = ', '.join( f'{key}={value}'.format(key, value) for key, value in annotations.items()) code.append(f'@modifiers.annotate({annotation_args})') if posoarg_n: posoarg_names = ', '.join(f"'{name}'" for name in posoarg_n) code.append(f'@modifiers.posoargs({posoarg_names})') if kwoarg_n: kwoargs_name = ', '.join(f"'{name}'" for name in kwoarg_n) code.append(f'@modifiers.kwoargs({kwoargs_name})') if not use_modifiers_annotate and return_annotation is not _util.UNSET: annotation_syntax = f" -> {return_annotation}" else: annotation_syntax = "" code.append(f'def {name}({params}){annotation_syntax}:') return_keyvalues = ', '.join(f'{name!r}: {name}' for name in names) code.append(f' return {{{return_keyvalues}}}') return '\n'.join(code)
[docs]def make_func(source, globals=None, locals=None, name='func', future_features=()): """Executes the given code and returns the object named func from the resulting namespace.""" flags = 0 for feature in future_features: flags |= getattr(__future__, feature).compiler_flag code = compile(source, "<sigtools.support>", "exec", flags) func_globals = { "modifiers": modifiers, **(globals or {}) } exec(code, func_globals, locals) def_space = locals if locals is not None else func_globals return def_space[name]
[docs]def f(sig_str, ret=_util.UNSET, *, pre='', globals=None, locals=None, name='func', future_features=(), **kwargs): """Creates a dummy function that has the signature represented by ``sig_str`` and returns a tuple containing the arguments passed, in order. .. warning:: The contents of the arguments are eventually passed to `exec`. Do not use with untrusted input. :: >>> from sigtools.support import f >>> import inspect >>> func = f('a, b=2, *args, c:"annotation", **kwargs') >>> print(inspect.signature(func)) (a, b=2, *args, c:'annotation', **kwargs) >>> func(1, c=3) {'b': 2, 'a': 1, 'kwargs': {}, 'args': ()} >>> func(1, 2, 3, 4, c=5, d=6) {'b': 2, 'a': 1, 'kwargs': {'d': 6}, 'args': (3, 4)} """ return make_func( func_code( *read_sig(sig_str, ret=ret, **kwargs), name=name, pre=pre, ), globals=globals, locals=locals, name=name, future_features=future_features, )
[docs]def s(*args, **kwargs): """Creates a signature from the given string representation of one. .. warning:: The contents of the arguments are eventually passed to `exec`. Do not use with untrusted input. :: >>> from sigtools.support import s >>> sig = s('a, b=2, *args, c:"annotation", **kwargs') >>> sig <inspect.Signature object at 0x7f15e6055550> >>> print(sig) (a, b=2, *args, c:'annotation', **kwargs) """ return specifiers.signature(f(*args, **kwargs))
[docs]def func_from_sig(sig): """Creates a dummy function from the given signature object .. warning:: The contents of the arguments are eventually passed to `exec`. Do not use with untrusted input. """ ret, sep, sig_str = str(sig).rpartition(' -> ') ret = ret if sep else _util.UNSET return f(sig_str[1:-1], ret)
[docs]def make_up_callsigs(sig, extra=2): """Figures out reasonably as many ways as possible to call a callable with the given signature.""" pospars, pokpars, varargs, kwopars, varkwargs = signatures.sort_params(sig) names = [ arg.name for arg in itertools.chain( pospars, pokpars, kwopars.values() )] for i in range(extra): names.append('__make_up_callsigs__extra_{0}'.format(i)) args = [ tuple(names[:i]) for i in range(len(names) + 1) ] if varargs: names.append(varargs.name) if varkwargs: names.append(varkwargs.name) kwargs = [ dict((name, name) for name in names_) for names_ in itertools.chain.from_iterable( itertools.combinations(names, i) for i in range(len(names) + 1) ) ] ret = list(itertools.product(args, kwargs)) return ret
[docs]def bind_callsig(sig, args, kwargs): """Returns a dict with each parameter name from ``sig`` mapped to values from ``args``, ``kwargs`` as if a function with ``sig`` was called with ``(*args, **kwargs)``. Similar to `inspect.Signature.bind`.""" assigned = {} varkwargs = next( (param for param in sig.parameters.values() if param.kind == param.VAR_KEYWORD), None) if varkwargs: assigned[varkwargs.name] = {} params = iter(sig.parameters.values()) args_ = iter(args) i = 0 for (i, posarg), param in zip(enumerate(args_, 1), params): if param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): assigned[param.name] = posarg elif param.kind == param.VAR_POSITIONAL: assigned[param.name] = (posarg,) + tuple(args_) break else: raise TypeError('too many positional arguments') else: if args[:i] != args: raise TypeError('too many positional arguments') for key, value in kwargs.items(): if key in sig.parameters: param = sig.parameters[key] if param.kind == param.POSITIONAL_ONLY: raise TypeError('{0!r} is positional-only'.format(key)) elif param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY): if key in assigned: raise TypeError('{0!r} was specified twice'.format(key)) assigned[key] = value continue if varkwargs: assigned[varkwargs.name][key] = value else: raise TypeError('unknown parameter {0!r}'.format(key)) for param in sig.parameters.values(): if param.name not in assigned: if param.kind == param.VAR_POSITIONAL: assigned[param.name] = () elif param.default != param.empty: assigned[param.name] = param.default else: raise TypeError('omitted required parameter {0!r}'.format( param.name)) return assigned
DEBUG_STDLIB=False
[docs]def sort_callsigs(sig, callsigs): """Determines which ways to call ``sig`` in ``callsigs`` are valid or not. :returns: Two lists: ``(valid, invalid)``. ``valid`` ``(args, kwargs, bound)`` in which ``bound`` is the dict returned by `bind_callsig`. It will be equal to the return value of a function with ``sig`` returned by `f` ``ìnvalid`` ``(args, kwargs)`` """ valid = [] invalid = [] for args, kwargs in callsigs: try: bound = bind_callsig(sig, args, kwargs) except TypeError: if DEBUG_STDLIB: try: sig.bind(*args, **kwargs) except TypeError: pass else: warn('{0}.bind(*{1}, **{2}) didn\'t raise TypeError' .format(sig, args, kwargs)) invalid.append((args, kwargs)) else: valid.append((args, kwargs, bound)) if DEBUG_STDLIB: try: sig.bind(*args, **kwargs) except TypeError as e: warn('{0}.bind(*{1}, **{2}) raised TypeError: {3}' .format(sig, args, kwargs, e)) return valid, invalid
[docs]def assert_func_sig_coherent(func, check_return=True, check_invalid=True): """Tests if a function is coherent with its signature. :param bool check_return: Check if the return value is correct (see `sort_callsigs`) :param bool check_invalid: Make sure call signatures invalid for the signature are also invalid for the passed callable. :raises: AssertionError """ sig = specifiers.signature(func) sig_exceptions = (TypeError, ValueError) if hasattr(sys, 'pypy_version_info') else TypeError valid, invalid = sort_callsigs(sig, make_up_callsigs(sig, extra=2)) for args, kwargs, expected_ret in valid: try: ret = func(*args, **kwargs) except sig_exceptions: raise AssertionError( '{0}{1} <- *{2}, **{3} raised TypeError' .format(_util.qualname(func), sig, args, kwargs)) else: if check_return and expected_ret != ret: raise AssertionError( '{0}{1} <- *{2}, **{3} returned {4} instead of {5}' .format(_util.qualname(func), sig, args, kwargs, ret, expected_ret)) if check_invalid: for args, kwargs in invalid: try: func(*args, **kwargs) except sig_exceptions: pass else: raise AssertionError( '{0}{1} <- *{2}, **{3} did not raise TypeError as expected' .format(_util.qualname(func), sig, args, kwargs))