Skip to main content

Apply and remove decorators to the fumctions (both sync and async) on-fly

Project description

PyPI version PyPI - Python Version PyPI - License Coverage Status CI/CD Status

WHAT IT IS

draping is a powerful, thread-safe utility module for dynamically applying, removing, and swapping decorators on functions and methods at runtime.

It allows you to perform live "surgery" on your code, which is invaluable for advanced debugging, testing, and even applying hotfixes to running applications without redeployment. It correctly handles synchronous and asynchronous functions, instance methods, class methods, and static methods.

INTRO

Decorators are a simple but compelling concept in Python. Simply put, by 'decorating' a function or method, we replace it with our own, which then accepts the original function and whatever is passed to it as arguments. Then we are free to do anything, from simply calling the 'decorated' function (in which case our decorator does nothing at all) to completely replacing the functionality of the original function with our own (in which case the original function will do nothing). This functionality is actively used for debugging, profiling, and controlling code execution, making it convenient to have the fact of profiling clearly visible. That is why a special syntax was invented, which looks like this:

@decorator
def func(*args):
    ...

It's simple, straightforward, and elegant. But sometimes you need to do something more sophisticated. For example, one day, you should strip the decoration from a function. Or you may want to decorate a function from another module.

Of course, these are entirely feasible tasks, and Python's power and flexibility make it easy to solve them. In theory. But as a rule, when they arise, you don't have much time to solve them. That's why I wrote this module. It allows you to decorate the necessary functions 'on the fly', in a single line, at the moment you need it. Conversely, you can remove the decoration at any time (the standard syntax does not provide this option). What's more, you can replace one decorator with another if the first one is applied to a given function. These are not features you need every day. But when you do need them, you can take advantage of the flexibility of this module to solve your problems as elegantly and efficiently as possible. It should be noted, however, that the undecorate() and redecorate() functions require a ‘standard’ decorator structure and will not work with ‘bad’ decorators (see the Caveats section).

Features

  • Dynamic Decoration: Apply any decorator to any function or method long after it has been defined.
  • Dynamic Undecoration: Remove decorators from functions, either the outermost one or a specific instance from deep within a decorator chain.
  • Dynamic Re-decoration: Swap an existing decorator on a function with a new one.
  • Thread-Safe: All patching operations are protected by a threading.Lock to prevent race conditions in multi-threaded applications.
  • Async Compatible: Works seamlessly with async def functions and methods.
  • Robust: Correctly handles instance methods, @classmethod, and @staticmethod.
  • Flexible Error Handling: Choose whether to raise exceptions on failure or receive a simple boolean success/failure status.

Installation

You can install the package from the Python Package Index (PyPI) using pip.

pip install draping

Quick Start

from draping import decorate, undecorate

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Decorator is running!")
        return func(*args, **kwargs)
    return wrapper

def greet(name):
    print(f"Hello, {name}!")

# Run the original function
greet("World")
# > Hello, World!

# Apply the decorator at runtime
decorate(my_decorator, greet)

# Run the function again - it's now decorated
greet("World")
# > Decorator is running!
# > Hello, World!

# Remove the decorator
undecorate(greet)

# The function is back to its original state
greet("World")
# > Hello, World!

or

# import everything...
from draping import decorate, redecorate, undecorate
from typing import Callable
import functools

# defining things to play with
def r(x):
    print(f'From inside: {x=}')
def deco_factory(name: str) -> Callable:
    """
    This factory creates a decorator that prints its name when called
    """
    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            print(f"    (Decorator '{name}' entering)")
            y = func(*args, **kwargs)
            print(f"    (Decorator '{name}' leaving)")
            return y
        return wrapper
    return decorator

d1=deco_factory("D1")
d2=deco_factory("D2")

## Playing now

r(8)
# 'From inside: x=8'

decorate(d1, r)
# (True,)

r(8)
"""
    (Decorator 'D1' entering)
From inside: x=8
    (Decorator 'D1' leaving)
"""

>>> redecorate(d1,d2,r)
# (True,)

>>> r(8)
"""
    (Decorator 'D2' entering)
From inside: x=8
    (Decorator 'D2' leaving)
"""

undecorate(r)
# True

r(8)
# 'From inside: x=8;

API Reference & Examples

