Zachowanie sygnatur dekorowanych funkcji

111

Załóżmy, że napisałem dekoratora, który robi coś bardzo ogólnego. Na przykład może przekonwertować wszystkie argumenty na określony typ, przeprowadzić rejestrowanie, zaimplementować zapamiętywanie itp.

Oto przykład:

def args_as_ints(f):
    def g(*args, **kwargs):
        args = [int(x) for x in args]
        kwargs = dict((k, int(v)) for k, v in kwargs.items())
        return f(*args, **kwargs)
    return g

@args_as_ints
def funny_function(x, y, z=3):
    """Computes x*y + 2*z"""
    return x*y + 2*z

>>> funny_function("3", 4.0, z="5")
22

Jak dotąd wszystko dobrze. Jest jednak jeden problem. Dekorowana funkcja nie zachowuje dokumentacji oryginalnej funkcji:

>>> help(funny_function)
Help on function g in module __main__:

g(*args, **kwargs)

Na szczęście istnieje obejście:

def args_as_ints(f):
    def g(*args, **kwargs):
        args = [int(x) for x in args]
        kwargs = dict((k, int(v)) for k, v in kwargs.items())
        return f(*args, **kwargs)
    g.__name__ = f.__name__
    g.__doc__ = f.__doc__
    return g

@args_as_ints
def funny_function(x, y, z=3):
    """Computes x*y + 2*z"""
    return x*y + 2*z

Tym razem nazwa funkcji i dokumentacja są poprawne:

>>> help(funny_function)
Help on function funny_function in module __main__:

funny_function(*args, **kwargs)
    Computes x*y + 2*z

Ale nadal jest problem: podpis funkcji jest nieprawidłowy. Informacja "* args, ** kwargs" jest prawie bezużyteczna.

Co robić? Przychodzą mi do głowy dwa proste, ale wadliwe obejścia:

1 - Dołącz poprawny podpis w dokumencie:

def funny_function(x, y, z=3):
    """funny_function(x, y, z=3) -- computes x*y + 2*z"""
    return x*y + 2*z

To jest złe z powodu duplikacji. Podpis nadal nie będzie poprawnie wyświetlany w automatycznie generowanej dokumentacji. Łatwo jest zaktualizować funkcję i zapomnieć o zmianie dokumentu lub popełnieniu literówki. [ I tak, zdaję sobie sprawę z faktu, że dokumentacja już powiela treść funkcji. Proszę zignoruj ​​to; funny_function to tylko przypadkowy przykład. ]

2 - Nie używaj dekoratora lub używaj dekoratora specjalnego przeznaczenia do każdego konkretnego podpisu:

def funny_functions_decorator(f):
    def g(x, y, z=3):
        return f(int(x), int(y), z=int(z))
    g.__name__ = f.__name__
    g.__doc__ = f.__doc__
    return g

Działa to dobrze w przypadku zestawu funkcji, które mają identyczny podpis, ale ogólnie jest bezużyteczne. Jak powiedziałem na początku, chcę mieć możliwość używania dekoratorów w sposób całkowicie ogólny.

Szukam rozwiązania w pełni ogólnego i automatycznego.

Pytanie brzmi więc: czy istnieje sposób na edycję podpisu dekorowanej funkcji po jej utworzeniu?

W przeciwnym razie, czy mogę napisać dekorator, który wyodrębnia sygnaturę funkcji i używa tych informacji zamiast "* kwargs, ** kwargs" podczas konstruowania funkcji dekorowanej? Jak wyodrębnić te informacje? Jak mam skonstruować dekorowaną funkcję - z exec?

Jakieś inne podejścia?

Fredrik Johansson
źródło
1
Nigdy nie powiedział „nieaktualny”. Mniej więcej zastanawiałem się, co inspect.Signaturedodało do zajmowania się dekorowanymi funkcjami.
NightShadeQueen

Odpowiedzi:

