Conditional Decorators in Python

Image generated by DALL·E from a prompt by the author

Image generated by DALL·E from a prompt by the author

Motivation

Python decorators are a code construct that provides additional functionality to our functions/methods.

But what if we need to modify the decorator’s behavior based on conditions?

How can we handle such scenarios effectively and maintain code quality?

This article serves as a step-by-step guide, addressing these questions, and delving into the use of Conditional Decorators. By exploring various approaches, including leveraging the powerful features of DynDesign package, we aim to improve the code quality and create more organized and flexible solutions for handling conditional behaviors.

Testing Ground: Traffic Light

Image generated by DALL·E from a prompt by the author

Image generated by DALL·E from a prompt by the author

As our testing ground, we will implement a Python script simulating a basic traffic light as described below.

The crossing of the road by a pedestrian named “NAME” is simulated through the display of the message “<NAME> is crossing…”, which may be enriched with informational messages based on the current state of the traffic light: - When the light is green, the crossing is permitted, and after crossing, it receives a “well done” acknowledgment. - When the light is red, crossing is forbidden, and after crossing, it is met with disapproval. - Finally, when the traffic light is off, no informational message is printed.

Traffic Light implemented with Builtin Decorators

Below is a possible implementation of the traffic light using a Python Builtin Decorator.

RED     = 1
GREEN   = 2

class TrafficLight:
    def __init__(self, name):
        self.status = None
        self.name = name

    def light_decorator(func):
        def wrapper(self):
            if self.status == RED:
                print(f"The light is RED: please STOP, {self.name}!")
            elif self.status == GREEN:
                print(f"The light is GREEN: now you can go, {self.name}.")
            func(self)
            if self.status == RED:
                print(f"Oh no! {self.name} ran a RED light!!")
            elif self.status == GREEN:
                print(f"Well done, {self.name}!")
        return wrapper

    @light_decorator
    def crossing(self):
        print(f"{self.name} is crossing...")


traffic_light = TrafficLight(name="Bob")
traffic_light.crossing()

# Bob is crossing...

traffic_light.status = GREEN
traffic_light.crossing()

# The light is GREEN: now you can go, Bob.
# Bob is crossing...
# Well done, Bob!

traffic_light.status = RED
traffic_light.crossing()

# The light is RED: please STOP, Bob!
# Bob is crossing...
# Oh no! Bob ran a RED light!!

In the code above, the crossing method is decorated with the light_decorator method, which prints informational messages based on the status attribute.

The status attribute of the traffic light can be assigned values of RED , GREEN , or None , signifying the respective states of the traffic light. When status is set to None , it indicates that the traffic light is turned off.

Shortcomings of this implementation

The implementation above suffers from two significant drawbacks: - There is a redundant repetition of the if..elif.. statement structure both before and after calling the decorated function. - Assuming that the management of the green light falls within a distinct scope from that of the red light, the Single Responsibility Principle (SRP) is not met, as both responsibilities should be handled within separate classes.

How to improve the Cleanliness of this Code?

Answering this question is not trivial. An alternative for the decorator wrapper could be as follows.

def wrapper(self):
    if self.status == RED:
        print(f"The light is RED: please STOP, {self.name}!")
        func(self)
        print(f"Oh no! {self.name} ran a RED light!!")
    elif self.status == GREEN:
        print(f"The light is GREEN: now you can go, {self.name}.")
        func(self)
        print(f"Well done, {self.name}!")
    else:
        func(self)

In this approach, the invocation of func occurs three times because we need to explicitly handle the case when the light is off. However, this serves as an initial step towards decoupling the responsibility of managing the green light from that of the red, as shown in the next version of the code.

Dependency Injection

By leveraging the Dependency Injection design pattern, we can separate the responsibility of handling different traffic light states into distinct classes. Specifically, we will introduce two new classes, GreenLight and RedLight , which will be responsible for managing the behavior of the traffic light when it is green and red, respectively. Each of these classes will encompass its own version of the light_decorator method, derived from the branches of the if..elif.. statement of the previous approach.