Below are the detailed signatures and examples for each function.

decorate()

Applies a decorator to one or more functions.

decorate(
    decorator: Callable,
    *functions: Callable,
    decorate_again: bool = False,
    raise_on_error: bool = True
) -> tuple[bool, ...]
  • decorator: The decorator to apply.
  • *functions: The functions/methods you want to decorate.
  • decorate_again: If False (default), it will not apply the same decorator instance if it's already present. If True, it will stack the decorator on top of itself.
  • Returns: A tuple of booleans indicating success for each function.

undecorate()

Removes a decorator from a function.

undecorate(
    func: Callable,
    decorator_to_remove: Optional[Callable] = None,
    *,
    if_topmost: bool = False,
    raise_on_error: bool = True
) -> bool
  • func: The decorated function.
  • decorator_to_remove: The specific decorator instance to remove. If None (default), it removes the outermost decorator.
  • if_topmost: If True, decorator_to_remove is only removed if it is the outermost decorator.
  • Returns: True if a decorator was removed, False otherwise.

redecorate()

Finds and replaces a decorator (deco1) with another (deco2).

redecorate(
    deco1: Callable,
    deco2: Callable,
    *functions: Callable,
    change_all: bool = True,
    raise_on_error: bool = True
) -> tuple[bool, ...]
  • deco1: The decorator instance to find and remove.
  • deco2: The new decorator instance to apply in its place.
  • change_all: If True (default), replaces all instances of deco1. If False, replaces only the outermost instance.
  • Returns: A tuple of booleans indicating if a replacement occurred.

Helpers

These helper functions are designed to filter callables (functions or methods) from a class or a list/tuple of callables based on their names. They are particularly useful for selecting specific subsets of methods to decorate, undecorate, or redecorate dynamically. Each helper returns a tuple of filtered callables, which can be unpacked directly into functions like decorate(), undecorate(), or redecorate().

Unlike other modules that provide a single, monolithic function with many arguments to decorate an entire class (which can be inflexible and hard to compose), these helpers are stackable. You can chain them together to build complex filters step-by-step, applying multiple criteria sequentially for precise control.

All helpers share a common API:

  • obj: A class (to extract its callable attributes, excluding dunder methods like __init__) or a list/tuple of callables.
  • *args: One or more strings representing prefixes, substrings, or regex patterns (depending on the helper).
  • Returns: A tuple of filtered callables. If no *args are provided, returns an empty tuple (for positive filters) or all callables (for negative filters).

start_with()

Filters callables whose names start with any of the given prefixes.

start_with(obj: Any, *prefixes: str) -> tuple[Callable, ...]

not_start_with()

Filters callables whose names do not start with any of the given prefixes.

not_start_with(obj: Any, *prefixes: str) -> tuple[Callable, ...]

contain()

Filters callables whose names contain any of the given substrings.

contain(obj: Any, *substrings: str) -> tuple[Callable, ...]

not_contain()

Filters callables whose names do not contain any of the given substrings.

not_contain(obj: Any, *substrings: str) -> tuple[Callable, ...]

positive_re()

Filters callables whose names match any of the given regex patterns (using re.search).

positive_re(obj: Any, *patterns: str) -> tuple[Callable, ...]

negative_re()

Filters callables whose names do not match any of the given regex patterns (using re.search).

negative_re(obj: Any, *patterns: str) -> tuple[Callable, ...]

Direct Integration Example

Apply a decorator to all methods in MyClass that start with "add" or "pay":

class MyClass:
    def add_user(self): pass
    def pay_bill(self): pass
    def report_error(self): pass

decorate(my_decorator, *start_with(MyClass, "add", "pay"))
# Applies to add_user and pay_bill

Chaining Example

Start with all methods in MyClass (excluding dunders), exclude those starting with "_", then exclude those containing "test", and finally select those containing "pay":

filtered = contain(
               not_contain(
                   not_start_with(MyClass, "_"),
                   "test"
               ),
               "pay"
)

decorate(my_decorator, *filtered)
# Applies only to methods like pay_bill that match the chain

This stackable approach allows for flexible, composable filtering without cluttering a single function call with too many conflicting options.

Caveats

The from ... import ... Rule: Patching the Source, Not the Local Copy

