Skip to main content

Python Decorators: Singleton, SemiSingleton, Multiton, Observer, Observable, generic Wrapper.

Project description

Introduction

The decoratory package is based on the Decorator Arguments Template, an integrated concept for Python decorators with and without parameters. In addition, all decorators created with it support complex arguments, e.g. lists of values and functions, without unnecessarily complicating the decoration of simple cases by these extensions. All implementation details are described on the Project Homepage.

Installation

pip install --upgrade decoratory

After installation, basic information about the package, its individual modules and their methods is available from the command line.

python -m decoratory --help

In particular, there is a comprehensive unit test for each module, which can be executed from the command line using the --test option.

python -m decoratory --test

Package Contents

The decoratory package includes some classic decorators implemented and functionally extended with this concept, e.g.

This is an open list of modules that possibly will grow over time.

Description

To illustrate the functionality of each module, simple as well as more complex examples are presented. Even if only one particular module is needed, it is recommended to view the preceding examples as well. For more examples of the full range of possibilities, please refer to Decorator Implementations on the Project Homepage.

Singleton

A singleton pattern is a design pattern that limits the instantiation of a class to a single (unique) instance. This is useful when exactly one unique object is needed i.e. to manage an expensive resource or coordinate actions across module boundaries.

As a simple example serves the decoration of the class Animal as a singleton. In the context of the Decorator Arguments Template, this can be done both without brackets (decorator class) and with brackets (decorator instance), meaning both notations describe the same functional situation.

# *** example_singleton.py - class Animal with Singleton decoration

from decoratory.singleton import Singleton

@Singleton                      # or @Singleton()
class Animal:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"{self.__class__.__name__}('{self.name}')"

# Create Instances
a = Animal(name='Teddy')        # Creates Teddy, the primary instance
b = Animal(name='Roxie')        # Returns Teddy, no Roxi is created

If instances of the class Animal are now created, this is only done for the very first instantiation, and for all further instantiations always this primary instance is given back.

# *** example_singleton.py - verfication of the unique instance

# Case 1: Static decoration using @Singleton or @Singleton()
print(f"a = {a}")               # a = Animal('Teddy')
print(f"b = {b}")               # b = Animal('Teddy')
print(f"a is b: {a is b}")      # a is b: True
print(f"a == b: {a == b}")      # a == b: True

If instead of the above static decoration using pie-notation, i.e. with @-notation at the class declaration, the dynamic decoration within Python code is used, additional parameters can be passed to the decorator for passing to or through the class initializer.

# *** example_singleton.py - dynamic decoration with extra parameters

# Case 2: Dynamic decoration providing extra initial default values
Animal = Singleton(Animal, 'Teddy')
Animal()                        # Using the decorator's default 'Teddy'
a = Animal(name='Roxie')        # Returns Teddy
print(a)                        # Animal('Teddy')

Quite generally, for all the following decorators based on this Decorator Arguments Template, these two properties are always fulfilled:

  1. Decoration as a class (without parentheses) and Decoration as an instance (with empty parentheses) are equivalent

  2. For dynamic decoration, extra parameters can be passed, e.g. for the class initializer

So far, this singleton implementation follows the concept of once forever, i.e. whenever a new instance of a class is created, one always gets the primary instance back - without any possibility of ever changing it again.

Although this behavior is consistent with the fundamental concept of a singleton, there are situations where it might be useful to reset a singleton. Such a resettable singleton, also called semi-singleton, could be useful to express in code that an instance is often retrieved but rarely changed.

# *** example_singleton.py - decoration as 'resettable singleton'

@Singleton(resettable=True)     # Exposes an additional reset method
class Animal:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"{self.__class__.__name__}('{self.name}')"

# Case 3: Decoration using @Singleton(resettable=True)
print(Animal(name='Teddy'))     # Animal('Teddy')
print(Animal(name='Roxie'))     # Animal('Teddy')   (=primary instance)
Animal.reset()                  # Reset the singleton
print(Animal(name='Roxie'))     # Animal('Roxie')
print(Animal(name='Teddy'))     # Animal('Roxie')   (=primary instance)

Without this striking resettable=True decoration Animal has no reset method and the call Animal.reset() will fail raising an AttributeError. For situations where this concept needs to be used more often, a subclass shortcut SemiSingleton is provided.

# *** example_singleton.py - decoration as a 'semi singleton'

from decoratory.singleton import SemiSingleton

@SemiSingleton                  # or @SemiSingleton()
class Animal:
    pass                        # Some code ...

Both Singleton and SemiSingleton of course provide a get_instance() method to directly retrieve the primary instance, e.g. using Animal.get_instance().

