11

In the following minimal example decorate is called two times. First using @decorate, second by normal function call decorate(bar).

def decorate(func):
    print(func.__name__)
    return func

@decorate
def bar():
    pass

decorate(bar)

Is it possible to see inside of decorate if the call was invoked by using @decorate or as a normal function call?

Tobias Hermann
  • 9,936
  • 6
  • 61
  • 134
  • 3
    No because these cases are *exactly the same* The `@` form is just syntactic sugar for the long form – Daniel Roseman Sep 05 '18 at 19:16
  • 3
    @DanielRoseman Please refrain from answering questions in the comments. Answers in comments are detrimental to the site. Comments cannot be downvoted, only upvoted, and so you can't have the quality checks that answers have. You cannot edit comments to improve them after 5 minutes. – wim Sep 05 '18 at 19:37
  • 4
    @wim please let people comment as they wish... https://meta.stackoverflow.com/questions/371115/what-if-i-dont-want-to-answer-but-still-want-to-help – Jean-François Fabre Sep 05 '18 at 19:38
  • @wim that's not really an answer. And it seems wrong seeing the good real answers below. – Jean-François Fabre Sep 05 '18 at 19:49
  • @Jean-FrançoisFabre It starts out "No", and is intended as an answer to the question "Is it possible...?". It's also an incorrect answer. What are you missing here? – wim Sep 05 '18 at 19:56
  • Why would you want to do this, when they are semantically the same? Unless this is purely out of curiosity about the internals of Python, then this is definitely an [XY problem](https://xyproblem.info). What problem are you actually trying to solve with this? – kaya3 Mar 22 '20 at 08:46
  • @kaya3 I don't remember any more. My question is from 2018. Probably I solved the actual problem differently back then. ;-) But I guess it was related to something I tried doing in that library: https://github.com/Dobiasd/undictify – Tobias Hermann Mar 23 '20 at 08:12

4 Answers4

12

The @decorator syntax is just syntactic sugar, thus both examples have identical behaviour. This also means whatever distinction you are doing between them might not be as meaningful as you thought.

Although, you can use inspect to read your script and see how the decorator was called in the above frame.

import inspect

def decorate(func):
    # See explanation below
    lines = inspect.stack(context=2)[1].code_context
    decorated = any(line.startswith('@') for line in lines)

    print(func.__name__, 'was decorated with "@decorate":', decorated)
    return func

Note that we had to specify context=2 to the inspect.stack function. The context argument indicates how many lines of code around the current line must be returned. In some specific cases, such as when decorating a subclass, the current line was on the class declaration instead of the decorator. The exact reason for this behaviour has been explored here.

Example

@decorate
def bar():
    pass

def foo():
    pass
foo = decorate(foo)

@decorate
class MyDict(dict):
    pass

Output

bar was decorated with "@decorate": True
foo was decorated with "@decorate": False
MyDict was decorated with "@decorate": True

Caveat

There are still some corner cases that we can hardly overcome such as linebreaks between the decorator and a class declaration.

# This will fail
@decorate

class MyDict(dict):
    pass
Olivier Melançon
  • 21,584
  • 4
  • 41
  • 73
4

Olivier's answer took the thoughts right out of my head. However, as inspect.stack() is a particularly expensive call, I would consider opting to use something along the lines of:

frame = inspect.getframeinfo(inspect.currentframe().f_back, context=1)
if frame.code_context[0][0].startswith('@'): 
    print('Used as @decorate: True')
else:
    print("Used as @decorate: False")
Philip DiSarro
  • 1,007
  • 6
  • 9
2

Contrary to popular believe, @decorator and decorator(…) are not exactly equivalent. The first runs before name binding, the latter after name binding. For the common use-case of top-level functions, this allows to cheaply test which case applies.

import sys

def decoraware(subject):
    """
    Decorator that is aware whether it was applied using `@deco` syntax
    """
    try:
        module_name, qualname = subject.__module__, subject.__qualname__
    except AttributeError:
        raise TypeError(f"subject must define '__module__' and '__qualname__' to find it")
    if '.' in qualname:
        raise ValueError(f"subject must be a top-level function/class")
    # see whether ``subject`` has been bound to its module
    module = sys.modules[module_name]
    if getattr(module, qualname, None) is not subject:
        print('@decorating', qualname)  # @decoraware
    else:
        print('wrapping()', qualname)   # decoraware()
    return subject

This example will merely print how it was applied.

>>> @decoraware
... def foo(): ...
...
@decorating foo
>>> decoraware(foo)
wrapping() foo

The same means can be used to run arbitrary code in each path, though.

In case that multiple decorators are applied, you must decide whether you want the top or bottom subject. For the top-function, the code works unmodified. For the bottom subject, unwrap it using subject = inspect.unwrap(subject) before detection.


The same approach can be used in a more general way on CPython. Using sys._getframe(n).f_locals gives access to the local namespace in which the decorator was applied.

def decoraware(subject):
    """Decorator that is aware whether it was applied using `@deco` syntax"""
    modname, topname = subject.__module__, subject.__name__
    if getattr(sys.modules[modname], topname, None) is subject:
        print('wrapping()', topname, '[top-level]')
    else:
        at_frame = sys._getframe(1)
        if at_frame.f_locals.get(topname) is subject:
            print('wrapping()', topname, '[locals]')
        elif at_frame.f_globals.get(topname) is subject:
            print('wrapping()', topname, '[globals]')
        else:
            print('@decorating', topname)
    return subject

Note that similar to pickle, this approach will fail if the subject's __qualname__/__name__ is tampered with or it is del'ed from its defining namespace.

MisterMiyagi
  • 44,374
  • 10
  • 104
  • 119
0

Building upon the two previous answers, I wrote a generic function which should work as expected in almost all real-world cases. I tested it with Python 3.6, 3.7 and 3.8.

Before copy-pasting this function into your code, make sure you wouldn't be better off using the decorator module instead.

def am_I_called_as_a_decorator(default=False):
    """This function tries to determine how its caller was called.

    The value returned by this function should not be blindly trusted, it can
    sometimes be inaccurate.

    Arguments:
        default (bool): the fallback value to return when we're unable to determine
                        how the function was called

    >>> def f(*args):
    ...     if am_I_called_as_a_decorator():
    ...         print("called as decorator with args {!r}".format(args))
    ...         if len(args) == 1:
    ...             return args[0]
    ...         return f
    ...     else:
    ...         print("called normally with args {!r}".format(args))
    ...
    >>> f()
    called normally with args ()
    >>> @f                              #doctest: +ELLIPSIS
    ... def g(): pass
    ...
    called as decorator with args (<function g at ...>,)
    >>> @f()
    ... class Foobar: pass
    ...
    called as decorator with args ()
    called as decorator with args (<class 'state_chain.Foobar'>,)
    >>> @f(                             #doctest: +ELLIPSIS
    ...     'one long argument',
    ...     'another long argument',
    ... )
    ... def g(): pass
    ...
    called as decorator with args ('one long argument', 'another long argument')
    called as decorator with args (<function g at ...>,)
    >>> @f('one long argument',         #doctest: +ELLIPSIS
    ...    'another long argument')
    ... def g(): pass
    ...
    called as decorator with args ('one long argument', 'another long argument')
    called as decorator with args (<function g at ...>,)
    >>> @f(                             #doctest: +ELLIPSIS
    ...     # A weirdly placed comment
    ...   )
    ... @f
    ... def g(): pass
    ...
    called as decorator with args ()
    called as decorator with args (<function g at ...>,)

    """

    def get_indentation(line):
        for i, c in enumerate(line):
            if not c.isspace():
                break
        return line[:i]

    # First, we try to look at the line where Python says the function call is.
    # Unfortunately, Python doesn't always give us the line we're interested in.
    call_frame = inspect.currentframe().f_back.f_back
    call_info = inspect.getframeinfo(call_frame, context=0)
    source_lines = linecache.getlines(call_info.filename)
    if not source_lines:
        # Reading the source code failed, return the fallback value.
        return default
    try:
        call_line = source_lines[call_info.lineno - 1]
    except IndexError:
        # The source file seems to have been modified.
        return default
    call_line_ls = call_line.lstrip()
    if call_line_ls.startswith('@'):
        # Note: there is a small probability of false positive here, if the
        # function call is on the same line as a decorator call.
        return True
    if call_line_ls.startswith('class ') or call_line_ls.startswith('def '):
        # Note: there is a small probability of false positive here, if the
        # function call is on the same line as a `class` or `def` keyword.
        return True
    # Next, we try to find and examine the line after the function call.
    # If that line doesn't start with a `class` or `def` keyword, then the
    # function isn't being called as a decorator.
    def_lineno = call_info.lineno
    while True:
        try:
            def_line = source_lines[def_lineno]
        except IndexError:
            # We've reached the end of the file.
            return False
        def_line_ls = def_line.lstrip()
        if def_line_ls[:1] in (')', '#', '@', ''):
            def_lineno += 1
            continue
        break
    if not (def_line_ls.startswith('class') or def_line_ls.startswith('def')):
        # Note: there is a small probability of false negative here, as we might
        # be looking at the wrong line.
        return False
    # Finally, we look at the lines above, taking advantage of the fact that a
    # decorator call is at the same level of indentation as the function or
    # class being decorated.
    def_line_indentation = get_indentation(def_line)
    for lineno in range(call_info.lineno - 1, 0, -1):
        line = source_lines[lineno - 1]
        line_indentation = get_indentation(line)
        if line_indentation == def_line_indentation:
            line_ls = line.lstrip()
            if line_ls[:1] in (')', ','):
                continue
            return line_ls.startswith('@')
        elif len(line_indentation) < len(def_line_indentation):
            break
    return default
Changaco
  • 790
  • 5
  • 12