A common pitfall when monkey-patching (and that;s what we do here) is trying to modify a function that has been imported directly into the local namespace using from module import function.

To correctly patch an imported function, you must always import the module itself and patch the function on the module object.

Wrong Way

This will report success (True) but will not actually affect the call to sin().

from math import sin
from draping import decorate

# This modifies math.sin, but your local 'sin' is unaffected.
decorate(my_decorator, sin)

# This calls the original, undecorated sin function.
sin(3)

Correct Way

This will work as expected.

import math
from draping import decorate

# Patch the function on its actual parent object.
decorate(my_decorator, math.sin)

# Call the function through the parent object.
math.sin(3)

Unwrapped decorators

Any 'standard' decorator has the internal closure decorated with @functools.wraps. Why so? The magic of @functools.wraps is that it does more than just copy the name and docstring of the original function. Its most important job is to create a special __wrapped__ attribute on the new wrapper function, which holds a direct reference to the original function it is wrapping.

Our undecorate() and redecorate() functions completely rely on this. They work by traversing this chain of __wrapped__ attributes to figure out what the original function was and which decorators have been applied.

What Happens Without @wraps

If a decorator is written without using @functools.wraps, the __wrapped__ attribute is never created. The link to the original function is lost. When undecorate() looks at the decorated function, it sees no __wrapped__ attribute and has no way of knowing that there's an original function hidden inside. It will treat it as a regular function and conclude that there is nothing to undecorate.

Example

Here is a simple demonstration of the concept:

import functools
from draping import undecorate

# A "good" decorator that uses @functools.wraps
def good_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("Good decorator is running!")
        return func(*args, **kwargs)
    return wrapper

# A "bad" decorator that does not
def bad_decorator(func):
    def wrapper(*args, **kwargs):
        print("Bad decorator is running!")
        return func(*args, **kwargs)
    # No @functools.wraps here!
    return wrapper


@good_decorator
def good_function():
    print("Running the good function.")

@bad_decorator
def bad_function():
    print("Running the bad function.")


# --- Verification ---

# The good function has the __wrapped__ attribute
print(f"Good function has __wrapped__: {hasattr(good_function, '__wrapped__')}")
# > Good function has __wrapped__: True

# The bad function does NOT
print(f"Bad function has __wrapped__: {hasattr(bad_function, '__wrapped__')}")
# > Bad function has __wrapped__: False


# --- Undecorating ---

print("\nAttempting to undecorate the good function...")
success = undecorate(good_function)
print(f"Success: {success}")
good_function() # Now runs without the decorator message

print("\nAttempting to undecorate the bad function...")
success = undecorate(bad_function, raise_on_error=False)
print(f"Success: {success}") # Fails as expected
bad_function() # Still has the decorator message

This is why using @functools.wraps is a critical best practice when writing decorators in Python. It ensures that your decorators play nicely with other tools (like debuggers, documentation generators, and the draping module) that rely on introspection.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

draping-0.5.4.tar.gz (20.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

draping-0.5.4-py3-none-any.whl (18.6 kB view details)

Uploaded Python 3

File details

Details for the file draping-0.5.4.tar.gz.

File metadata

  • Download URL: draping-0.5.4.tar.gz
  • Upload date:
  • Size: 20.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.12.10

File hashes

Hashes for draping-0.5.4.tar.gz
Algorithm Hash digest
SHA256 678ad3529de2c6b561944208f74936bfd703746b37f328159f5304dde5fef8da
MD5 8cfcd235a2e206252fb60bce2c41fe9e
BLAKE2b-256 bda8f37aa5cfe58f90872fafc030b2038711419d652adf2122e6a4993b64d066

See more details on using hashes here.

File details

Details for the file draping-0.5.4-py3-none-any.whl.

File metadata

  • Download URL: draping-0.5.4-py3-none-any.whl
  • Upload date:
  • Size: 18.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.12.10

File hashes

Hashes for draping-0.5.4-py3-none-any.whl
Algorithm Hash digest
SHA256 ab6c6f3893928619759ee170e55738591656144f2d4043213795adf5334c1b7d
MD5 88d5e059d9ca917dce7ec9cf467ff2ee
BLAKE2b-256 e6ac5087eff44c41cef2604e22c0c05b8e08ebc59f454c958e556a3659f768ce

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page