Hint — Using reset() and get_instance() in combination

It should be noted that the combination of reset() and immediately following get_instance() does not return a valid object, but None. So a reset() should always be followed by an instantiation to ensure that a valid singleton instance exists.

Within the main process of Python’s Global Interpreter Lock (GIL), both Singleton and SemiSingleton are thread-safe. In example, using a ThreadPoolExecutor threadsafety can be easily demonstrated with sample code like this:

# *** example_singleton.py - threadsafety

from decoratory.singleton import Singleton
from concurrent.futures import ThreadPoolExecutor, as_completed

@Singleton                      # or @Singleton()
class Animal:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"{self.__class__.__name__}('{self.name}')"

# Create Instances
names = ["Teddy", "Roxie", "Molly", "Benny"]
with ThreadPoolExecutor(max_workers=2) as tpe:
    futures = [tpe.submit(Animal, name) for name in names]

# Case 4: Decoration using @Singleton
for future in futures:
    instance = future.result()  # All the same instances, i.e.
    print(instance)             # Animal('Teddy') -- four times!

The same instance is always presented, most likely Animal('Teddy') of the first submitted thread, but it could also be any of the others.

Multiton

A multiton pattern is a design pattern that extends the singleton pattern. Whereas the singleton allows for exactly one instance per class, the multiton ensures one single (unique) instance per key.

In this implementation, the key parameter can be anything that is possible as a key for a Python dict() dictionary, such as an immutable type or a callable eventually returning such an immutable type etc.

In case of an invalid key, key is set None and with only one key value the multiton simply collapses to a singleton, therefore the decoration @Multiton resp. @Multiton() or even @Multiton(key=17) or @Multiton(key='some constant value') and so on always creates a singleton.

Normally, the key is part of or is composed from the initial values of the classified object, as in the following example, where the key function matches the signature of the initializer and uses the initial value of the name parameter to construct a key value for the instances of Animal.

# *** example_multitonton.py - class Animal with Multiton decoration

from decoratory.multiton import Multiton

@Multiton(key=lambda spec, name: name)
class Animal:
    def __init__(self, spec, name):
        self.spec = spec
        self.name = name

    def __repr__(self):
        return f"{self.__class__.__name__}('{self.spec}', '{self.name}')"

# Create Instances
a = Animal('dog', name='Teddy')
b = Animal('cat', name='Molly')
c = Animal('dog', name='Roxie')

When instances of the class Animal are now created, this only happens for the first instantiation per key value, the initial name of the animal. For all subsequent instantiations, this primary instance per key value is returned. But for each new key value, a new Animal instance is created and stored in the internal directory.

# *** example_multitonton.py - One unique instance per name

# Case 1: decoration @Multiton(key=lambda spec, name: name)
print(a)                        # Animal('dog', 'Teddy')
print(b)                        # Animal('cat', 'Molly')
print(c)                        # Animal('dog', 'Roxie')

With three different names, a separate instance is created in each case. In contrast, the following variant distinguishes only two types (equivalence classes): animals with a character ‘y’ in their name and those without and thus the key values can only be True or False.

# *** example_multitonton.py - One unique instance per equivalence class

# Case 2: decoration @Multiton(key=lambda spec, name: 'y' in name)
print(a)                        # Animal('dog', 'Teddy')
print(b)                        # Animal('dog', 'Teddy')
print(c)                        # Animal('dog', 'Roxie')

The initial parameter values of the initializer can also be accessed by their args-index or kwargs-name. So the following decorations are also possible:

# *** example_multitonton.py - Alternative decoration examples

# Case 3: One unique instance per specie
@Multiton(key="{0}".format)     # spec is args[0]
class Animal:
    pass                        # Some code ...

# Case 4: One unique instance per name
@Multiton(key="{name}".format)  # name is kwargs['name']
class Animal:
    pass                        # Some code ...

# Case 5: One unique instance for all init values, i.e. no duplicates
@Multiton(key=lambda spec, name: (spec, name))
class Animal:
    pass                        # Some code ...

# Case 6: One unique instance from a @staticmethod or @classmethod
@Multiton(key=F("my_key"))      # Late binding with F(classmethod_string)
class Animal:
    pass                        # Some code ...

    @classmethod
    def my_key(cls, spec, name):
        return 'y' in name

To actively control access to new equivalence classes, Multiton provides the seal(), unseal(), and issealed() methods for sealing, unsealing, and checking the sealing state of the Multiton. By default, the sealing state is set False, so for every new key a new (unique) object is instantiated. When sealed (e.g. later in the process) is set True the dictionary has completed, i.e. restricted to the current object set and any new key raises a KeyError.