This ensures that each class focuses solely on its specific functionality, promoting a cleaner and more maintainable codebase.

class GreenLight:
    def light_decorator(self, func, decorated_self):
        print(f"The light is GREEN: now you can go, {decorated_self.name}.")
        func(decorated_self)
        print(f"Well done, {decorated_self.name}!")

class RedLight:
    def light_decorator(self, func, decorated_self):
        print(f"The light is RED: please STOP, {decorated_self.name}!")
        func(decorated_self)
        print(f"Oh no! {decorated_self.name} ran a RED light!!")

class TrafficLight:
    def __init__(self, name):
        self.light = None
        self.name = name

    def light_decorator(func):
        def wrapper(self):
            if self.light:
                self.light.light_decorator(func, self)
            else:
                func(self)
        return wrapper

    @light_decorator
    def crossing(self):
        print(f"{self.name} is crossing...")


traffic_light = TrafficLight(name="Bob")
traffic_light.crossing()

# Bob is crossing...

traffic_light.light = GreenLight()
traffic_light.crossing()

# The light is GREEN: now you can go, Bob.
# Bob is crossing...
# Well done, Bob!

traffic_light.light = RedLight()
traffic_light.crossing()

# The light is RED: please STOP, Bob!
# Bob is crossing...
# Oh no! Bob ran a RED light!!

The GreenLight.light_decorator and RedLight.light_decorator methods take the original function (func ) and the calling instance of TrafficLight (decorated_self ) as arguments. Concurrently, the TrafficLight .light_decorator method has been updated to accommodate the new classes.

Now, if a self.light instance exists, indicating that a green or red light is selected, it calls the corresponding light_decorator method of GreenLight or RedLight , passing the func and self arguments to apply the relevant behavior. Otherwise, if self.light is not set, the original function func is executed without decoration.

As a result, by setting the traffic_light.light attribute to an instance of either GreenLight or RedLight , we can dynamically change the behavior of the crossing method, simulating different traffic light states.

Can we further improve the Code?

As I review the light_decorator code within TrafficLight , I find myself dissatisfied for a couple of reasons.

Firstly, I am generally not fond of the code overhead that Builtin Decorators introduce, as they necessitate nesting a wrapper function inside a decorator function.

Secondly, I think that the functionality of the light_decorator method within TrafficLight , acting as an intermediary decorator to redirect to the specific light_decorator methods of GreenLight or RedLight , could be automated for a more efficient and concise implementation. In order to eliminate the need for TrafficLight.light_decorator , we would ideally want the Python interpreter to accept a decoration syntax such as @light.light_decorator . However, this syntax is not allowed since the light property is assigned dynamically and doesn’t exist statically.

@light.light_decorator
 ^^^^^
#NameError: name &#x27;light&#x27; is not defined.
def crossing(self):
    ...

Leveraging Dynamic Decorators

Good news! This is precisely the purpose for which the Dynamic Decorators from package DynDesign are designed.

As a result, we can now get rid of intermediary decorator TrafficLight.light_decorator , and can utilize the simplified syntax for the decorators that does not require nested wrappers.

from dyndesign import decoratewith

class GreenLight:
    def light_decorator(self, func, decorated_self):
        print(f"The light is GREEN: now you can go, {decorated_self.name}.")
        func(decorated_self)
        print(f"Well done, {decorated_self.name}!")

class RedLight:
    def light_decorator(self, func, decorated_self):
        print(f"The light is RED: please STOP, {decorated_self.name}!")
        func(decorated_self)
        print(f"Oh no! {decorated_self.name} ran a RED light!!")

class TrafficLight:
    def __init__(self, name):
        self.light = None
        self.name = name

    @decoratewith("light.light_decorator")
    def crossing(self):
        print(f"{self.name} is crossing...")


traffic_light = TrafficLight(name="Bob")
traffic_light.crossing()

# Bob is crossing...

traffic_light.light = GreenLight()
traffic_light.crossing()

# The light is GREEN: now you can go, Bob.
# Bob is crossing...
# Well done, Bob!

