Conditional Decorators in Python ################################ .. figure:: /images/dyndesign_conditional-decorators_intro.png :alt: 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 ***************************** .. figure:: /images/dyndesign_conditional-decorators_semaphore.png :alt: 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 “ 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. Written by Patrizio Gelosi --------------------------