In situations where it might be useful to reset the multiton to express in code that instances are often retrieved but rarely modified, setting the decorator parameter resettable=True will expose the reset() method, by means of which the internal directory of instances can be completely cleared.

Last but not least, Multiton provides a instances property and associated getter and setter methods to directly retrieve the internal dictionary of primary instances. It is obvious that manipulations on this directory can corrupt the functionality of the multiton, but sometimes it is useful to have the freedom of access.

Hint — Changes affecting key values of classified objects

Classifications into the multiton directory are done only once on initial key data. Subsequent changes affecting a key value are not reflected in the multiton directory key, i.e. the directory may then be corrupted by such modifications.

Therefore, never change key related values of classified objects!

All these things taken together could give the following exemplary picture:

# *** example_multitonton.py - seal, unseal, reset, get_instance

# Case 7: with decoration @Multiton(key=lambda spec, name: name,
#                                   resettable=True)
Animal.reset()                  # Because of resettable=True
print(Animal.get_instances())   # {}
print(Animal.issealed())        # False     (=default)
Animal('dog', name='Teddy')     # Animal('dog', 'Teddy')
print(Animal.get_instances())   # {'Teddy': Animal('dog', 'Teddy')}
Animal.seal()                   # Seal the multiton!
print(Animal.issealed())        # True
try:                            # Try to..
    Animal('cat', name='Molly') # .. add a new animal
except  KeyError as ex:         # .. will fail
    print(f"Sorry {ex.args[1]}, {ex.args[0]}")
print(Animal.get_instances())   # {'Teddy': Animal('dog', 'Teddy')}
Animal.unseal()                 # Unseal the multiton!
print(Animal.issealed())        # False
Animal('cat', name='Molly')     # Now, Molly is added
print(Animal.get_instances())   # {'Teddy': Animal('dog', 'Teddy'),
                                #  'Molly': Animal('cat', 'Molly')}
Animal.get_instances().pop('Teddy')
print(Animal.get_instances())   # {'Molly': Animal('cat', 'Molly')}
Animal.get_instances().clear()  # Same as Animal.reset()
print(Animal.get_instances())   # {}

The last two lines show the functional equivalence of Animal.get_instances().clear() with Animal.reset(), but the reset option is more transparent because it is not necessary to look “behind the scenes”.

Within the main process of Python’s Global Interpreter Lock (GIL), Multiton is thread-safe. In example, using a ThreadPoolExecutor threadsafety can be easily demonstrated with sample code like this:

# *** example_multiton.py - threadsafety

from decoratory.multiton import Multiton
from concurrent.futures import ThreadPoolExecutor, as_completed

@Multiton(key=lambda spec, name: spec)
class Animal:
    def __init__(self, spec, name):
        self.spec = spec
        self.name = name

    def __repr__(self):
        return f"{self.__class__.__name__}('{self.spec}', '{self.name}')"

# Create Instances
pets = [('dog', 'Teddy'), ('dog', 'Roxie'),    # dogs
        ('cat', 'Molly'), ('cat', 'Felix')]    # cats
with ThreadPoolExecutor(max_workers=2) as tpe:
    futures = [tpe.submit(Animal, *pet) for pet in pets]

# Case 8: Decoration using spec: @Multiton(key=lambda spec, name: spec)
for future in futures:          # Same instance per spec (=key), i.e.
    instance = future.result()  # Animal('dog', 'Teddy') - for all dogs
    print(instance)             # Animal('cat', 'Molly') - for all cats

Per type of animal (key = spec) always the same instance is presented, most likely Animal('Teddy') for all dogs and Animal('cat', 'Molly') for all cats, resulting from the first submitted thread per species, but it could also be any of the others.

Wrapper

As the name implies, a wrapper encloses the original function with an

  • (optional) before call functionality

and/or an

  • (optional) after call functionality.

This implementation additionally supports an

  • (optional) replace call functionality.

This generic Wrapper is all the more broadly applicable, the more flexibly these three activities can be formulated. All three decorator parameters, before, after and replace, can be combined with each other and support both single callables and (nested) lists of F-types (imported from module decoratory.basic, see F signature below for details). In addition, replace supports passing a result object from successive replacement calls through an optional keyword argument named result with a defaut value, e.g. result=None.

Even without any of these arguments, such an empty wrapper can be used to overwrite default values, for example.

# *** example_wrapper.py - overwrite default parameter values

from decoratory.wrapper import Wrapper