traffic_light.light = RedLight()
traffic_light.crossing()

# The light is RED: please STOP, Bob!
# Bob is crossing...
# Oh no! Bob ran a RED light!!

As a bonus , we no longer need to explicitly manage the case when the light is off. When no light.light_decorator method is found (due to no component class being loaded), the crossing method is executed with no decoration by default.

Dynamic Inheritance: an alternative approach to Dependency Injection

The above version of the code is overall satisfactory, but there are a couple of details that indicate the potential for an alternative version: - One might have some reservations about the signatures of the decorators within classes GreenLight and RedLight , as they require both self and decorated_self as parameters: having only one self parameter in the decorator signature would make the code easier to manage. - The current implementation works well in this simple case since TrafficLight is effectively decoupled from GreenLight and RedLight . However, in more complex scenarios, the most suitable relationship between the main class and its components might be inheritance.

For these reasons, I am presenting an alternative approach to Dependency Injection, which leverages another disruptive feature of DynDesign: Dynamic Inheritance.

With this approach, the classes GreenLight and RedLight are no longer component classes ; instead, they become parent classes that can be dynamically added or removed from the superclass set of TrafficLight .

from dyndesign import decoratewith, DynInheritance

class GreenLight:
    def light_decorator(self, func):
        print(f"The light is GREEN: now you can go, {self.name}.")
        func(self)
        print(f"Well done, {self.name}!")

class RedLight:
    def light_decorator(self, func):
        print(f"The light is RED: please STOP, {self.name}!")
        func(self)
        print(f"Oh no! {self.name} ran a RED light!!")

class TrafficLight(DynInheritance):
    def __init__(self, name):
        self.name = name

    @decoratewith("light_decorator")
    def crossing(self):
        print(f"{self.name} is crossing...")


traffic_light = TrafficLight(name="Bob")
traffic_light.crossing()

# Bob is crossing...

TrafficLight.dynparents_replace(GreenLight)
traffic_light.crossing()

# The light is GREEN: now you can go, Bob.
# Bob is crossing...
# Well done, Bob!

TrafficLight.dynparents_replace(RedLight)
traffic_light.crossing()

# The light is RED: please STOP, Bob!
# Bob is crossing...
# Oh no! Bob ran a RED light!!

# dynparents_replace with no arguments removes all the dynamic superclasses
TrafficLight.dynparents_replace()
traffic_light.crossing()

# Bob is crossing...

Using Dynamic Inheritance, we can dynamically replace the superclass set of the TrafficLight class with a new class set through the dynparents_replace method from DynDesign package.

Hence, by simply decorating the crossing method with @decoratewith(&quot;light_decorator “), we automatically ensure that the method is decorated with the light_decorator from the parent class whenever TrafficLight inherits from either GreenLight or RedLight . When TrafficLight does not inherit from either GreenLight or RedLight , indicating that the light is off, no decoration is applied to crossing .

Consequently, the classes TrafficLight , GreenLight , and RedLight share the same self instance, simplifying the communication and data sharing between them.

Conclusion

In this article, we embarked on a journey of code improvement for a use case involving Conditional Decorators.

Starting from a basic implementation with built-in decorators, we identified its limitations and explored the possibility of using decoratewith from the DynDesign package and Dependency Injection. This approach aims to decouple the responsibility of managing the green light from that of the red and to eliminate the need for intermediary decorators and made the code more concise and organized.

Building upon this progress, we introduced Dynamic Inheritance by using DynDesign’s dynparents_replace method, which led us to a more flexible and versatile solution. Through Dynamic Inheritance, we dynamically adjusted the superclass set of the TrafficLight class, which had a profound impact on simplifying the communication and data sharing between these classes.

By leveraging the unique features of DynDesign, we demonstrated how to create a more efficient and modular codebase, enhancing the overall readability and maintainability of the implementation. These improvements illustrate the power and potential of using DynDesign in handling Conditional Decorators and showcase the benefits of employing dynamic and extensible design patterns in Python development.

Written by Patrizio Gelosi