Conditional Decorators in Python
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
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 'light' 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("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.