# Case 1: Dynamic decoration with decorator arguments, only
def some_function(value: str = "original"):
    print(f"value = '{value}'")

# Function call with default parameters
some_function()                 # value = 'original'
some_function = Wrapper(some_function, value="changed")
some_function()                 # value = 'changed'

The functionality of some_function() itself remains unchanged. For the sake of clarity, the principle of all or nothing is applied, i.e. defaults must be defined for all parameters and they are only used if no current parameters at all are transmitted. There is no mixing of current and default parameters. Thus, even a call of the decorated function with an incomplete parameter set is explicitly not supported and will throw a TypeError.

A typical scenario for a wrapper is, of course, the execution of additional functionality before and/or after a given functionality, which itself remains unchanged, such as enter/leave markers, call data caches, runtime measurements, etc. Here is a typical example:

# *** example_wrapper.py - enclose original function

from decoratory.wrapper import Wrapper
from decoratory.basic import F

# Case 2: Decoration with before and after functionalities
def print_message(message: str = "ENTER"):
    print(message)

@Wrapper(before=print_message, after=F(print_message, "LEAVE"))
def some_function(value: str = "original"):
    print(f"value = '{value}'")

some_function()                 # ENTER
                                # value = 'original'
                                # LEAVE

While before calls print_message with its default parameters the after parameter uses the F-function from decoratory.basic. It has a signature F(callable, *args, **kwargs) and encapsulates the passing of any function with optional positional and keyword parameters. Accordingly, the keyword parameter after=F(print_message, message="LEAVE") would also be possible.

The idea behind the replace option is not so much to replace the complete original functionality, because you could simply create your own functionality for that but to wrap the original functionality, e.g. according to the principle:

  1. Edit and/or prepare the call parameters for the original functionality

  2. Execute the original functionality with these modified call parameters

  3. Edit and/or revise the result and return this modified result

All this together could then look like this:

# *** example_wrapper.py - enclose and replacing original function

# Case 3: Decoration with replace functionality
def replace_wrapper(value: str="replace"):
    # 1. Edit the call parameters for the original functionality
    value = value.upper()
    # 2. Execute original functionality with modified call parameters
    result = some_function.substitute.callee(value)             # (1)
    # 3. Edit the result and return this modified result
    return f"result: '{result}'"

@Wrapper(replace=replace_wrapper)
def some_function(value: str = "original"):
    print(f"value = '{value}'")
    return value

result = some_function()        # value = 'REPLACE'
print(result)                   # result: 'REPLACE'

The first output value = 'REPLACE' comes from the original function some_function() but using parameters modified to uppercase letters by the``replace_wrapper()``. The second line result: 'REPLACE' is the result of the return modified by the replace_wrapper(). Please note the line marked with (1) in the replace_wrapper(): It is very important to avoid self-recursions:

Hint — Avoidance of self-recursion in the replace wrapper

In the replace wrapper, the undecorated version of the original functionality must always be called. It is accessible via the substitute.callee method of the wrapper!

For the sake of completeness, a rather more complex example illustrates the replacement of the original functionality with a sequence of replacement functionalities, passing a result object of type int between successive calls.

# *** example_wrapper.py - enclose and replacing original function

# Case 4: Decoration with before, after and multiple replacements
def print_message(message: str = "UNDEFINED"):
    print(message)

def replacement_printer(add: int = 1, *, result=None):
    result += add if isinstance(result, int) else 0
    print(f"result = {result}")
    return result

@Wrapper(before=F(print, "ENTER"), # Python's print()
         replace=[F(replacement_printer, 1, result=0),
                  F(replacement_printer, 3),
                  F(replacement_printer, 5)],
         after=F(print_message, "LEAVE"))
def result_printer(message: str = "UNKNOWN"):
    print(message)

result_printer()                # ENTER         (before)
                                # result = 1    (replacement_printer, 1)
                                # result = 4    (replacement_printer, 3)
                                # result = 9    (replacement_printer, 5)
                                # LEAVE         (after)
                                # 9             (output default_printer)

The absence of the outputs of UNDEFINED and UNKNOWN reflects the correct replacements by the decoration, and the order of execution is exactly as expected: before then replace then after and in each of these variables the lists are processed in ascending order.

The decoration of a class always refers to the initializer of the class, e.g.

# *** example_wrapper.py - class decoration

@Wrapper(before=F(print, "BEFORE init"), after=F(print, "AFTER init"))
class Animal:
    def __init__(self, name):
        self.name = name
        print("RUNNING init")

# Case 5: Decoration of a class always refers to __init__
a = Animal(name='Teddy')        # BEFORE init
                                # RUNNING init
                                # AFTER init