79
  1. Zainstaluj moduł dekoratora :

    $ pip install decorator
  2. Dostosuj definicję args_as_ints():

    import decorator
    
    @decorator.decorator
    def args_as_ints(f, *args, **kwargs):
        args = [int(x) for x in args]
        kwargs = dict((k, int(v)) for k, v in kwargs.items())
        return f(*args, **kwargs)
    
    @args_as_ints
    def funny_function(x, y, z=3):
        """Computes x*y + 2*z"""
        return x*y + 2*z
    
    print funny_function("3", 4.0, z="5")
    # 22
    help(funny_function)
    # Help on function funny_function in module __main__:
    # 
    # funny_function(x, y, z=3)
    #     Computes x*y + 2*z
    

Python 3.4+

functools.wraps()ze stdlib zachowuje podpisy od Pythona 3.4:

import functools


def args_as_ints(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        args = [int(x) for x in args]
        kwargs = dict((k, int(v)) for k, v in kwargs.items())
        return func(*args, **kwargs)
    return wrapper


@args_as_ints
def funny_function(x, y, z=3):
    """Computes x*y + 2*z"""
    return x*y + 2*z


print(funny_function("3", 4.0, z="5"))
# 22
help(funny_function)
# Help on function funny_function in module __main__:
#
# funny_function(x, y, z=3)
#     Computes x*y + 2*z

functools.wraps()jest dostępny co najmniej od Pythona 2.5, ale nie zachowuje tam podpisu:

help(funny_function)
# Help on function funny_function in module __main__:
#
# funny_function(*args, **kwargs)
#    Computes x*y + 2*z

Uwaga: *args, **kwargszamiast x, y, z=3.

jfs
źródło
Twoja odpowiedź nie była pierwszą, ale jak dotąd najbardziej wyczerpującą :-) Właściwie wolałbym rozwiązanie nie angażujące zewnętrznego modułu, ale patrząc na źródło modułu dekoratora, jest na tyle proste, że będę mógł po prostu to skopiuj.
Fredrik Johansson,
1
@MarkLodato: functools.wraps()już zachowuje podpisy w Pythonie 3.4+ (jak wspomniano w odpowiedzi). Czy masz na myśli ustawienie wrapper.__signature__pomocy we wcześniejszych wersjach? (które wersje testowałeś?)
jfs
1
@MarkLodato: help()pokazuje poprawny podpis w Pythonie 3.4. Jak myślisz, dlaczego functools.wraps()jest uszkodzony, a nie IPython?
jfs
1
@MarkLodato: jest zepsuty, jeśli musimy napisać kod, aby to naprawić. Biorąc pod uwagę, że help()daje to poprawny wynik, pytanie brzmi, które oprogramowanie powinno zostać naprawione: functools.wraps()czy IPython? W każdym razie ręczne przypisywanie __signature__jest w najlepszym przypadku obejściem - nie jest to rozwiązanie długoterminowe.
jfs
1
Wygląda na to, że inspect.getfullargspec()nadal nie zwraca prawidłowego podpisu dla functools.wrapsw Pythonie 3.4 i należy go inspect.signature()zamiast tego użyć .
Tuukka Mustonen
16

Można to rozwiązać za pomocą standardowej biblioteki Pythona, functoolsa konkretnie functools.wrapsfunkcji, która ma na celu „ aktualizację funkcji opakowującej, aby wyglądała jak funkcja opakowana ”. Jego zachowanie zależy jednak od wersji Pythona, jak pokazano poniżej. Zastosowany do przykładu z pytania kod wyglądałby następująco:

from functools import wraps

def args_as_ints(f):
    @wraps(f) 
    def g(*args, **kwargs):
        args = [int(x) for x in args]
        kwargs = dict((k, int(v)) for k, v in kwargs.items())
        return f(*args, **kwargs)
    return g


@args_as_ints
def funny_function(x, y, z=3):
    """Computes x*y + 2*z"""
    return x*y + 2*z

Po wykonaniu w Pythonie 3 dałoby to następujące efekty:

>>> funny_function("3", 4.0, z="5")
22
>>> help(funny_function)
Help on function funny_function in module __main__:

funny_function(x, y, z=3)
    Computes x*y + 2*z

Jego jedyną wadą jest to, że w Pythonie 2 nie aktualizuje listy argumentów funkcji. Po uruchomieniu w Pythonie 2 wygeneruje:

>>> help(funny_function)
Help on function funny_function in module __main__:

funny_function(*args, **kwargs)
    Computes x*y + 2*z
Timur
źródło
Nie jestem pewien, czy to Sfinks, ale to nie działa, gdy opakowana funkcja jest metodą klasy. Sphinx nadal zgłasza sygnaturę wywoławczą dekoratora.
alphabetasoup
9

Istnieje modułdecorator dekoratora z dekoratorem, którego możesz użyć:

@decorator
def args_as_ints(f, *args, **kwargs):
    args = [int(x) for x in args]
    kwargs = dict((k, int(v)) for k, v in kwargs.items())
    return f(*args, **kwargs)

Następnie zostaje zachowany podpis i pomoc metody:

>>> help(funny_function)
Help on function funny_function in module __main__:

funny_function(x, y, z=3)
    Computes x*y + 2*z

EDYCJA: JF Sebastian zwrócił uwagę, że nie modyfikowałem args_as_intsfunkcji - teraz jest to naprawione.

DzinX
źródło
8

Przyjrzyj się modułowi dekoratora - konkretnie dekoratorowi dekoratora, który rozwiązuje ten problem.

Brian
źródło
6

Druga opcja:

  1. Zainstaluj moduł wrapt:

$ easy_install wrapt

wrapt mają bonus, zachowaj podpis klasy.


import wrapt
import inspect

@wrapt.decorator def args_as_ints(wrapped, instance, args, kwargs): if instance is None: if inspect.isclass(wrapped): # Decorator was applied to a class. return wrapped(*args, **kwargs) else: # Decorator was applied to a function or staticmethod. return wrapped(*args, **kwargs) else: if inspect.isclass(instance): # Decorator was applied to a classmethod. return wrapped(*args, **kwargs) else: # Decorator was applied to an instancemethod. return wrapped(*args, **kwargs) @args_as_ints def funny_function(x, y, z=3): """Computes x*y + 2*z""" return x * y + 2 * z >>> funny_function(3, 4, z=5)) # 22 >>> help(funny_function) Help on function funny_function in module __main__: funny_function(x, y, z=3) Computes x*y + 2*z
macm
źródło
2

Jak skomentowano powyżej w odpowiedzi jfs ; jeśli obawiasz się podpisu pod względem wyglądu ( helpi inspect.signature), używanie functools.wrapsjest w porządku.

Jeśli martwisz się o podpis pod względem zachowania (w szczególności TypeErrorw przypadku niezgodności argumentów), functools.wrapsnie zachowuje go. Powinieneś raczej użyć decoratordo tego lub mojego uogólnienia jego podstawowego silnika o nazwie makefun.

from makefun import wraps

def args_as_ints(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("wrapper executes")
        args = [int(x) for x in args]
        kwargs = dict((k, int(v)) for k, v in kwargs.items())
        return func(*args, **kwargs)
    return wrapper


@args_as_ints
def funny_function(x, y, z=3):
    """Computes x*y + 2*z"""
    return x*y + 2*z


print(funny_function("3", 4.0, z="5"))
# wrapper executes
# 22

help(funny_function)
# Help on function funny_function in module __main__:
#
# funny_function(x, y, z=3)
#     Computes x*y + 2*z

funny_function(0)  
# observe: no "wrapper executes" is printed! (with functools it would)
# TypeError: funny_function() takes at least 2 arguments (1 given)

Zobacz także ten post ofunctools.wraps .

smarie
źródło
1
Wynik inspect.getfullargspecnie jest również utrzymywany przez wywołanie functools.wraps.
laike9m
Dzięki za przydatny dodatkowy komentarz @ laike9m!
smarie