Source code for sigtools.modifiers

# 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.modifiers`: Modify the effective signature of the decorated callable
------------------------------------------------------------------------------

The functions in this module can be used as decorators to mark and enforce some
parameters to be keyword-only (`kwoargs`) or annotate (`annotate`) them, just
like you can :ref:`using Python 3 syntax <def>`. You can also mark and enforce
parameters to be positional-only (`posoargs`). `autokwoargs` helps you quickly
make your parameters with default values become keyword-only.

"""

from functools import partial, update_wrapper

from sigtools import _util, _specifiers, _signatures

__all__ = ['annotate', 'kwoargs', 'autokwoargs', 'posoargs']


class _PokTranslator(_util.OverrideableDataDesc):
    __slots__ = ['__self__', 'func', 'posoarg_names', 'kwoarg_names', 'kwopos', '__signature__']

    def __new__(cls, func=None, posoargs=(), kwoargs=(), **kwargs):
        if func is None:
            return partial(_PokTranslator, posoargs=posoargs,
                           kwoargs=kwoargs, **kwargs)
        if posoargs or kwoargs:
            return super(_PokTranslator, cls).__new__(cls)
        return func

    def __init__(self, func, posoargs=(), kwoargs=(), **kwargs):
        update_wrapper(self, func)
        try:
            self.__self__ = func.__self__
        except AttributeError:
            pass
        try:
            del self._sigtools__forger
        except AttributeError:
            pass
        super(_PokTranslator, self).__init__(**kwargs)
        self.func = func
        self.posoarg_names = set(posoargs)
        self.kwoarg_names = set(kwoargs)
        if isinstance(func, _PokTranslator):
            self._merge_other(func)
        self._prepare()

    def _merge_other(self, other):
        self.func = other.func
        self.posoarg_names |= other.posoarg_names
        self.kwoarg_names |= other.kwoarg_names

        from sigtools import wrappers
        self.custom_getter = wrappers.Combination(
            self.custom_getter, other.custom_getter)

    def _prepare(self):
        intersection = self.posoarg_names & self.kwoarg_names
        if intersection:
            raise ValueError(
                'Parameters marked as both positional-only and keyword-only: '
                + ' '.join(repr(name) for name in intersection))
        to_use = self.posoarg_names | self.kwoarg_names

        sig = _specifiers.forged_signature(self.func, auto=False)
        params = []
        kwoparams = []
        kwopos = self.kwopos = []
        found_pok = found_kws = False
        for i, param in enumerate(sig.parameters.values()):
            if param.kind == param.POSITIONAL_OR_KEYWORD:
                if param.name in self.posoarg_names:
                    if found_pok:
                        raise ValueError(
                            '{0.name!r} was requested to become a positional-'
                            'only parameter, but comes after a regular '
                            'parameter'.format(param))
                    params.append(
                        param.replace(kind=param.POSITIONAL_ONLY))
                    to_use.remove(param.name)
                elif param.name in self.kwoarg_names:
                    kwoparams.append(
                        param.replace(kind=param.KEYWORD_ONLY))
                    kwopos.append((i, param))
                    to_use.remove(param.name)
                else:
                    found_pok = True
                    params.append(param)
            else: # not a POK param
                if param.name in to_use:
                    if param.kind == param.POSITIONAL_ONLY and param.name in self.posoarg_names:
                        to_use.remove(param.name)
                    elif param.kind == param.KEYWORD_ONLY and param.name in self.kwoarg_names:
                        to_use.remove(param.name)
                    else:
                        raise ValueError(
                            '{0.name!r} is not of kind POSITIONAL_OR_KEYWORD, but:'
                            ' {0.kind}'.format(param))
                if param.kind == param.VAR_KEYWORD:
                    found_kws = True
                    params.extend(kwoparams)
                params.append(param)
        if not found_kws:
            params.extend(kwoparams)
        if to_use:
            raise ValueError("Parameters not found: " + ' '.join(to_use))
        self.__signature__ = sig.replace(
            parameters=params,
            sources=_signatures.copy_sources(sig.sources, {self.func:self}))

    def _sigtools__autoforwards_hint(self, func):
        ast = _util.get_ast(self.func)
        if ast is None:
            return None
        sig = self.__signature__
        return self.func, ast, sig

    def __call__(self, *args, **kwargs):
        intersect = self.posoarg_names.intersection(kwargs)
        if intersect:
            raise TypeError(
                'Named arguments refer to positional-only parameters: {0}'
                .format(' '.join(repr(name) for name in intersect))
                )
        args = list(args) # we might need list.insert
        missing = []
        for pos, param in self.kwopos:
            if param.name in kwargs:
                if pos < len(args):
                    args.insert(pos, kwargs.pop(param.name))
            elif param.default == param.empty:
                missing.append(param.name)
            elif pos < len(args):
                args.insert(pos, param.default)
        if missing:
            raise TypeError('{0}() is missing the following required '
                            'keyword-only arguments: {1}'.format(
                            self.func.__name__, ', '.join(missing)))
        return self.func(*args, **kwargs)

    def parameters(self):
        return {
            'posoargs': self.posoarg_names,
            'kwoargs': self.kwoarg_names,
            }

    def __repr__(self):
        return (
            '<{0.func!r} with arg translation>'
            .format(self))

[docs]@_PokTranslator(kwoargs=('start',)) def kwoargs(start=None, *kwoarg_names): """Marks the given parameters as keyword-only, avoiding the use of python3 syntax. These two functions are equivalent:: def py3_func(spam, *, ham, eggs='chicken'): return spam, ham, eggs @kwoargs('ham', 'eggs') def py23_func(spam, ham, eggs='chichen'): return spam, ham, eggs :param str start: If given and is the name of a parameter, it and all parameters after it are made keyword-only :param str kwoarg_names: Names of the parameters to convert :raises: `ValueError` if end or one of posoarg_names isn't in the decorated function's signature. """ assert all(isinstance(s, str) for s in kwoarg_names), \ "argument names must be strings; forgot to put () after @kwoargs?" if start is not None: return partial(_kwoargs_start, start, kwoarg_names) if not kwoarg_names: return _util.noop return partial(_PokTranslator, kwoargs=kwoarg_names)
# my syntax highlighter is broken """ def _kwoargs_start(start, _kwoargs, func, *args, **kwargs): kwoarg_names = set(_kwoargs) found = False sig = _specifiers.forged_signature(func, auto=False).parameters.values() for param in sig: if param.kind == param.POSITIONAL_OR_KEYWORD: if found or param.name == start: found = True kwoarg_names.add(param.name) elif param.kind != param.POSITIONAL_ONLY: break # no more POKs now if not found: raise ValueError('{0!r} not found in {1.__name__}{2}'.format( start, func, sig)) return _PokTranslator( func, kwoargs=kwoarg_names, get=partial(_kwoargs_start, start, _kwoargs))
[docs]@kwoargs('end') def posoargs(end=None, *posoarg_names): """Marks the given parameters as positional-only. If the resulting function is passed any named arguments that references a positional parameter, `TypeError` will be raised. >>> from sigtools.modifiers import posoargs >>> @posoargs('ham') ... def func(ham, spam): ... pass ... >>> func('ham', 'spam') >>> func('ham', spam='spam') >>> func(ham='ham', spam='spam') Traceback (most recent call last): File "<input>", line 1, in <module> File "./sigtools/modifiers.py", line 94, in __call__ .format(' '.join(repr(name) for name in intersect)) TypeError: Named arguments refer to positional-only parameters: 'ham' :param str end: If given and is the name of a parameter, it and all parameters leading to it are made positional-only. :param str posoarg_names: Names of the parameters to convert :raises: `ValueError` if end or one of posoarg_names isn't in the decorated function's signature. """ assert all(isinstance(s, str) for s in posoarg_names), \ "argument names must be strings" if end is not None: return partial(_posoargs_end, end, posoarg_names) if not posoarg_names: return _util.noop return partial(_PokTranslator, posoargs=posoarg_names)
def _posoargs_end(end, _posoargs, func, *args, **kwargs): posoarg_names = set(_posoargs) found = False sig = _specifiers.forged_signature(func, auto=False).parameters.values() for param in sig: if param.kind == param.POSITIONAL_OR_KEYWORD: if not found: posoarg_names.add(param.name) if param.name == end: found = True elif param.kind != param.POSITIONAL_ONLY: break # no more POKs now if not found: raise ValueError('{0!r} not found in {1.__name__}{2}'.format( end, func, sig)) return _PokTranslator( func, posoargs=posoarg_names, get=partial(_posoargs_end, end, _posoargs))
[docs]@kwoargs('exceptions') def autokwoargs(func=None, exceptions=()): """Marks all arguments with default values as keyword-only. :param sequence exceptions: names of parameters not to convert :: >>> from sigtools.modifiers import autokwoargs >>> @autokwoargs(exceptions=['c']) ... def func(a, b, c=3, d=4, e=5): ... pass ... >>> from inspect import signature >>> print(signature(func)) (a, b, c=3, *, d=4, e=5) """ if func is not None: if callable(func): return _autokwoargs(exceptions, func) else: raise ValueError("exceptions must be passed by name") else: return partial(_autokwoargs, exceptions)
def _autokwoargs(exceptions, func): sig = _specifiers.forged_signature(func, auto=False) args = [] exceptions = set(exceptions) for param in sig.parameters.values(): if ( param.kind == param.POSITIONAL_OR_KEYWORD and param.default != param.empty ): try: exceptions.remove(param.name) except KeyError: args.append(param.name) if exceptions: raise ValueError( "parameters referred to by 'exceptions' not present: " + ' '.join(repr(name) for name in exceptions)) return kwoargs(*args)(func)
[docs]class annotate(object): """Annotates a function, avoiding the use of python3 syntax These two functions are equivalent:: def py3_func(spam: 'ham', eggs: 'chicken'=False) -> 'return': return spam, eggs @annotate('return', spam='ham', eggs='chicken') def py23_func(spam, eggs=False): return spam, eggs :param _annotate__return_annotation: The annotation to attach for return value :param annotations: The annotations to attach for each parameter :raises: `ValueError` if a parameter to be annotated does not exist on the function """ def __init__(self, __return_annotation=_util.UNSET, **annotations): self.ret = __return_annotation self.annotations = annotations self.to_use = set(annotations) def __call__(self, obj): func = obj poks = [] while isinstance(func, _PokTranslator): poks.append(func) func = func.func sig = _specifiers.forged_signature(func, auto=False) parameters = [] to_use = self.to_use.copy() for name, parameter in sig.parameters.items(): if name in self.annotations: annotation = self.annotations[name] upgraded_annotation = _signatures.UpgradedAnnotation.preevaluated(annotation) parameters.append(parameter.replace( annotation=annotation, upgraded_annotation=upgraded_annotation, )) to_use.remove(name) else: parameters.append(parameter) if to_use: raise ValueError( 'the following parameters to be annotated ' 'were not found in {0}: {1}' .format(func.__name__, ', '.join(to_use))) if self.ret is _util.UNSET: sig = sig.replace(parameters=parameters) else: sig = sig.replace(parameters=parameters, return_annotation=self.ret, upgraded_return_annotation=_signatures.UpgradedAnnotation.preevaluated(self.ret)) func.__signature__ = sig for pok in reversed(poks): pok._prepare() return obj def __repr__(self): return '{0}.annotate({1}{2})'.format( _util.qualname(type(self)), '' if self.ret is _util.UNSET else '{0!r}, '.format(self.ret), ', '.join('{0[0]}={0[1]!r}'.format(item) for item in sorted(self.annotations.items())))
_finalized = True