For all other methods applies:

Hint — Dynamic versus static decoration

Decorations to @staticmethod or @classmethod can be done analogously to the function decorations above, since they already exist at compile time. Instance methods, on the other hand, do not exist until an object instance is created and must be decorated dynamically as an instance (e.g. see Instance Decoration below).

With Wrapper and custom service functions, a private wrapper library can be built and reused.

# *** example_wrapper.py - private wrapper library

# Case 6: Define a private wrapper library
before_wrapper = Wrapper(before=F(print, "BEFORE"))
after_wrapper = Wrapper(after=F(print, "AFTER"))

# Multiple decorations for specialized functionality encapsulation
@before_wrapper
@after_wrapper
def some_function(value: str = "original"):
    print(f"value = '{value}'")

some_function()                 # BEFORE
                                # value = 'original'
                                # AFTER

Observer

The observer pattern is generally used to inform one or more registered objects (observers, subscribers, objects) about selected actions of an observed object (observable, publisher, subject).

The time of activation is set to AFTER by default, i.e. the observable performs its own activity and then activates all registered observers in the specified order. This setting can be adjusted to before, after, both or even no activation at all via the parameter activate of Observable.

This implementation provides several ways to decorate a function or class as an observable or observer.

Observable Decoration

The simplest and at the same time the most pythonic variant of decoration is to decorate only the observed entities as an Observable.

This is possible because all observer pattern functionalities are concentrated in the Observable.BaseClass = BaseObservable of the observable decorator, while the Observer.BaseClass = BaseObserver of the observer decorator remains empty here. If necessary, it is possible to inherit from both BaseClasses to modify their functionalities.

# *** example_observer.py - observable decoration

from decoratory.observer import Observable
from decoratory.basic import F

def person(say: str = "Hello?"):
    print(f"{person.__name__} says '{say}'")

@Observable(observers=F(person, 'Hey, dog!'))
def dog(act: str = "Woof!"):
    print(f"{dog.__name__} acts '{act}'")

# Case 1: Observable decoration
#    ---> Person as an observer to observable dog
person()                        # person says 'Hello?'    (person acting)
dog()                           # dog acts 'Woof!'        (dog acting)
                                # person says 'Hey, dog!' (observer to dog)

Obviously, the addressed observer, the person, must be declared before the observed dog. With the simpler decoration @Observable(observers=person) the person would always respond with their default action and say 'Hello?'. The usage of F enables the transfer of individual parameters to the observer.

Due to hierarchies in stacked observer patterns, a more detailed management of observed vs. observing objects may be necessary.

# *** example_observer.py - observable decoration

def person(say: str = "Hello?"):
    print(f"{person.__name__} says '{say}'")

@Observable(observers=F(person, 'Hey, cat!'))
def cat(act: str = "Meow!"):
    print(f"{cat.__name__} acts '{act}'")

@Observable(observers=[F(cat, 'Roar!'), F(person, 'Hey, dog!')])
def dog(act: str = "Woof!"):
    print(f"{dog.__name__} acts '{act}'")

# Case 2: Stacked observable decoration
#    ---> Cat observes dog, person observes cat and dog
person()                        # person says 'Hello?'    (person acting)

cat()                           # cat acts 'Meow!'        (cat acting)
                                # person says 'Hey, cat!' (observer to cat)

dog()                           # dog acts 'Woof!'        (dog acting)
                                # cat acts 'Roar!'        (observer to dog)
                                # person says 'Hey, cat!' (observer to cat)
                                # person says 'Hey, dog!' (observer to dog)

Person is an observer, but not an observable, so the call to person() reflects only person’s own activity. Cat is an observable that is observed by person and therefore the activity cat() triggers a follow-up activity by person. Calling dog() results in three activities at the observers, because dog() is observed by the observed cat, which informs the person about its own activity.

The order of reactions is determined by the order in the list in which the cat observes the dog prior to the person. If this order is reversed:

# *** example_observer.py - observable decoration

@Observable(observers=[F(person, 'Hey, dog!'), F(cat, 'Roar!')])
def dog(act: str = "Woof!"):
    print(f"{dog.__name__} acts '{act}'")

# Case 3: Stacked observable decoration
#    ---> Cat observes dog, person observes dog and cat
dog()                           # dog acts 'Woof!'        (dog acting)
                                # person says 'Hey, dog!' (observer to dog)
                                # cat acts 'Roar!'        (observer to dog)
                                # person says 'Hey, cat!' (observer to cat)

