Apply and remove decorators to the fumctions (both sync and async) on-fly
Project description
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.Lockto prevent race conditions in multi-threaded applications. - Async Compatible: Works seamlessly with
async deffunctions 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: IfFalse(default), it will not apply the same decorator instance if it's already present. IfTrue, 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. IfNone(default), it removes the outermost decorator.if_topmost: IfTrue,decorator_to_removeis only removed if it is the outermost decorator.- Returns:
Trueif a decorator was removed,Falseotherwise.
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: IfTrue(default), replaces all instances ofdeco1. IfFalse, 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
*argsare 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
678ad3529de2c6b561944208f74936bfd703746b37f328159f5304dde5fef8da
|
|
| MD5 |
8cfcd235a2e206252fb60bce2c41fe9e
|
|
| BLAKE2b-256 |
bda8f37aa5cfe58f90872fafc030b2038711419d652adf2122e6a4993b64d066
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ab6c6f3893928619759ee170e55738591656144f2d4043213795adf5334c1b7d
|
|
| MD5 |
88d5e059d9ca917dce7ec9cf467ff2ee
|
|
| BLAKE2b-256 |
e6ac5087eff44c41cef2604e22c0c05b8e08ebc59f454c958e556a3659f768ce
|