Again, calling dog() results in three activities at the observers, but here person reacts first as an observer to dog and later again as an observer to cat.

If this behavior is not desired, dog() can instead address the original cat using the cat.substitute.callee, i.e.

# *** example_observer.py - observable decoration

@Observable(observers=[F(cat.substitute.callee, 'Roar!'),
                       F(person, 'Hey, dog!')])
def dog(act: str = "Woof!"):
    print(f"{dog.__name__} acts '{act}'")

# Case 4: Stacked observable decoration
#    ---> Original cat observes dog, person observes dog and cat
dog()                           # dog acts 'Woof!'        (dog acting)
                                # cat acts 'Roar!'        (observer to dog)
                                # person says 'Hey, dog!' (observer to dog)

In this case, cat acts before person because of the order of the observer list and because the original cat observes dog the Hey, cat! statement of person is missing.

Observer Decoration

In this reversed decoration scheme, the observer decorator collects its observables. This seems more elaborate at first glance, but some prefer to explicitly designate the Observer and Observable roles in their code.

Because an observer decoration uses observable methods, all observable(s) must always be declared and decorated before their observer(s).

1. Rule: Declare Observables before Observers

2. Rule: Decorating as @Observable before using in an @Observer

Thus, the initial example Case 1 from Observable Decoration translates to:

# *** example_observer.py - observer decoration

from decoratory.observer import Observer, Observable
from decoratory.basic import X

@Observable
def dog(act: str = "Woof!"):    # 1. Rule: declare dog before person!
    print(f"{dog.__name__} acts '{act}'")

@Observer(observables=X(dog, 'Hey, dog!'))
def person(say: str = "Hello?"):
    print(f"{person.__name__} says '{say}'")

# Case 1: Observer decoration
#    ---> Person as an observer to observable dog
person()                        # person says 'Hello?'
dog()                           # dog acts 'Woof!'        (dog acting)
                                # person says 'Hey, dog!' (observer to dog)

The use of the semantic cross-function X from decoratory.basic instead of F indicates that dog is the observable, but the X arguments apply for the observer person.

For multiple decorations, the order of decoration is also relevant here. The situation Case 2 from Observable Decoration with person, dog and cat would then look like:

# *** example_observer.py - observer decoration

@Observable                     # 2. Rule: dog before cat & person
def dog(act: str = "Woof!"):    # 1. Rule: dog before cat & person
    print(f"{dog.__name__} acts '{act}'")

@Observer(observables=X(dog, 'Roar!'))
@Observable                     # 2. Rule: observable cat before person
def cat(act: str = "Meow!"):    # 1. Rule: cat before person
    print(f"{cat.__name__} acts '{act}'")

@Observer(observables=[X(dog, 'Hey, dog!'),
                       X(cat.substitute.callee, say='Hey, cat!')])
def person(say: str = "Hello?"):
    print(f"{person.__name__} says '{say}'")

# Case 2: Stacked observer decoration
#    ---> Cat observes dog, person observes cat and dog
person()                        # person says 'Hello?'    (person acting)

cat()                           # cat acts 'Meow!'        (cat acting)
                                # person says 'Hey, cat!' (observer to cat)

dog()                           # dog acts 'Woof!'        (dog acting)
                                # cat acts 'Roar!'        (observer to dog)
                                # person says 'Hey, cat!' (observer to cat)
                                # person says 'Hey, dog!' (observer to dog)

Here, cat becomes an observer but its callee cat.substitute.callee is an observable which can be observed by person! This observed cat observes the dog, reacts and triggers the person.

To reproduce Case 4 from above, simply swap the order of the decorations at the cat and the dog then is observed by the original cat.

# *** example_observer.py - observer decoration

@Observable                     # 2. Rule: dog before cat & person
def dog(act: str = "Woof!"):    # 1. Rule: dog before cat & person
    print(f"{dog.__name__} acts '{act}'")

@Observable                     # 2. Rule: cat before person
@Observer(observables=X(dog, 'Roar!'))
def cat(act: str = "Meow!"):    # 1. Rule: cat before person
    print(f"{cat.__name__} acts '{act}'")

@Observer(observables=[X(dog, 'Hey, dog!'), X(cat, say='Hey, cat!')])
def person(say: str = "Hello?"):
    print(f"{person.__name__} says '{say}'")

# Case 3: Stacked observer decoration
#    ---> Cat observes dog, person observes cat and dog
person()                        # person says 'Hello?'    (person acting)

cat()                           # cat acts 'Meow!'        (cat acting)
                                # person says 'Hey, cat!' (observer to cat)

dog()                           # dog acts 'Woof!'        (dog acting)
                                # cat acts 'Roar!'        (observer to dog)
                                # person says 'Hey, dog!' (observer to dog)

Now, both dog and cat end up being observables, observed by the person. But the cat observing the dog is the original cat, which does not inform the person about its activities, and so person’s statement Hey, cat! is missing.

Class Decoration

Both techniques, Observable Decoration and Observer Decoration, are static, in the sense, decorations are done e.g. in @-notation evaluated at compile time. They are applied to static functions.

Decoration of a class by default addresses decoration of the class initializer, this means

@Observable
class Dog:
    def __init__(self):
        pass                    # Some code ...

should be understood as

class Dog:
    @Observable
    def __init__(self):
        pass                    # Some code ...

But this behavior is insidious, e.g.

# *** example_observer.py - class decoration

from decoratory.observer import Observable

class Person:
    def __init__(self, name: str = "Jane Doe"):
        print(f"{name} arrived.")

@Observable(observers=Person)
class Dog:
    def __init__(self, name: str = "Teddy"):
        print(f"Dog {name} arrived.")

# Case 1: Dog is an observable to Person
prs = Person()                  # Jane Doe arrived.
dog = Dog()                     # Dog Teddy arrived.
                                # Jane Doe arrived.

The instantiation of Dog induced an instantiation of Person.

Hint — Take care when decorating a class initializer

Notifying the __init__ method of an observer results in a new instance! This means calling the observable induces instantiation of a new observer object, surely in not any case this is the desired behavior …

So the decoration should not address a class but one (or more) target methods of the class. As already mentioned, this is easy if this callback function is a @staticmethod or @classmethod.

# *** example_observer.py - class decoration

class Person:
    def __init__(self, name: str = "Jane Doe"):
        print(f"{name} arrived.")

    @staticmethod
    def action1(act: str = "Hello?"):
        print(f"Person says {act}")

    @classmethod
    def action2(cls, act: str = "What's up?"):
        print(f"Person says {act}")

@Observable(observers=[Person.action1, Person.action2])
class Dog:
    def __init__(self, name: str = "Teddy"):
        print(f"Dog {name} arrived.")

# Case 2: Dog is an observable to Person.action
prs = Person()                  # Jane Doe arrived.
dog = Dog()                     # Dog Teddy arrived.
                                # Person says Hello?
                                # Person says What's up?

This is how it usually works: one action of the observable, here it’s the instantiation of Dog, triggers one to many actions at each observer, here Person.

But often an instance method is also interesting as a callback function:

  • If a particular instance prs = Person(name="John Doe") of a person is meant, a decoration like @Observable(observers=prs.action) with the instance method can be applied to Dog.

  • For any instance of a person @Observable(observers=Person().action) works.

Even a list of F structures would be possible to optionally submit different parameters.

# *** example_observer.py - class decoration

from decoratory.observer import Observable
from decoratory.basic import F

class Person:
    def __init__(self, name: str = "Jane Doe"):
        self.name = name
        print(f"{name} arrived.")

    def action(self, act: str = "Hello?"):
        print(f"{self.name} says {act}")

prs1 = Person()                 # Jane Doe arrived.
prs2 = Person("John Doe")       # John Doe arrived.

@Observable(observers=[prs1.action, F(prs2.action, "What's up?")])
class Dog:
    def __init__(self, name: str = "Teddy"):
        print(f"Dog {name} arrived.")

# Case 3: Dog is an observable to actions of various person instances.
dog = Dog()                     # Dog Teddy arrived.
                                # Jane Doe says Hello?
                                # John Doe says What's up?

But here, one action of the observable, the instantiation of Dog, triggers one to many actions at each selected resp. instantiated observer, Person. In such situations, a late dynamic decoration could be a good idea.

So far, instantiating Dog resulted in an information and induced action at Person. If Dog has its own actions that need to be selectively monitored, each of the selected actions can of course be decorated individually as an Observable. For the sake of a better overview, this can also be done on the class itself.

# *** example_observer.py - class decoration

class Person:
    def __init__(self, name: str = "Jane Doe"):
        self.name = name
        print(f"{name} arrived.")

    @classmethod
    def actionA(cls, act: str = "Hello?"):
        print(f"Person says {act}")

    def actionB(self, act: str = "Hello?"):
        print(f"{self.name} says {act}")

@Observable(methods=["action1", "action2"],
            observers=[Person.actionA, Person("Any Doe").actionB])
class Dog:
    def __init__(self, name: str = "Teddy"):
        self.name = name
        print(f"Dog {name} arrived.")

    @staticmethod
    def action1(act: str = "Woof!"):
        print(f"Dog acts {act}")

    def action2(self, act: str = "Brrr!"):
        print(f"{self.name} acts {act}")

# Case 4: Dog is an observable with selected actions.
                                # Any Doe arrived.
prs = Person()                  # Jane Doe arrived.
dog = Dog()                     # Dog Teddy arrived.

dog.action1()                   # Dog acts Woof!        (@staticmethod)
                                # Person says Hello?    (@classmethod)
                                # Any Doe says Hello?   (Instance 'Any')

Dog.action2(dog)                # Teddy acts Brrr!      (Instance 'Teddy')
                                # Person says Hello?    (@classmethod)
                                # Any Doe says Hello?   (Instance 'Any')

The last line Dog.action2(dog) provides the instance of Teddy as the first argument self. This works because internally the class method Dog.action2 was registered instead of an instance method that didn’t exist at compile time. On the other hand, the call dog.action2() would fail because this instance method was not registered. But, if this is what is to be achieved, an instance method can first be created and registered, just as seen above in Class Decoration, Case 3.

Instance Decoration

The classic way to exchange information between objects with the observer pattern is through the active use of the register, dispatch, and unregister interface methods that an observable exposes. Information can be given to the right recipients at relevant places in the code. For this, the classes are not decorated and dynamic decoration comes into play. Dynamic decoration is used often also in connection with getter/setter/property constructions since data changes take place meaningfully over these methods.

Consider the following two example classes:

# *** example_observer.py - instance decoration

class Note:                             # Observer without decoration!
    def info(self, thing):
        print(f"Note.info: val = {thing.a}")

class Thing:                            # Observable without decoration!
    def __init__(self, a=0):            # Initializer, defining variabe 'a'
        self._a = a
    def inc(self):                      # Instance method, modifying 'a'
        self._a += 1
    def get_a(self):                    # Getter, setter, property,
        return self._a                  # modifying variable 'a'
    def set_a(self, value):
        self._a = value
    a = property(get_a, set_a)

Initially, all these classes are undecorated and typical actions might be:

# *** example_observer.py - instance decoration

from decoratory.observer import Observable
from decoratory.basic import F

# (1) Setup instances
nti = Note()                    # Note instance
tgi = Thing()                   # Thing instance

# (2) Dynamic decoration of some methods: Late binding
tgi.inc = Observable(tgi.inc)           # Late method decoration
Thing.set_a = Observable(Thing.set_a)   # Late property decoration
Thing.a = property(Thing.get_a, Thing.set_a)

# (3) Register the observer (Note) with the observable (Thing)
tgi.inc.observable.register(F(nti.info, tgi))
tgi.set_a.observable.register(F(nti.info, thing=tgi))

# Case 1: Change self.a = 0 using inc()
tgi.inc()                       # Note.info: val = 1

# Case 2: Change self.a = 1 using setter via property
tgi.a = 2                       # Note.info: val = 2

# Case 3: Notification from inc() to nti.info() about Thing(3)
tgi.inc.observable.dispatch(nti.info, Thing(3))
                                # Note.info: val = 3

# Case 4: Notification from set_a() to nti.info() about Thing(4)
tgi.set_a.observable.dispatch(nti.info, Thing(4))
                                # Note.info: val = 4

# Case 5: Print the current value of tgi.a
print(f"a = {tgi.a}")           # a = 2     (no changes by notification)

# Case 6: Print list of all observers
print(tgi.inc.observable.observers(classbased=True))
# ---> {'Note': ['F(info, <__main__.Thing object at ..)']}
print(tgi.set_a.observable.observers(classbased=True))
# ---> {'Note': ['F(info, thing=<__main__.Thing object at ..)']}

# Case 7: Unregister nti.info from tgi
tgi.inc.observable.unregister(nti.info)
print(tgi.inc.observable.observers(classbased=True))    # {}

In contrast to Class Decoration, this Instance Decoration

  1. instantiates the native classes (1), then

  2. decorates the relevant instance methods (2), and then

  3. registers the observers with the associated observables (3).

This method of instance decoration is certainly the most flexible. However, it bears the risk of losing track of all dependencies.

~~~ Home ~~~ Contents ~~~ Singleton ~~~ Multiton ~~~ Wrapper ~~~ Observer ~~~

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

decoratory-0.9.9.3.tar.gz (68.9 kB view hashes)

Uploaded Source

Built Distribution

decoratory-0.9.9.3-py3-none-any.whl (68.5 kB view hashes)

Uploaded Python 3

Supported by

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