tel: +48 728 438 076
email: piotr.hyzy@eviden.com

Początek

Kilka zasad

  1. W razie problemów => chat, potem SMS i telefon, NIE mail
  2. Materiały szkoleniowe
  3. Wszystkie pytania są ok
  4. Reguła Vegas
  5. Słuchawki
  6. Kamerki
  7. Chat
  8. Zgłaszamy wyjścia na początku danego dnia, także pożary, wszystko na chacie
  9. By default mute podczas wykład
  10. Przerwy (praca 08:30 - 16:30)
    • blok 08:30 - 10:00
    • kawowa 10:00 - 10:15 (15')
    • blok 10:15 - 11:45
    • kawowa 11:45 - 12:00
    • blok 12:00 - 13:30
    • obiad 13:30 - 14:00
    • blok 14:00 - 15:15
    • kawowa 15:15 - 15:30
    • blok 15:30 - 16:30
  11. wszystkie czasy są plus/minus 10'
  12. Jak zadawać pytanie? 1) przerwanie 2) pytanie na chacie 3) podniesienie wirtualnej ręki
  13. IDE => dowolne
  14. Każde ćwiczenie w osobnym pliku/Notebooku
  15. Nie zapraszamy innych osób
  16. Zaczynamy punktualnie
  17. Ćwiczenia w dwójkach, rotacje, ask for help

Rundka - sprawdzenie mikrofonów

Advanced OOP

Class & Object Attributes

Example 1 (Primitives)

from typing import Any

class Person:
    phone = None

    def __init__(self, name: str) -> None:
        self.name = name

        self.name

    def foo(self):
        return int(5)
p1 = Person('John')
p2 = Person('Alice')
p1.phone = 98765
print('p2.phone =', p2.phone)
print('p1.phone =', p1.phone)

print('p1.__dict__ =', p1.__dict__)
print('p2.__dict__ =', p2.__dict__)

print('Person.__dict__ =', Person.__dict__)
p2.phone = None
p1.phone = 98765
p1.__dict__ = {'name': 'John', 'phone': 98765}
p2.__dict__ = {'name': 'Alice'}
Person.__dict__ = {'__module__': '__main__', 'phone': None, '__init__': <function Person.__init__ at 0x1044e6de0>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}

alt text

Example 2 (List)

class Person:
    phones = []

    db = dict()

    def __init__(self, name):
        self.name = name
p1 = Person('Jan')
p2 = Person('Anna')

p1.phones.append(98765)

print('p1.phones =', p1.phones)
print('p2.phones =', p2.phones)


print('p1.__dict__ =', p1.__dict__)
print('p2.__dict__ =', p2.__dict__)
print('Person.__dict__ =', Person.__dict__)
p1.phones = [98765, 98765]
p2.phones = [98765, 98765]
p1.__dict__ = {'name': 'Jan'}
p2.__dict__ = {'name': 'Anna'}
Person.__dict__ = {'__module__': '__main__', 'phones': [98765, 98765], '__init__': <function Person.__init__ at 0x10aae9440>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}

alt text

Example 3 (Replace List)

class Person:
    phones = []

    def __init__(self, name):
        self.name = name
p1 = Person('Jan')
p2 = Person('Anna')
p1.phones = [98765]
print('p2.phones =', p2.phones)
print('p1.phones =', p1.phones)
print('p1.__dict__ =', p1.__dict__)
print('p2.__dict__ =', p2.__dict__)
print('Person.__dict__ =', Person.__dict__)
p2.phones = []
p1.phones = [98765]
p1.__dict__ = {'name': 'Jan', 'phones': [98765]}
p2.__dict__ = {'name': 'Anna'}
Person.__dict__ = {'__module__': '__main__', 'phones': [], '__init__': <function Person.__init__ at 0x10aae9580>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}

alt text

@staticmethod Decorator

The @staticmethod decorator in Python is used to define a method that belongs to a class but does not require access to the instance (self) or class (cls) itself. This means that a static method can be called on the class or instance, but it cannot modify the instance or class state.

  • No self or cls argument: A static method does not take self (instance reference) or cls (class reference) as the first parameter.
  • Called on class or instance: You can call a static method using either the class or an instance of the class.

Static methods are useful when you need a method that logically belongs to a class but does not need to modify or access class-specific data.

class Person:

    phone = None

    def __init__(self, name):
        self.name = name

    def foo(self):
        print(self.__dict__)

    @classmethod
    def boo(cls):
        print(cls.__dict__)
p1 = Person('John')
p1.phone=123


p1.foo()
p1.boo()
{'name': 'John', 'phone': 123}
{'__module__': '__main__', 'phone': None, '__init__': <function Person.__init__ at 0x10ab5d4e0>, 'foo': <function Person.foo at 0x10ab5d940>, 'boo': <classmethod(<function Person.boo at 0x10ab5da80>)>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}
class MyClass:
    @staticmethod
    def foo():
        print('metoda statyczna')
a = MyClass()
a.foo()
MyClass.foo()
metoda statyczna
metoda statyczna
class MyClass:
    @staticmethod
    def greet(name):
        return f"Hello, {name}!"
# Calling on the class
print(MyClass.greet("Alice"))  # Outputs: Hello, Alice!
Hello, Alice!
# Calling on an instance
obj = MyClass()
print(obj.greet("Bob"))  # Outputs: Hello, Bob!
Hello, Bob!

Exercise: Implement Point.get_distance

Create a class Point that represents a point in a 2D space with x and y coordinates. Implement a static method get_distance that takes two Point objects and calculates the Euclidean distance between them.

Example:

p1 = Point(0, 0)
p2 = Point(3, 4)
print(Point.get_distance(p1, p2))  # Outputs: 5.0

The formula for Euclidean distance is:

$$ \text{distance} = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2} $$
#### Solution 1
from math import sqrt

class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    @staticmethod
    def get_distance(a: 'Point', b: 'Point'):
        return sqrt((a.x-b.x)**2 + (a.y-b.y)**2)

p1 = Point(0, 0)
p2 = Point(3, 4)
print(Point.get_distance(p1, p2))  # Outputs: 5.0
5.0
### Solution 2

from dataclasses import dataclass  # pip install dataclasses on Python 3.6 or ealier
from math import sqrt

@dataclass
class Point:
    x: float
    y: float

    @staticmethod
    def get_distance(a, b):
        return sqrt((a.x-b.x)**2 + (a.y-b.y)**2)


p1 = Point(0, 0)
p2 = Point(3, 4)
print(Point.get_distance(p1, p2))  # Outputs: 5.0
5.0
### Solution 3

from pydantic import BaseModel  # pip install dataclasses on Python 3.6 or ealier
from math import sqrt

class Point(BaseModel):
    x: float
    y: float

    @staticmethod
    def get_distance(a, b):
        return sqrt((a.x-b.x)**2 + (a.y-b.y)**2)


p1 = Point(x=5, y=7)
p2 = Point(x=1, y=4)
print(Point.get_distance(p1, p2))  # Outputs: 5.0
5.0

Special Methods

class Foo:
    def __init__(self):
        print('Constructor')

    def __str__(self):
        return 'Foo'

    def __len__(self):
        return 5

    def __getitem__(self, key):
        if key >= 5:
            raise IndexError
        return key + 2
f = Foo()

g = Foo()

print(f)

print(len(f))
print(len(g))

print(f[2])
print(f[6])
Constructor
Constructor
Foo
5
5
4
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[60], line 11
      8 print(len(g))
     10 print(f[2])
---> 11 print(f[6])

Cell In[58], line 13, in Foo.__getitem__(self, key)
     11 def __getitem__(self, key):
     12     if key >= 5:
---> 13         raise IndexError
     14     return key + 2

IndexError: 
  1. __init__(self):

    • Called when an instance of the class is created. It's the constructor method.
    • Example: f = Foo() will call __init__ and print 'Constructor'.
  2. __str__(self):

    • Called by str() or print() to provide a readable, string representation of an object.
    • Example: print(str(f)) will print 'Foo!!!'.
  3. __len__(self):

    • Called by len() to return the length of an object.
    • Example: len(f) will return 5.
  4. __getitem__(self, key):

    • Called when accessing elements using square brackets (e.g., f[key]).
    • Example: f[3] will return 5 because the method adds 2 to the key. If the key is 5 or greater, it raises an IndexError.

getattr

The __getattr__ method in Python is invoked as a "last resort" when an attribute is not found in an instance or class namespace. If you try to access an attribute that doesn't exist, Python will first look in the instance's __dict__, then the class's __dict__, and if the attribute is still not found, it will call the __getattr__ method (if it is defined).

class Bar:
    class_attr = "This is a class attribute"

    def __init__(self):
        self.instance_attr = 42  # This is an instance attribute

    def __getattr__(self, name):
        # This is only called if the attribute isn't found in the instance or class
        return f"'{name}' attribute not found, returning from __getattr__"
# Creating an instance
b = Bar()
# Accessing instance attribute
print(b.instance_attr)
42
# accesing instance method
print(b.__init__)
<bound method Bar.__init__ of <__main__.Bar object at 0x104797800>>
# Accessing class attribute
print(b.class_attr)
This is a class attribute
# Accessing a non-existent attribute
print(b.non_existent_attr)  # Outputs "'non_existent_attr' attribute not found, returning from __getattr__"
'non_existent_attr' attribute not found, returning from __getattr__

Breakdown of the attribute search:

  1. obj.instance_attr: Found in the instance, so it returns the value 42.
  2. obj.class_attr: Not found in the instance, but found in the class, so it returns "This is a class attribute".
  3. obj.non_existent_attr: Not found in either the instance or the class, so Python calls __getattr__, which returns the custom message.

This demonstrates how Python looks for an attribute first in the instance, then in the class, and finally resorts to __getattr__ if it isn't found.

setattr

The __setattr__ method is another special method in Python that is called every time an attribute is set on an object. Unlike __getattr__ or __getattribute__, which deal with retrieving attributes, __setattr__ is triggered whenever you attempt to assign a value to an attribute, whether the attribute exists or not.

Here's an example demonstrating how __setattr__ works:

class MyClass:
    def __init__(self):
        # Setting an instance attribute in the normal way
        # Since __setattr__ is used for any assignment, even in __init__ it is called
        self.instance_attr = 42

    def __setattr__(self, name, value):
        # This will be called for every attribute assignment
        print(f"__setattr__ called for setting '{name}' to {value}")
        # Using the default behavior by calling the parent class's __setattr__
        super().__setattr__(name, value)
# Creating an instance of MyClass
obj = MyClass()
__setattr__ called for setting 'instance_attr' to 42
# Setting an existing attribute
obj.instance_attr = 100  # This will trigger __setattr__
__setattr__ called for setting 'instance_attr' to 100
# Setting a new attribute
obj.new_attr = "Hello"  # This will also trigger __setattr__
__setattr__ called for setting 'new_attr' to Hello

Example with Custom Logic in setattr

You can customize the behavior of setattr to implement checks, logging, or other special behaviors when setting attributes:

class MyClass:
    def __init__(self):
        self.instance_attr = 42

    def __setattr__(self, name, value):
        if name == "instance_attr" and value < 0:
            raise ValueError(f"Cannot set '{name}' to a negative value!")
        print(f"Setting attribute '{name}' to {value}")
        super().__setattr__(name, value)
# Creating an instance of MyClass
obj = MyClass()

# Setting an attribute with a valid value
obj.instance_attr = 100  # Outputs: Setting attribute 'instance_attr' to 100
Setting attribute 'instance_attr' to 42
Setting attribute 'instance_attr' to 100
# Attempting to set a negative value (triggers a custom check)
try:
    obj.instance_attr = -1  # This will raise a ValueError
except ValueError as e:
    print(e)
Cannot set 'instance_attr' to a negative value!

Conclusion:

  • __setattr__ is triggered for every attribute assignment.
  • You can customize __setattr__ to handle specific logic, such as validation or logging, when setting attributes.
  • Always use super().__setattr__ to perform the actual assignment inside the method to avoid infinite recursion.

Exercise: Implement the Record Class

Your task is to implement a class Record that allows access to its internal data through both attribute-style access (using . notation) and dictionary-style access (using [] notation).

Requirements:

  1. Attribute Access:

    • You should be able to access data using attribute-style syntax, e.g., obj.key.
    • You should be able to assign values using the same attribute-style syntax, e.g., obj.key = value.
  2. Dictionary Access:

    • You should be able to access data using dictionary-style syntax, e.g., obj['key'].
    • You should be able to assign values using dictionary-style syntax, e.g., obj['key'] = value.
  3. Data Storage:

    • The class should store all data internally in a way that supports both attribute and dictionary-style access seamlessly.
  4. Ensure:

    • Both attribute-style and dictionary-style access refer to the same underlying data.
    • Attribute access for internal mechanisms (like the storage of data itself) should not cause recursive behavior.

Example Usage:

r = Record()

# Dictionary-style access
r['name'] = "Alice"
print(r['name'])  # Outputs: Alice

# Attribute-style access
r.age = 30
print(r.age)  # Outputs: 30

# Both access styles should refer to the same underlying data
print(r['age'])  # Outputs: 30
r['city'] = "New York"
print(r.city)  # Outputs: New York

Solution

### Solution 1a (With Custom _data Dictionary):

class Record:
    def __init__(self):
        self._data = {}

    def __getattr__(self, attr):
        return self._data[attr]

    def __setattr__(self, attr, value):
        if attr == '_data':
            super().__setattr__(attr, value)
        else:
            self._data[attr] = value

    def __getitem__(self, item):
        return self._data[item]

    def __setitem__(self, item, value):
        self._data[item] = value



r = Record()

# Dictionary-style access
r['name'] = "Alice"
print(r['name'])  # Outputs: Alice

# Attribute-style access
r.age = 30
print(r.age)  # Outputs: 30

# Both access styles should refer to the same underlying data
print(r['age'])  # Outputs: 30
r['city'] = "New York"
print(r.city)  # Outputs: New York
Alice
30
30
New York
# Solution 1b (With Cleaner Initialization):
class Record:
    def __init__(self):
        super().__setattr__('_data', {})  # Directly setting _data during initialization

    def __getattr__(self, attr):
        return self._data[attr]

    def __setattr__(self, attr, value):
        self._data[attr] = value

    def __getitem__(self, item):
        return self._data[item]

    def __setitem__(self, item, value):
        self._data[item] = value


r = Record()

# Dictionary-style access
r['name'] = "Alice"
print(r['name'])  # Outputs: Alice

# Attribute-style access
r.age = 30
print(r.age)  # Outputs: 30

# Both access styles should refer to the same underlying data
print(r['age'])  # Outputs: 30
r['city'] = "New York"
print(r.city)  # Outputs: New York
Alice
30
30
New York
# Solution 2 (Preferred - Inheriting from dict):
class Record(dict):
    def __getattr__(self, attr):
        return self[attr]

    def __setattr__(self, attr, value):
        self[attr] = value


r = Record()

# Dictionary-style access
r['name'] = "Alice"
print(r['name'])  # Outputs: Alice

# Attribute-style access
r.age = 30
print(r.age)  # Outputs: 30

# Both access styles should refer to the same underlying data
print(r['age'])  # Outputs: 30
r['city'] = "New York"
print(r.city)  # Outputs: New York
Alice
30
30
New York
### Solution 3  (Pseudo dict):

class Record:
    def __getitem__(self, item):
        return self.__dict__[item]

    def __setitem__(self, item, value):
        self.__dict__[item] = value

r = Record()

# Dictionary-style access
r['name'] = "Alice"
print(r['name'])  # Outputs: Alice

# Attribute-style access
r.age = 30
print(r.age)  # Outputs: 30

# Both access styles should refer to the same underlying data
print(r['age'])  # Outputs: 30
r['city'] = "New York"
print(r.city)  # Outputs: New York
Alice
30
30
New York

getattribute

The __getattribute__ method in Python is similar to __getattr__ in that it deals with attribute access, but it has an important difference:

  • __getattribute__ is called immediately whenever any attribute is accessed, regardless of whether it exists or not. It is the first method that Python checks when an attribute is accessed.
  • __getattr__, on the other hand, is only called after Python fails to find the attribute through normal means (i.e., it isn't found in the instance or class namespace).

Here's an example using __getattribute__:

class Foo:
    class_attr = "This is a class attribute"

    def __init__(self):
        self.instance_attr = 42

    def __getattribute__(self, name):
        # This will be called for every attribute access, including existing ones
        print(f"__getattribute__ called for: {name}")
        return super().__getattribute__(name)  # Default behavior to avoid infinite recursion
# Creating an instance of Foo
f = Foo()
# Accessing instance attribute
print(f.instance_attr)  # This will trigger __getattribute__ before returning 42
__getattribute__ called for: instance_attr
42
print(f.class_attr)
__getattribute__ called for: class_attr
This is a class attribute
print(f.non_existent_attr)  # This triggers __getattribute__ and then raises AttributeError
__getattribute__ called for: non_existent_attr
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[86], line 1
----> 1 print(f.non_existent_attr)  # This triggers __getattribute__ and then raises AttributeError

Cell In[81], line 9, in Foo.__getattribute__(self, name)
      7 def __getattribute__(self, name):
      8     print(f"__getattribute__ called for: {name}")
----> 9     return super().__getattribute__(name)

AttributeError: 'Foo' object has no attribute 'non_existent_attr'

Key points:

  • __getattribute__ is always called first, even if the attribute exists in the instance or class. You can think of it as the "first-glance" method for attribute access.
  • If you override __getattribute__, you need to be cautious and make sure to call the base class’s __getattribute__ method (via super()) to avoid infinite recursion.
  • It gives you fine-grained control over attribute access but can be tricky to use if not handled properly.

In contrast, __getattr__ is only called as a fallback when an attribute cannot be found.

Callable Classes

In Python, callable classes are classes that behave like functions, meaning you can "call" an instance of the class as if it were a function. This is done by defining the __call__ method in the class.

__call__ Method:

  • The __call__ method allows an instance of a class to be called like a regular function.
  • When you write obj(), Python internally calls obj.__call__().
class MyCallableClass:
    def __init__(self, *args, **kwargs):
        print(f"Init: Called with arguments: {args} and keyword arguments: {kwargs}")

    def __call__(self, *args, **kwargs):
        print(f"Call: Called with arguments: {args} and keyword arguments: {kwargs}")
# Creating an instance
obj = MyCallableClass('a', 'b', x=4)
Init: Called with arguments: ('a', 'b') and keyword arguments: {'x': 4}
# Calling the instance as if it were a function
obj(1, 2, 3, name="Alice")
Call: Called with arguments: (1, 2, 3) and keyword arguments: {'name': 'Alice'}

Decorator Definition

import functools

def shouter(func):
    print('wywolanie dekorowania funkcji')
    @functools.wraps(func)  # takes care of func.__doc__ and func.__name__
    def wrapper(*args, **kwargs):
        print('Before foo')
        result = func(*args, **kwargs)
        print('After foo')
        return result
    return wrapper

class Shouter:
    def __init__(self, func):
        print('Shouter.__init__')
        self.func = func

    def __call__(self, *args, **kwargs):
@shouter # => shouter(foo) => wrapper
def foo(a):
    '''Docstring'''
    print("Message:", a)
    return 42

@Shouter # => Shouter(foo) => self = Shouter(foo)
def foo(a):
    '''Docstring'''
    print("Message:", a)
    return 42
wywolanie dekorowania funkcji
Shouter.__init__
result = foo(a="Inside")  # delegates to wrapper()
Before foo
Message: Inside
After foo
### Supress exc eptionw ith use classic function decorator
def suppress_exceptions(func):
    @functools.wraps(func)  # takes care of func.__doc__ and func.__name__
    # wrapper definition starts
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception:
            return None
    # wrapper definiton ends
    return wrapper

Exercise: Implement SuppressExceptions Class

Your task is to implement a class SuppressExceptions that can be used as a decorator to wrap any function or method. The purpose of this decorator is to suppress all exceptions that may occur during the execution of the wrapped function and return None in case an exception occurs. If no exception is raised, the function should behave normally and return its result.

Requirements:

  1. Class Structure:

    • Implement a class SuppressExceptions with an __init__ and __call__ method.
    • The __init__ method should accept the function to be decorated.
    • The __call__ method should execute the function with the provided arguments, suppress any exceptions, and return None if an exception is raised.
  2. Functionality:

    • The class should allow the decorated function to be called with any number of positional (*args) and keyword arguments (**kwargs).
    • If an exception occurs while calling the decorated function, it should catch the exception and return None without raising the exception.
  3. Exception Handling:

    • Suppress all types of exceptions, not just specific ones.
  4. Expected Behavior:

    • When applied as a decorator to a function, it should print the args and kwargs passed to the function, execute the function, and handle exceptions accordingly.

Example Usage:

@SuppressExceptions
def foo(a, b):
    return a / b

# Testing the decorated function
print(foo(4, 2))  # Outputs: 2.0
print(foo(4, 0))  # Outputs: None (due to ZeroDivisionError)
print(foo(4, b=0))  # Outputs: None (due to ZeroDivisionError)

Hints:

  • Use a try-except block inside the __call__ method to catch any exceptions and return None.
  • Ensure the function arguments (*args and **kwargs) are passed correctly to the decorated function.
### Supress exc eptionw ith use classic function decorator
def suppress_exceptions(func):
    @functools.wraps(func)  # takes care of func.__doc__ and func.__name__
    # wrapper definition starts
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception:
            return None
    # wrapper definiton ends
    return wrapper

class SuppressExceptions:
    def __init__(self, func):
        functools.wraps(func)(self)
        self.func = func

    def __call__(self, *args, **kwargs):
        try:
            # print('args =', args)
            # print('kwargs =', kwargs)
            return self.func(*args, **kwargs)
        except Exception:
            return None
@SuppressExceptions # foo = SupressException(foo)
def foo(a, b):
    '''
    doc string
    '''
    return a / b

# Testing the decorated function
print(foo(4, 2))  # Outputs: 2.0
print(foo(4, 0))  # Outputs: None (due to ZeroDivisionError)
print(foo(4, b=0))  # Outputs: None (due to ZeroDivisionError)

print(foo.__doc__)
2.0
None
None

    doc string
    

Exercise: Implement SuppressExceptionsEx Class

Your task is to implement a class-based parameterized decorator named SuppressExceptionsEx that allows you to:

  1. Specify the type of exception you want to catch (defaults to catching all exceptions).
  2. Specify the return value in case the specified exception occurs (defaults to None if not specified).

The decorator should be able to wrap any function, suppress the specified exception, and return the specified value when that exception is raised.

Requirements:

  1. Class Structure:

    • Implement a class SuppressExceptionsEx that accepts the following parameters:
      • exception: The type of exception to catch (e.g., ZeroDivisionError). If not provided, it should catch all exceptions.
      • value_on_exception: The value to return if the specified exception occurs (defaults to None if not provided).
    • The __init__ method should store these parameters.
    • The __call__ method should decorate a function and handle exceptions based on the provided parameters.
  2. Decorator Functionality:

    • When applying the decorator, if the specified exception occurs, it should return the value_on_exception.
    • If another type of exception occurs, it should propagate that exception as usual.
    • The function should accept and work with any number of positional (*args) and keyword arguments (**kwargs).

Example Usage:

@SuppressExceptionsEx(exception=ZeroDivisionError, value_on_exception=0.0)
def egg(a, b):
    return a / b

# Test cases
print(egg(4, 2))  # Outputs: 2.0
print(egg(4, 0))  # Outputs: 0.0 (ZeroDivisionError is caught)
print(egg("a", "b"))  # Raises TypeError (since we're not catching TypeError)

Hints:

  • Use a try-except block inside the __call__ method to handle the specified exception and return the desired value.
  • Ensure that the decorator works with multiple types of exceptions if no specific exception is provided.
  • Be mindful of how you pass arguments (*args, **kwargs) to the wrapped function.
class SuppressExceptionsEx:
    def __init__(self, exception=Exception, value_on_exception=None):
        self.exception = exception
        self.value_on_exception = value_on_exception

    def __call__(self, func):
        def wrapper(*args, **kwargs):
            try:
                # print('args =', args)
                # print('kwargs =', kwargs)
                return func(*args, **kwargs)
            except self.exception:
                return self.value_on_exception
        return wrapper
@SuppressExceptionsEx(exception=ZeroDivisionError, value_on_exception=0.0) # => SuppressExceptionsEx(exception=ZeroDivisionError, value_on_exception=0.0)(egg)
def egg(a, b):
    return a / b

print(egg(4, 2))  # Outputs: 2.0
print(egg(4, 0))  # Outputs: 0.0 (ZeroDivisionError is caught)
2.0
0.0
@SuppressExceptionsEx()
def egg(a, b):
    return a / b

print(egg(4, 2))  # Outputs: 2.0
print(egg(4, 0))  # Outputs: None (ZeroDivisionError is caught)
print(egg("a", "b"))  # Outputs: None (TypeError is caught)
2.0
None
None

@property

The @property decorator in Python provides a way to manage attributes with getter and setter methods in a clean, readable way, allowing you to treat method calls like attribute access. It helps to encapsulate logic for getting and setting attributes while keeping a user-friendly interface.

Example: Vector Class

Consider a Vector class representing a vector in a 2D space. Initially, we store the x and y components as well as the length of the vector. However, this leads to a broken Single Source of Truth issue — modifying the vector's coordinates (x or y) doesn’t update the length automatically.

from math import sqrt

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.length = sqrt(self.x**2 + self.y**2)  # Problem: length doesn't update when x or y changes
v = Vector(3, 4)
print(v.x, v.y, v.length)
3 4 5.0
# example of monkey patching
v.length = 10
print(v.x, v.y, v.length)
3 4 10

In this example, v.length = 10 sets a new length, but x and y don't adjust accordingly. This violates the Single Source of Truth, where the length should always be derived from the x and y values.

Fixing with Getter and Setter Methods

A better approach is to calculate the length dynamically based on the current x and y values, and update x and y when the length is modified. Here's a version using explicit getter and setter methods:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def get_length(self):
        return sqrt(self.x**2 + self.y**2)

    def set_length(self, new_length):
        factor = new_length / self.get_length()
        self.x *= factor
        self.y *= factor
v = Vector(3, 4)
print(v.x, v.y, v.get_length())
3 4 5.0
v.set_length(10)
print(v.x, v.y, v.get_length())
6.0 8.0 10.0

Using @property for a Cleaner Interface

We can make the class interface cleaner by using the @property decorator. It allows us to treat the length as a regular attribute while still having control over how it is calculated and updated. With @property, you can access length as if it were a simple attribute, but behind the scenes, it is computed dynamically. The setter allows you to update x and y when the length is changed.

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    @property
    def length(self):
        return sqrt(self.x**2 + self.y**2)

    @length.setter
    def length(self, new_length):
        factor = new_length / self.length
        self.x *= factor
        self.y *= factor
v = Vector(3, 4)
print(v.x, v.y, v.length)
3 4 5.0
v.length = 10
print(v.x, v.y, v.length)
6.0 8.0 10.0

Benefits of @property:

  • Encapsulation: Encapsulates the logic of getting and setting attributes, keeping the interface clean.
  • Single Source of Truth: Ensures the vector's length is always consistent with x and y.
  • Intuitive Usage: Users of the class can interact with the object as if length were a simple attribute.

Inheriting from Immutable Types

In Python, immutable types like tuple, int, str, etc., cannot be modified once created. However, it’s possible to inherit from these types and extend their functionality. This chapter explores how to create custom classes that inherit from immutable types and the considerations for memory efficiency and initialization.

Memory-Consuming Point

When defining a class in Python, each instance has a __dict__ attribute that stores all the instance’s attributes. This can consume a significant amount of memory, especially when creating many objects with only a few attributes.

Consider the following Point class:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(2, 3)
print(p.__dict__)
print(p.__dict__.__sizeof__())
print(p.__sizeof__())
{'x': 2, 'y': 3}
280
16

Reducing Memory Overhead with __slots__

To save memory, you can define __slots__ in the class. This tells Python to allocate space for a fixed set of attributes, avoiding the overhead of a __dict__ for each instance.

class Point:
    __slots__ = ['x', 'y']  # No __dict__ will be created, saving memory

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(2, 3)
# print(p.__dict__)
# print(p.__dict__.__sizeof__())
print(p.__sizeof__())
32

Inheriting from tuple

Inheriting from immutable types like tuple allows you to extend the behavior of these types, but it comes with limitations. Since tuple is immutable, you cannot modify its elements after creation.

Consider this attempt to create a Point class by inheriting from tuple:

class Point(tuple):
    def __init__(self, x, y):
        self[0] = x  # Attempting to modify a tuple element
        self[1] = y

p = Point(2, 3)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[137], line 6
      3         self[0] = x  # Attempting to modify a tuple element
      4         self[1] = y
----> 6 p = Point(2, 3)

TypeError: tuple expected at most 1 argument, got 2

This will raise a TypeError because tuples are immutable and cannot be modified

Creating Immutable Objects with __new__

When inheriting from immutable types, you need to override the __new__ method rather than __init__. The __new__ method is responsible for creating new instances of immutable types before they are initialized.

class Point(tuple):

    def __new__(cls, x, y):
        return super().__new__(cls, (x,y))

    @property
    def x(self):
        return self[0]

    @property
    def y(self):
        return self[1]
p = Point(2, 3)

print(type(p))
p[0]
p.x

print(p.__sizeof__())
<class '__main__.Point'>
40

Inheriting from int

Inheriting from other immutable types, like int, follows a similar approach. You can use the __new__ method to create new instances and add extra attributes. Let’s look at an example where we create an integer class with additional metadata

class IntegerWithMeta(int):
    def __new__(cls, value, meta):
        self = super().__new__(cls, value)  # Create an integer object
        self.meta = meta  # Attach metadata to the object
        return self
two = IntegerWithMeta(2, 'kg')
print(two)
print(type(two))
print(two.__dict__)
2
<class '__main__.IntegerWithMeta'>
{'meta': 'kg'}
three = IntegerWithMeta(3, 'g')
print(two < three)
True

In this example:

  • IntegerWithMeta inherits from int.
  • The __new__ method creates the integer object and attaches additional metadata (e.g., 'kg' for kilograms) to it.
  • You can still use this class like a regular integer, but with the added ability to store and access extra data (meta).

Conclusion

Inheriting from immutable types in Python requires careful use of the __new__ method to create objects. You cannot modify these objects after creation, but you can extend their functionality and add additional attributes like metadata. By using immutability effectively, you can ensure that certain objects remain unchanged throughout their lifetime, while still making them more useful and feature-rich.

Exercise: Implement a Point3D

Your task is to create a class Point3D that represents a point in 3D space. The point should be immutable, and you should define x, y, and z as properties to access the coordinates. Additionally, implement a method distance_from_origin that returns the Euclidean distance of the point from the origin (0, 0, 0).

Requirements:

  1. Inherit from tuple to make the point immutable.
  2. Create x, y, and z as properties to access the coordinates.
  3. Implement a method distance_from_origin that computes the Euclidean distance from the origin.
  4. Ensure that the class is immutable and inherits from tuple.

Example Usage:

p = Point3D(3, 4, 5)

# Access the x, y, z coordinates
print(p.x)  # Outputs: 3
print(p.y)  # Outputs: 4
print(p.z)  # Outputs: 5

# Calculate the distance from the origin
print(p.distance_from_origin())  # Outputs: 7.071 (approximately)

# Check immutability (should raise a TypeError if modification is attempted)
try:
    p.x = 10  # This should raise an error because the object is immutable
except AttributeError as e:
    print(e)  # Outputs: "can't set attribute"

# Check inheritance
print(isinstance(p, tuple))  # Outputs: True (Point3D should inherit from tuple)

Hint:

  • Use sqrt(x**2 + y**2 + z**2) to calculate the Euclidean distance in 3D.
  • Try accessing p.x, p.y, and p.z as properties and test if modifying these values raises an error to confirm immutability.

Solution

from math import sqrt

class Point3D(tuple):
    def __new__(cls, x, y, z):
        # We call the parent class tuple constructor with the values x, y, z as a tuple
        return super().__new__(cls, (x, y, z))

    @property
    def x(self):
        return self[0]

    @property
    def y(self):
        return self[1]

    @property
    def z(self):
        return self[2]

    def distance_from_origin(self):
        return sqrt(self.x**2 + self.y**2 + self.z**2)
# Testing the implementation
p = Point3D(3, 4, 5)

# Access the x, y, z coordinates
print(p.x)  # Outputs: 3
print(p.y)  # Outputs: 4
print(p.z)  # Outputs: 5

# Calculate the distance from the origin
print(p.distance_from_origin())  # Outputs: 7.0710678118654755

# Check immutability (should raise a TypeError if modification is attempted)
try:
    p.x = 10  # This should raise an error because the object is immutable
except AttributeError as e:
    print(e)  # Outputs: "can't set attribute"

# Check inheritance
print(isinstance(p, tuple))  # Outputs: True (Point3D should inherit from tuple)
3
4
5
7.0710678118654755
property 'x' of 'Point3D' object has no setter
True

Descriptors

In Python, descriptors provide a powerful way to manage attribute access and can help solve certain problems related to attribute management that standard properties (@property) might not address fully. Descriptors allow you to define custom behavior for getting, setting, and deleting attributes, making them more flexible than properties.

Point with @property

Let's start with a common scenario using @property. We want to create a Point class that acts as a proxy for a JSON object. The JSON represents coordinates u and v, but we want the user to access them via the more intuitive x and y properties. We also want to ensure that:

  • The x and y values are always returned as floats.
  • Any update to x or y should modify the underlying JSON.
  • Invalid data types (like strings) should raise an error, while integers should be converted to floats.
class Point:
    def __init__(self, json: dict):
        self.json = json

    @property
    def x(self):
        return float(self.json['u'])

    @property
    def y(self):
        return float(self.json['v'])

    @x.setter
    def x(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Must be int or float')
        self.json['u'] = float(value)

    @y.setter
    def y(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Must be int or float')
        self.json['v'] = float(value)

Expected Behavior:

p = Point({'u': 42, 'v': 18.0})
assert p.x == 42.0
p.y = 15
assert p.y == 15.0

assert type(p.x) is float  # Even though 'u' was an int, x is always a float
assert p.json == {'u': 42, 'v': 15.0}
assert type(p.json['v']) is float
assert 'x' not in p.__dict__
assert 'u' not in p.__dict__

Dict Key Order

Before introducing descriptors, it's essential to understand how dictionaries behave in different versions of Python, as they play a role when handling JSON-like data:

  • Python 3.5 and earlier: Dictionaries do not remember the order of keys.
  • Python 3.6: Dictionaries remember the order of keys, but it was not guaranteed in the language specification.
  • Python 3.7 and later: Key order is officially part of the language specification, meaning dictionaries maintain the insertion order.

Introduction

A descriptor is a Python object that implements one or more of the following special methods:

  • __get__(self, instance, owner): Handles attribute access.
  • __set__(self, instance, value): Handles setting attribute values.
  • __delete__(self, instance): Handles deleting attributes.

Descriptors allow you to manage attribute access in a class in a fine-grained way. Here is an example of a descriptor:

class Descriptor:
    def __get__(self, instance, owner):
        print('self =', self)
        print('instance =', instance)
        print('owner =', owner)
        return 42

    def __set__(self, instance, value):
        print('self =', self)
        print('instance =', instance)
        print('value =', value)

    def __delete__(self, instance):
        print('self =', self)
        print('instance =', instance)
class Owner:
    my_int = 84
    attribute = Descriptor()
owner = Owner()
print(owner.my_int)
84

Access through Instance

print(owner.attribute)  # Access via Descriptor (calls Descriptor.__get__)
self = <__main__.Descriptor object at 0x10af084d0>
instance = <__main__.Owner object at 0x10af0bd10>
owner = <class '__main__.Owner'>
42

When you access owner.attribute, it calls the __get__ method of the Descriptor class, and prints the instance and class before returning 42.

Let's assign a value to owner.attribute:

owner.attribute = 100  # Calls Descriptor.__set__
self = <__main__.Descriptor object at 0x10af084d0>
instance = <__main__.Owner object at 0x10af0bd10>
value = 100

Finally, deleting the attribute:

del owner.attribute  # Calls Descriptor.__delete__
self = <__main__.Descriptor object at 0x10af084d0>
instance = <__main__.Owner object at 0x10af0bd10>

Behavior in Initializers

What happens when you try to override the descriptor's value in the initializer?

class Owner:
    attribute = Descriptor()

    def __init__(self, k):
        self.attribute = k
        self.casual = k
owner_2 = Owner(10)
print(owner_2.__dict__)
self = <__main__.Descriptor object at 0x10af0be60>
instance = <__main__.Owner object at 0x10af0ad50>
value = 10
{'casual': 10}

The descriptor behavior is bypassed in __init__, but the dictionary shows only casual, not attribute.

Accessing attribute again still invokes the descriptor's __get__ method:The descriptor behavior is bypassed in __init__, but the dictionary shows only casual, not attribute.

Accessing attribute again still invokes the descriptor's __get__ method:

print(owner_2.attribute)
self = <__main__.Descriptor object at 0x10af0be60>
instance = <__main__.Owner object at 0x10af0ad50>
owner = <class '__main__.Owner'>
42

Access through Class

Accessing the descriptor through the class (rather than an instance) behaves slightly differently:

print(Owner.attribute)
self = <__main__.Descriptor object at 0x10af0be60>
instance = None
owner = <class '__main__.Owner'>
42

Here, instance is None because we are accessing the attribute directly from the class, not an instance.

You can override the descriptor on the class level:

Owner.attribute = 44  # Bypasses the descriptor

print(Owner.attribute)
44
del Owner.attribute  # Deletes the descriptor
print(Owner.attribute)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[176], line 1
----> 1 del Owner.attribute  # Deletes the descriptor
      2 print(Owner.attribute)

AttributeError: type object 'Owner' has no attribute 'attribute'

Example Use Case

Let's look at a more practical example: a power class that can represent power in different units—decibels-milliwatts (dBm), milliwatts (mW), and watts (W). Descriptors are used to convert between these units automatically.

import math

class Unit_dBm:
    '''
    decibel-milliwatts
    '''
    def __get__(self, instance, owner):
        return instance._dBm

    def __set__(self, instance, value):
        instance._dBm = float(value)

class Unit_mW:
    def __get__(self, instance, owner):
        return int(10 ** (instance._dBm / 10))

    def __set__(self, instance, value):
        instance._dBm = 10 * math.log10(float(value))

class Unit_W:
    def __get__(self, instance, owner):
        return (10 ** (instance._dBm / 10)) / 1000

    def __set__(self, instance, value):
        instance._dBm = 10 * math.log10(float(value) * 1000)

class Power:
    dBm = Unit_dBm()
    mw = Unit_mW()
    w = Unit_W()

    _dBm: float

    def __init__(self, dBm=None, mw=None, w=None):
        if dBm is not None:
            self.dBm = dBm
        elif mw is not None:
            self.mw = mw
        elif w is not None:
            self.w = w
        else:
            raise TypeError("Must provide a value for one of the units")

        print('self._dBm =', self._dBm)
power = Power(w=0.2)
self._dBm = 23.010299956639813
print(power.mw)
200
power.mw = 10
print(power.dBm)
print(power.mw)
print(power.w)
10.0
10
0.01
print(power.__dict__)
{'_dBm': 10.0}

Exercise: Implementing a Field Descriptor for Data Validation

Problem Description:

You are tasked with creating a system for managing attributes in a class that uses data from a JSON structure. Instead of using @property, which results in repetitive code for validation and type conversion, you will use descriptors to generalize this behavior and eliminate code duplication.

The goal is to implement a generic Field class that manages data validation and type conversion for different types of fields. The Field class should be reusable for different types like float, string, or any custom type. Your task is to:

  1. Create a base class Field, which will be responsible for handling:

    • Getting values from the underlying JSON.
    • Validating and setting values in the JSON.
    • Performing type conversion.
  2. Implement a FloatField class that inherits from Field, specifically for handling floating-point values.

  3. Extend the functionality with additional types of fields, such as StringField for strings and TypeField for general type conversion.

  4. Finally, implement a Model class that will serve as a base class for other models (like Point). The model will store a reference to a JSON structure, and its attributes will be handled by the Field descriptors.

Requirements:

  1. FloatField Class:

    • Inherits from Field.
    • Converts the values in the JSON to float.
  2. StringField Class:

    • Inherits from Field.
    • Converts the values in the JSON to str.
  3. TypeField Class:

    • A more flexible version of Field that can handle any type conversion based on a provided type (like int, float, str, etc.).
  4. Field Class:

    • Field should take a key (representing a JSON key) in the constructor and use this key to access the underlying JSON.
    • It should handle type conversion through a convert method.
    • It should raise NotImplementedError if the convert method is not implemented in a subclass.
  5. Model Class:

    • The Model class should take a JSON dictionary in the constructor, which will serve as the underlying data store.
    • It will use descriptors (Field, FloatField, etc.) to manage access to its attributes.

Expected Behavior:

class Point(Model):
    x = FloatField('u')
    y = TypeField('v', float)
    name = StringField('n')

p = Point({'u': 42, 'v': 18.0, 'n': "A"})

# Test getting values
assert p.x == 42.0
assert p.y == 18.0
assert p.name == "A"

# Test setting values
p.y = 15
assert p.y == 15.0
p.name = "qwer"
assert p.json == {'u': 42, 'v': 15.0, 'n': 'qwer'}

# Test type conversions
assert type(p.x) is float
assert type(p.y) is float

# Test setting values with type conversion
p.name = 123
assert p.json == {'u': 42, 'v': 15.0, 'n': '123'}

Hints:

  • Use the __get__ and __set__ methods to manage access and assignment of values.
  • Implement the convert method in each subclass to handle type conversion.
  • Raise an error in Field if the conversion method is not implemented.

Solution:

class Model:
    def __init__(self, json):
        self.json = json


class Field:
    def __init__(self, key):
        self.key = key

    def __get__(self, instance, owner):
        return self.convert(instance.json[self.key])

    def __set__(self, instance, value):
        instance.json[self.key] = self.convert(value)

    def convert(self, value):
        raise NotImplementedError("The 'convert' method should be implemented in subclasses.")


class FloatField(Field):
    def convert(self, value):
        return float(value)

class StringField(Field):
    def convert(self, value):
        return str(value)

class TypeField(Field):
    def __init__(self, key, type_):
        super().__init__(key)
        # self.type_ = type_
        self.convert = type_

    # def convert(self, value):
    #     return self.type_(value)

# Define the Point class inheriting from Model
class Point(Model):
    x = FloatField('u')
    y = TypeField('v', float)
    name = StringField('n')
# Example Usage
p = Point({'u': 42, 'v': 18.0, 'n': "A"})

# Access attributes
assert p.x == 42.0
assert p.y == 18.0
assert p.name == "A"

# Modify attributes
p.y = 15
assert p.json == {'u': 42, 'v': 15.0, 'n': 'A'}

p.name = "qwer"
assert p.json == {'u': 42, 'v': 15.0, 'n': 'qwer'}

# Test type conversion
p.name = 123
assert p.json == {'u': 42, 'v': 15.0, 'n': '123'}

Explanation of the Solution:

  1. Field Class:

    • This class is the base descriptor responsible for handling getting and setting values in the JSON.
    • It requires each subclass to implement a convert method to handle type conversion. This method ensures the value in the JSON is always stored as the correct type.
  2. FloatField Class:

    • This subclass converts the value to a float. When the attribute is accessed, the value is fetched from the JSON and converted to float. When the value is set, it converts it to float before storing it.
  3. StringField Class:

    • This subclass converts the value to a string using the same pattern as FloatField.
  4. TypeField Class:

    • This more generic subclass allows any type conversion to be passed as a parameter. You can specify the desired type (e.g., float, int, etc.) and it will convert the value accordingly.
  5. Model Class:

    • The Model class provides the base structure that interacts with the JSON data. The descriptors (fields) manage individual attributes, handling both data validation and conversion.
  6. Point Class:

    • This is the final class that inherits from Model. It defines specific attributes (x, y, and name) using the descriptors (FloatField, TypeField, and StringField).

Exercise: Implementing Your Own @property Descriptor

In this exercise, you will implement your own custom descriptor called Property, which mimics the behavior of the built-in @property decorator in Python. This custom descriptor should handle both getter and setter methods for attributes, allowing for read-only

Requirements:

  1. Property class:
    • The Property class should be able to accept getter function via its constructor.
    • If only the getter is defined, the property should act as a read-only property.
    • The __get__ method should return the result of the getter function.

Expected Behavior:

You will define a class MyClass that uses the Property descriptor to handle a read-only attribute (readonly_value).

Example Code for Testing:

class MyClass:
    def __init__(self, readonly_value):
        self._readonly_value = readonly_value

    @Property
    def readonly_value(self):
        return self._readonly_value


# Test cases
d = MyClass("readonly value")

# Test accessing read-only property
assert d.readonly_value == 'readonly value'


# Test setting read-only property (should raise an AttributeError)
try:
    d.readonly_value = 3
except AttributeError as e:
    print(e)  # Outputs: Cannot set read-only property.

# Test accessing the property directly from the class
print(MyClass.readonly_value)

Solution:

class Property:
    def __init__(self, fget=None):
        self.fget = fget

    def __get__(self, instance, owner):
        if instance is None:
            return self
        if self.fget is None:
            raise AttributeError("Unreadable property")
        return self.fget(instance)

class MyClass:
    def __init__(self, readonly_value):
        self._readonly_value = readonly_value

    @Property
    def readonly_value(self):
        return self._readonly_value
# Test cases
d = MyClass("readonly value")

# Test accessing read-only property
assert d.readonly_value == 'readonly value'


# Test setting read-only property (should raise an AttributeError)
try:
    d.readonly_value = 3
except AttributeError as e:
    print(e)  # Outputs: Cannot set read-only property.

# Test accessing the property directly from the class
print(MyClass.readonly_value)
<__main__.Property object at 0x10b64dc10>

Bounded Methods in Python

class Foo:
    def __init__(self, f):
        self.f = f

    def method(self, arg):
        print(f'method({arg})')

def function(arg):
    print(f'function({arg})')

foo = Foo(function)

Bound Methods vs. Functions

Let's investigate the behavior when we call these methods and functions:

foo.method(42)
method(42)
Foo.method(foo, 42)
method(42)
  • foo.method(42) automatically passes foo as the first argument (self) to the method.
  • Foo.method(foo, 42) explicitly passes foo as the first argument.

Accessing the Function Stored in an Instance

You can also call the function stored in the instance directly:

foo.f(42) # => function(42)
function(42)

However, trying to access f from the class itself will result in an error:

Foo.f(42)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[203], line 1
----> 1 Foo.f(42)

AttributeError: type object 'Foo' has no attribute 'f'

This is because f is an instance attribute and not part of the class itself.

Bound Method vs Unbound Method

When you assign a method to a variable, it becomes a bound method, meaning it carries with it the instance it was bound to:

m = foo.method
m(42)
method(42)

If you check foo.method, it is a bound method, as it is tied to the instance foo:

print(foo.method) # Foo.method.__get__(foo, Foo)
print(Foo.method)
<bound method Foo.method of <__main__.Foo object at 0x10adea2d0>>
<function Foo.method at 0x10ba86700>
  • foo.method is a bound method, meaning it’s tied to the instance foo.
  • Foo.method is a function (unbound method), meaning it can be called, but it expects an instance as its first argument.

Checking Bound Instance

You can also check the instance to which a bound method is attached using the __self__ attribute:

m = foo.method
print(m.__self__)
<__main__.Foo object at 0x10adea2d0>

Bound Methods Behind the Scenes

When you call foo.method, it is equivalent to calling Foo.method.__get__(foo, Foo). This is how Python implements method binding:

print(foo.method)
print(Foo.method.__get__(foo, Foo))
<bound method Foo.method of <__main__.Foo object at 0x1044a6150>>
<bound method Foo.method of <__main__.Foo object at 0x1044a6150>>

This shows that Python binds methods using the __get__ method of the descriptor protocol.

This mechanics allows a developer to inject a custom code between an call to a method of an class's instance and an actuall call to bound function at the class definition.

class BoundedMethod:

    def __init__(self, method):
        self.method = method

    def __get__(self, instance, class_definition):
        return self.method(instance)


class Foo:
    def method(self, arg):
        print(f'method({arg})')


foo = Foo() # create instance of the Foo class


# python creates empty foo without  method
# foo.method = BoundedMethod(Foo.method) => foo.method => Foo.method.__get__(foo, Foo)
print(Foo.method)
print(foo.method)
<function Foo.method at 0x10bba44a0>
<bound method Foo.method of <__main__.Foo object at 0x1044a6150>>

Summary of Key Concepts

  • Bound Method: A function that is bound to an instance. It automatically passes the instance as the first argument.
  • Unbound Method: A function that is not bound to an instance. It needs an instance to be explicitly passed.
  • __get__ method: Python binds methods using the __get__ method of descriptors, which ties a function to an instance of a class.

Conclusion

Bound methods allow object-oriented programming in Python to work seamlessly, ensuring that methods are always called with the correct instance. This automatic binding is a core part of how Python handles object methods, making method calls simpler and more intuitive.

Multiple Inheritance in Python

class Base1:
    def base_method(self):
        print('base_method')

class Base2:
    def another_method(self):
        print('another_method')
class Child(Base2, Base1):
    def child_method(self):
        print('child_method')
# Creating an instance of Child and calling methods
child = Child()
child.base_method()
child.another_method()    # Inherited from Base2
child.child_method()      # Defined in Child
base_method
another_method
child_method

In this example:

  • Child inherits methods from both Base1 and Base2.
  • You can access methods from both base classes using an instance of Child.

However, the parent classes (Base1, Base2) do not have access to each other's methods:

b1 = Base1()
b1.base_method()          # Works fine
base_method
b1.child_method()       # Raises an AttributeError
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[219], line 1
----> 1 b1.child_method()       # Raises an AttributeError

AttributeError: 'Base1' object has no attribute 'child_method'
b1.another_method()     # Raises an AttributeError
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[220], line 1
----> 1 b1.another_method()     # Raises an AttributeError

AttributeError: 'Base1' object has no attribute 'another_method'

Method Resolution Order (MRO)

When a class inherits from multiple parents, Python needs a way to decide the order in which it looks for methods and attributes. This is called the Method Resolution Order (MRO). Python uses the C3 linearization algorithm to determine the order in which classes are searched for methods and attributes.

Let's explore this with a more complex example:

class Foo:

    def __init__(self):
        print('Foo.__init__')


class Boo(Foo):
    pass


class Zoo(Foo):
    def __init__(self):
        print('Zoo.__init__')


class Doo(Foo):
    def __init__(self):
        super().__init__()
        print('Zoo.__init__')


d = Foo()
b = Boo()
z = Zoo()
Foo.__init__
Foo.__init__
Zoo.__init__
e = Doo()
Foo.__init__
Zoo.__init__
class Person:
    def __init__(self, name):
        self.name = name


class PersonExt(Person):

    def __init__(self, name, surname):
        super().__init__(name)

        self.surname = surname
 
class External:

    def method1(self):
        pass

    def method2(self):
        pass


class Internal(External):
    pass


class Internal2:

    def __init__(self, ext: External):
        self._ext = ext

    def method1(self):
        return self._ext.method1()
# A => base Interface (no functionality)
# B => read functionality
# C => write functionality
# D => module using write and read


class A:  # Implicitly inherits from object => A(object):
    def __init__(self):
        super().__init__()
        print("A")

class B:
    def __init__(self):
        super().__init__()
        print("B")

class C:
    def __init__(self):
        super().__init__()
        print("C")

class D(B, C):
    def __init__(self):
        super().__init__()
        print("D")

In this case, D inherits from both B and C, which in turn inherit from A. When we create an instance of D, Python will follow the MRO to determine which __init__ methods to call:

print(D.__mro__)
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class 'object'>)
d = D()
C
B
D

The MRO determines that:

  1. D looks in its own class.
  2. Then it looks in B.
  3. Then in C.
  4. Then in A.
  5. Finally, it looks in object, the root of all classes in Python.

As a result, the __init__ methods are called in the following order: A, C, B, and finally D.

Custom MRO Traversal

You can manually control how the methods are called by using the __class__.__mro__ attribute, which stores the MRO of a class. Here's an example of manually traversing the MRO and calling the next class in the MRO:

class A:
    def __init__(self):
        mro = self.__class__.__mro__
        next_class = mro[mro.index(A) + 1]
        next_class.__init__(self)
        print("A")

class B(A):
    def __init__(self):
        mro = self.__class__.__mro__
        next_class = mro[mro.index(B) + 1]
        next_class.__init__(self)
        print("B")

class C(A):
    def __init__(self):
        mro = self.__class__.__mro__
        next_class = mro[mro.index(C) + 1]
        next_class.__init__(self)
        print("C")

class D(B, C):
    def __init__(self):
        mro = self.__class__.__mro__
        next_class = mro[mro.index(D) + 1]
        next_class.__init__(self)
        print("D")
d = D()
A
C
B
D

In this version, each class manually retrieves the next class in the MRO and explicitly calls its __init__ method. This approach provides more control, but it’s rarely necessary in normal Python programming since Python’s super() handles most cases.

Excercise

Implement ComparableByKeyMixin. Classes inheriting from the mixin should implement get_key() method that returns the value we should compare objects by (i.e., for vectors, it should return their length).

Hint: Six special methods are used to compare objects: __lt__, __le__, __eq__, __ne__, __gt__, and __ge__.

DatabaseModel class already implements the logic of storing an object in a database, so you can simply inherit from it.

Implement the following classes:

  • Vector: Should be comparable by length and NOT stored in a database.
  • Person: Should be comparable by id and stored in a database.
  • TodoItem: Should NOT be comparable and should be stored in a database.

Implement LogCreationMixin:

  • This mixin logs (prints to the output) when you create an instance of any class using this mixin.
  • This is very useful for debugging.
  • Use it for both the Person and TodoItem classes.

Optional tasks (★):

  • JSONSerializableMixin: Implements .to_json() method and .from_json(json) classmethod that allow you to convert an object to/from a dictionary.

    • This should work for any class, so you need to dynamically get all object attributes from self.__dict__.
    • Make Vector and Person serializable.
  • DateCreatedMixin: Automatically adds created_at attribute and sets it to the current time by default (unless an explicit created_at keyword argument is provided in __init__).

    • Reuse this logic for both Vector and TodoItem.

    Implementation:

  • First person implements ComparableByKeyMixin, second person implements LogCreationMixin, then the first person implements JSONSerializableMixin, and the second person implements DateCreatedMixin.

Initial Code

class DatabaseModel:
    # Imagine this class implements complex logic of adding
    # transparent persistence in a database. Don't modify the code,
    # however, make sure that both Person and TodoItem inherit
    # from this class.
    def save(self):
        print("Automagically saved to the database.")


class ComparableByKeyMixin:
    def __lt__(self, other):  # triggered when you compare self < other
        pass

    # Implement __le__, __eq__, __ne__, __gt__, __ge__ as well


class LogCreationMixin:
    # Implement logging functionality when a new instance is created
    ...


class Vector(...):
    def __init__(self, x, y):
        self.x = x
        self.y = y

    # Vectors should be comparable by length and NOT stored in a database
    ...

class Person(...):
    def __init__(self, id, name):
        self.id = id
        self.name = name

    # Persons should be comparable by id and stored in a database
    ...


class TodoItem(...):
    def __init__(self, id, title):
        self.id = id
        self.title = title

    # TodoItems should be NOT comparable and should be stored in a database
    ...

Expected Behaviour

v = Vector(3, 4)  # length 5
u = Vector(0, 6)  # length 6 
assert v < u  # compare by length

# v.save()  # Vectors do NOT support database persistence

p1 = Person(id=1234, name='John')
p2 = Person(id=2345, name='Alice')
assert p1 < p2  # compare by id
p1.save()  # Persons support database persistence
# Output: Created an instance of Person
# Output: Automagically saved to the database.

t1 = TodoItem(1, 'Clean dishes')
t1.save()  # TodoItems support database persistence
# Output: Created an instance of TodoItem
# Output: Automagically saved to the database.

Additional Tasks (Optional)

# Optional: DateCreatedMixin
print(v.created_at)
# print(p1.created_at)  # Persons should not have created_at field
print(t1.created_at)
# Output: 2020-11-03 12:46:51.985752

t2 = TodoItem(1, 'Clean dishes', created_at=datetime(2020, 1, 1))
print(t2.created_at)
# Output: Created an instance of TodoItem
# Output: 2020-01-01 00:00:00

# Optional: JSONSerializableMixin
print(v.to_json())
# Output: {'x': 3, 'y': 4}

w = Vector.from_json({'x': 2, 'y': 3})
print(w.__dict__)
# Output: {'created_at': datetime.datetime(2020, 11, 3, 12, 46, 51, 985752), 'x': 2, 'y': 3}
class A:
    def __init__(self):
        super().__init__()
        print('A.__init__')


class B:
    def __init__(self):
        super().__init__()
        print('B.__init__')


class C:
    def __init__(self):
        super().__init__()
        print('C.__init__')


class D(C, B, A):
    def __init__(self):
        super().__init__()
        print('D.__init__')
    # pass


d = D() # d.__init__()
A.__init__
B.__init__
C.__init__
D.__init__
D.__mro__
(__main__.D, __main__.C, __main__.B, __main__.A, object)
#### Solution

from datetime import datetime
from functools import total_ordering
from math import sqrt


class DatabaseModel:
    # Imagine this class implements complex logic of adding
    # transparent persistence in a database. Don't modify the code,
    # however make sure, that both Vector and Person inherit
    # from this class.
    def save(self):
        print("Automagically saved to the database.")


@total_ordering
class ComparableByKeyMixin:
    def __lt__(self, other: 'ComparableByKeyMixin'):  # self < other
        return self.get_key() < other.get_key()

    def __eq__(self, other: 'ComparableByKeyMixin'):  # self == other
        return self.get_key() == other.get_key()

    def get_key(self):
        raise NotImplementedError


class LogCreationMixin:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        print(f"Created an instance of {self.__class__.__name__}")

class DateCreatedMixin:
    def __init__(self, *args, created_at=None, **kwargs):
        super().__init__(*args, **kwargs)
        if created_at is None:
            created_at = datetime.now()
        self.created_at = created_at

class JSONSerializableMixin:
    def to_json(self):
        return self.__dict__.copy()

    @classmethod
    def from_json(cls, json: dict):
        return cls(**json) # __class__

class Vector(ComparableByKeyMixin, DateCreatedMixin, JSONSerializableMixin):
    def __init__(self, x, y, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.x = x
        self.y = y

    def get_key(self):
        return sqrt(self.x**2 + self.y**2)


class Person(ComparableByKeyMixin, LogCreationMixin, JSONSerializableMixin, DatabaseModel):
    def __init__(self, id, name):
        super().__init__()
        self.id = id
        self.name = name

    def get_key(self):
        return self.id


class TodoItem(LogCreationMixin, DateCreatedMixin, DatabaseModel):
    def __init__(self, id, title, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.id = id
        self.title = title
v = Vector(3, 4)  # length 5
u = Vector(0, 6)  # length 6
assert v < u  # compare by length

# v.save()  # Vectors do NOT support database persistence

p1 = Person(id=1234, name='John')
p2 = Person(id=2345, name='Alice')
assert p1 < p2  # compare by id
p1.save()  # Persons support database persistence
# Output: Created an instance of Person
# Output: Automagically saved to the database.

t1 = TodoItem(1, 'Clean dishes')
t1.save()  # TodoItems support database persistence
# Output: Created an instance of TodoItem
# Output: Automagically saved to the database.
Created an instance of Person
Created an instance of Person
Automagically saved to the database.
Created an instance of TodoItem
Automagically saved to the database.
# Optional: DateCreatedMixin
print(v.created_at)
# print(p1.created_at)  # Persons should not have created_at field
print(t1.created_at)
# Output: 2020-11-03 12:46:51.985752

t2 = TodoItem(1, 'Clean dishes', created_at=datetime(2020, 1, 1))
print(t2.created_at)
# Output: Created an instance of TodoItem
# Output: 2020-01-01 00:00:00

# Optional: JSONSerializableMixin
print(v.to_json())
# Output: {'x': 3, 'y': 4}

w = Vector.from_json({'x': 2, 'y': 3})
print(w.__dict__)
# Output: {'created_at': datetime.datetime(2020, 11, 3, 12, 46, 51, 985752), 'x': 2, 'y': 3}
2024-09-27 11:45:35.724468
2024-09-27 11:45:35.725278
Created an instance of TodoItem
2020-01-01 00:00:00
{'created_at': datetime.datetime(2024, 9, 27, 11, 45, 35, 724468), 'x': 3, 'y': 4}
{'created_at': datetime.datetime(2024, 9, 27, 11, 45, 47, 679946), 'x': 2, 'y': 3}

Metaclasses

Metaclasses allow you to control how classes behave. Just as classes control the behavior of their instances, metaclasses control the behavior of classes themselves. This can be useful for tasks such as:

  • Automatically adding methods or attributes to classes.
  • Enforcing class-level constraints or properties.
  • Modifying class behavior dynamically.

In essence, metaclasses allow you to hook into and customize class creation in Python.

Introduction

In Python, everything is an object, even classes themselves. This means that classes are instances of something too, and that something is called a metaclass. Metaclasses allow you to control how classes are created.

Let's explore this by starting with a simple integer variable s.

s = 2
print(s)
print(type(s)) # => class int
print(s.__class__) # => class int
2
<class 'int'>
<class 'int'>

Here, the variable s is an instance of the class int. The type() function tells us that s is of type int, and s.__class__ gives the same result.

Now, what about the class itself? Let's check the type of the int class:

k = s.__class__ # return int class form int instance

print(k.__class__) # return ?
print(type(k))
print(type(int))
<class 'type'>
<class 'type'>
<class 'type'>

The int class itself is an instance of the class type. In Python, classes are created using a metaclass, and the default metaclass is type. Now, let's define a simple class:

class MyClass:
    pass
inst = MyClass()
print(type(inst))    # What is the type of inst (instance)?
print(type(MyClass)) # What is the type of MyClass (class)?
<class '__main__.MyClass'>
<class 'type'>

Using type() to Create Classes

Normally, we define classes using the class keyword, but Python also provides a way to create classes dynamically using the built-in type() function.

class BaseClass:
    def base_method(self):
        print("Base method called")

class MyClass(BaseClass):
    def bar(self):
        print("Bar method called")

    foo = 42

inst = MyClass()

This can be rewritten using the type() function. The type() function can be used to dynamically create classes at runtime by passing three arguments:

  1. Class name: The name of the class as a string.
  2. Bases: A tuple of base classes.
  3. Namespace: A dictionary representing the class body (attributes and methods).

Here’s how the class MyClass would look if we used type() to define it:

def base_method(self):
    print("Base method called")

name = 'BaseClass'
bases = ()
namespace = {
    'base_method': base_method,
}

BaseClass = type(name, bases, namespace)  # Creating BaseClass dynamically
def bar(self):
    print("Bar method called")

name = 'MyClass'
bases = (BaseClass,)  # Inheriting from BaseClass
namespace = {
    'bar': bar,
    'foo': 42,
}
MyClass = type(name, bases, namespace)  # Creating MyClass with inheritance from BaseClass
inst = MyClass()

inst.bar()         # Calls MyClass's method
inst.base_method() # Calls BaseClass's method
Bar method called
Base method called
  • BaseClass: We dynamically create BaseClass using type() with a single method base_method.
  • name: The string 'MyClass' is the name of the class we are creating.
  • bases: We pass (BaseClass,) as the base class from which MyClass inherits.
  • namespace: This dictionary includes the bar method and foo attribute for MyClass.

The type() function creates a class named MyClass that inherits from BaseClass, and has its own methods and attributes.

Exercise: Implement a Metaclass to Uppercase All Attributes

Description:

In this exercise, you will implement a metaclass called upper_attr. This metaclass will modify the class creation process so that all the attributes (both fields and methods) of the class are converted to uppercase. The idea is to intercept the class creation process and transform all attribute names to uppercase in the resulting class.

Initial Code:

def upper_attr(name, bases, namespace):
    # Your code here
    ...
    return type(name, bases, namespace)

Expected Behaviour:

You should be able to define a class using upper_attr as its metaclass, and all attributes should be converted to uppercase.

class Foo(metaclass=upper_attr):
    bar = 'foo'

    def function(self):
        print(42)

# Check the class dictionary
print(Foo.__dict__)
# Expected Output:
# {
#     '__MODULE__': '__main__',
#     '__QUALNAME__': 'Foo',
#     'BAR': 'foo',
#     'FUNCTION': <function Foo.function at 0x7fe925bf44c0>,
#     '__module__': '__main__',
#     '__dict__': <attribute '__dict__' of 'Foo' objects>,
#     '__weakref__': <attribute '__weakref__' of 'Foo' objects>,
#     '__doc__': None
# }

f = Foo()

# Ensure the attributes are accessible in uppercase
assert f.BAR == 'foo'
f.FUNCTION()  # Expected Output: 42

Solution

# meta function
def upper_attr(name, bases, namespace):
    # Convert all attributes to uppercase
    uppered = {key.upper(): value for key, value in namespace.items()}
    return type(name, bases, uppered)

# Testing the metaclass
class Foo(metaclass=upper_attr): # Foo: => Foo(metaclass=type):
    bar = 'foo'

    def function(self):
        print(42)

# Check the class dictionary
print(Foo.__dict__)

# Test instance attributes and methods
f = Foo()
assert f.BAR == 'foo'
f.FUNCTION()
{'__MODULE__': '__main__', '__QUALNAME__': 'Foo', 'BAR': 'foo', 'FUNCTION': <function Foo.function at 0x1097074c0>, '__module__': '__main__', '__dict__': <attribute '__dict__' of 'Foo' objects>, '__weakref__': <attribute '__weakref__' of 'Foo' objects>, '__doc__': None}
42
# Meta class definition
class HelloMeta(type):
    def __new__(cls, name, bases, namespace):
        # Tworzymy nową metodę 'hello'
        def hello(self):
            print(f"Hello from {self.__class__.__name__}!")

        # Dodajemy metodę 'hello' do klasy
        namespace['hello'] = hello

        # Wywołujemy oryginalną metodę __new__ z metaklasy 'type'
        return super().__new__(cls, name, bases, namespace)
class MyClass(metaclass=HelloMeta):
    pass
obj = MyClass()
obj.hello()
Hello from MyClass!

Explanation:

  1. upper_attr Function:

    • The upper_attr function is a custom metaclass that intercepts the class creation process.
    • It takes three arguments:
      • name: The name of the class.
      • bases: A tuple of base classes.
      • namespace: A dictionary containing the class attributes and methods.
    • The function loops over all items in the namespace and converts each key (attribute name) to uppercase.
  2. Class Creation:

    • When Foo is defined with metaclass=upper_attr, the upper_attr function is called to create the class.
    • The returned class has all its attributes (fields and methods) in uppercase.
  3. Testing:

    • We check the __dict__ of the Foo class to confirm that all attributes have been converted to uppercase.
    • We create an instance of Foo and verify that we can access the attributes using uppercase names.

SOLID Design Principles: A Simple Introduction

The SOLID principles are a set of five guidelines used in object-oriented programming to help developers write more maintainable, flexible, and understandable code. These principles were introduced by Robert C. Martin (also known as Uncle Bob) and are designed to encourage better software design, making the code easier to extend, modify, and test.

SOLID stands for:

  1. Single Responsibility Principle (SRP): Each class or module should have only one reason to change. In simpler terms, a class should do only one thing and be responsible for one part of the software's functionality.
  2. Open/Closed Principle (OCP): Classes should be open for extension but closed for modification. You should be able to add new features to a class without changing its existing code.
  3. Liskov Substitution Principle (LSP): Subclasses should be substitutable for their base classes. In other words, objects of a subclass should behave correctly when used in place of an object of the base class.
  4. Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. Instead of one large interface, create smaller, more specific ones.
  5. Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces), making the system more modular and flexible.

The SOLID principles gained popularity in the early 2000s, but their roots go back further. In 1994, four authors—Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides—published "Design Patterns: Elements of Reusable Object-Oriented Software". This book, commonly referred to as the Gang of Four (GoF) book, introduced design patterns to a wider audience and influenced how we think about software design. SOLID principles extend and refine the ideas from the GoF patterns, emphasizing maintainability and scalability in complex systems.

Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class or function should have only one responsibility or reason to change. In practical terms, it means that every class should do only one job, and if a class has more than one responsibility, it’s a candidate for refactoring.

A classic quote from Robert C. Martin is: "A class should have only one reason to change." This "reason" can be understood as a responsibility or actor that affects the class. The aim is to prevent classes from becoming overly complex, harder to understand, and more prone to bugs.

Example: Simple Demonstration of SRP Violation and Fix

Here’s an example of a class that violates the SRP by mixing business logic (calculating pay) with database persistence (saving the object):

class Employee:
    def calculate_pay(self):
        print("Calculating pay")

    def save(self):
        print("Saving to the database")

This class has two responsibilities: calculating the employee's pay and saving the employee to the database. To apply SRP, we should separate these concerns into different classes.

Refactored Solution:

class Employee:
    def calculate_pay(self):
        print("Calculating pay")

class EmployeeRepository:
    def save(self, employee: Employee):
        print(f"Saving {employee} to the database")

Now, the Employee class only handles the business logic, and EmployeeRepository is responsible for saving the employee.

Example: Report Generation and Logging

In this example, we have a Report class that is responsible for both generating a report and handling logging. This violates the SRP because the class is handling two distinct concerns: generating the report and logging messages.

import logging

class Report:
    def __init__(self, title: str, content: str):
        self.title = title
        self.content = content
        self.logger = logging.getLogger('ReportLogger')

    def generate(self):
        self.logger.info(f"Generating report: {self.title}")
        print(f"Report Title: {self.title}\nContent: {self.content}")

    def log_error(self, message: str):
        self.logger.error(message)

Here, the Report class is handling both report generation and logging. If the logging requirements change (e.g., changing the logging mechanism), you’d have to modify the Report class, which should only focus on generating reports.

To fix this violation, we’ll split the logging responsibility into a separate Logger class. The Report class will now only be responsible for report generation, and logging will be handled independently.

import logging

class Logger:
    def __init__(self):
        self.logger = logging.getLogger('ReportLogger')

    def log_info(self, message: str):
        self.logger.info(message)

    def log_error(self, message: str):
        self.logger.error(message)

class Report:
    def __init__(self, title: str, content: str, logger: Logger):
        self.title = title
        self.content = content
        self.logger = logger

    def generate(self):
        self.logger.log_info(f"Generating report: {self.title}")
        print(f"Report Title: {self.title}\nContent: {self.content}")

Example: File Handling and Parsing

Let’s consider a scenario where a class handles both file reading and data parsing. This is another common violation of SRP, as reading a file and parsing data are two distinct responsibilities.

class DataProcessor:
    def read_file(self, file_path: str) -> str:
        with open(file_path, 'r') as file:
            return file.read()

    def parse_data(self, data: str) -> list:
        return data.splitlines()

In this example, DataProcessor is doing two jobs: reading a file and parsing its content. These are separate concerns and should be handled by different classes.

To adhere to SRP, we’ll split the DataProcessor into two classes: one for reading files and one for parsing data.

class FileReader:
    def read_file(self, file_path: str) -> str:
        with open(file_path, 'r') as file:
            return file.read()

class DataParser:
    def parse_data(self, data: str) -> list:
        return data.splitlines()
def test_file_processing():
    file_reader = FileReader()
    data_parser = DataParser()

    data = file_reader.read_file('data.txt')
    parsed_data = data_parser.parse_data(data)

    print(parsed_data)

test_file_processing()
['line1', 'line2', 'line3']

SRP Refactoring Process: From Bad to Good

Now, let's walk through refactoring an example step-by-step to better apply the SRP using a Person class.

Before Refactoring (Bad Example)

The Person class below is responsible for both storing personal information and validating the email:

# %%writefile srp_before.py
import re
import pytest


EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')


class Person:
    def __init__(self, first_name: str, last_name: str, email: str):
        self.first_name = first_name
        self.last_name = last_name
        if self.validate_email(email):
            self.email = email
        else:
            raise ValueError('Invalid email')

    def validate_email(self, email: str) -> bool:
        return EMAIL_PATTERN.match(email) is not None


class BillingAddress:
    def __init__(self, address: str, email: str):
        pass


def test_valid_person():
    Person('Jan', 'Kowalski', 'jan@kowalski.pl')

def test_invalid_email():
    with pytest.raises(ValueError):
        Person('Jan', 'Kowalski', 'invalid email')
Overwriting srp_before.py
! pytest srp_before.py
============================= test session starts ==============================
platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0
rootdir: /Users/a563420/python_training/design_patterns
plugins: anyio-4.4.0
collected 2 items                                                              

srp_before.py ..                                                         [100%]

============================== 2 passed in 0.02s ===============================

Iteration 1: Move Email Validation Out

# %%writefile srp_before.py
import re
import unittest

import pytest


EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')


def validate_email(email: str) -> str:
    if EMAIL_PATTERN.match(email) is None:
        raise ValueError('Invalid email')
    else:
        return email


class Person:
    def __init__(self, first_name: str, last_name: str, email: str):
        self.first_name = first_name
        self.last_name = last_name
        self.email = validate_email(email)


class BillingAddress:
    def __init__(self, address: str, email: str):
        self.email = validate_email(email)


def test_valid_person():
    Person('Jan', 'Kowalski', 'jan@kowalski.pl')

def test_invalid_email():
    with pytest.raises(ValueError):
        Person('Jan', 'Kowalski', 'invalid email')
Overwriting srp_before.py
! pytest srp_before.py
============================= test session starts ==============================
platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0
rootdir: /Users/a563420/python_training/design_patterns
plugins: anyio-4.4.0
collected 2 items                                                              

srp_before.py ..                                                         [100%]

============================== 2 passed in 0.02s ===============================

Iteration 1: Bad Solution

# %%writefile srp_before.py
import re

import pytest


EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')


class Validator:
    def validate_email(self, email: str) -> bool:
        return EMAIL_PATTERN.match(email) is not None


class Person(Validator):
    def __init__(self, first_name: str, last_name: str, email:str):
    # def __init__(self, first_name: str, last_name: str, email_working: str, email_home: str):
        self.first_name = first_name
        self.last_name = last_name

        if self.validate_email(email):
            self.email = email
        else:
            raise ValueError('Invalid email')



class BillingAddress(Validator):
    def __init__(self, address: str, email: str):
        if self.validate_email(email):
            self.email = email
        else:
            raise ValueError('Invalid email')


def test_valid_person():
    Person('Jan', 'Kowalski', 'jan@kowalski.pl')

def test_invalid_email():
    with pytest.raises(ValueError):
        Person('Jan', 'Kowalski', 'invalid email')
Overwriting srp_before.py
! pytest srp_before.py
============================= test session starts ==============================
platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0
rootdir: /Users/a563420/python_training/design_patterns
plugins: anyio-4.4.0
collected 2 items                                                              

srp_before.py ..                                                         [100%]

============================== 2 passed in 0.02s ===============================

Iteration 2: Create an Email Class

# %%writefile srp_before.py
import re
import pytest


EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')



class Email:
    def __init__(self, email):
        if EMAIL_PATTERN.match(email) is None:
            raise ValueError('Invalid email')
        self.email = email

    def __str__(self):
        return self.email


class Person:
    def __init__(self, first_name: str, last_name: str, email: str):
        self.first_name = first_name
        self.last_name = last_name
        self._email = Email(email)

    @property
    def email(self):
        return self._email.email


def test_valid_person():
    Person('Jan', 'Kowalski', 'jan@kowalski.pl')

def test_invalid_email():
    with pytest.raises(ValueError):
        Person('Jan', 'Kowalski', 'invalid email')
Overwriting srp_before.py
! pytest srp_before.py
============================= test session starts ==============================
platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0
rootdir: /Users/a563420/python_training/design_patterns
plugins: anyio-4.4.0
collected 2 items                                                              

srp_before.py ..                                                         [100%]

============================== 2 passed in 0.02s ===============================

Iteration 3: Final Solution with dataclass

# %%writefile srp_after.py
from dataclasses import dataclass
import re

import pytest


EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')


@dataclass(frozen=True)
class Email:  # Value Object
    email: str

    def __post_init__(self):
        if EMAIL_PATTERN.match(self.email) is None:
            raise ValueError('Invalid email')


class Person:
    def __init__(self, first_name: str, last_name: str, email: str):
        self.first_name = first_name
        self.last_name = last_name
        self._email = Email(email)

    @property
    def email(self):
        return self._email.email


def test_valid_person():
    Person('Jan', 'Kowalski', 'jan@kowalski.pl')

def test_invalid_email():
    with pytest.raises(ValueError):
        Person('Jan', 'Kowalski', 'invalid email')

In this final version, each class has a single responsibility: Email manages validation, and Person holds personal information.


With these refactorings, developers can see the value of SRP through incremental improvements, leading to more modular and maintainable code.

Open/Closed Principle (OCP)

The Open/Closed Principle is the second principle in the SOLID design principles and states that:

"Software entities (such as classes, modules, and functions) should be open for extension but closed for modification."

This means that we should be able to extend the behavior of a class without modifying its existing source code. In simpler terms, you should be able to add new functionality by writing new code, but you shouldn't have to change existing code to accommodate those changes. This principle encourages using abstraction and polymorphism to make code more flexible and resistant to changes.

The goal of OCP is to avoid frequent modifications to tested and stable code when new requirements arise. By adhering to this principle, you minimize the risk of introducing bugs while adding new features.

How to Achieve OCP?

To achieve the Open/Closed Principle, we often rely on:

  • Abstraction (e.g., abstract classes or interfaces) to define behaviors.
  • Polymorphism to allow different implementations of the same behavior.

By leveraging these techniques, you can extend the functionality of your system without altering existing code.

What is an Abstract Base Class?

An Abstract Base Class is a class that cannot be instantiated on its own and typically contains one or more abstract methods. Abstract methods are methods that are declared but contain no implementation; they must be overridden by subclasses.

Python provides the abc module to create ABCs. To define an abstract base class, you inherit from ABC and decorate abstract methods with @abstractmethod.

Why Use ABCs?

  • Enforces a Contract: ABCs ensure that all subclasses implement specific methods. This prevents subclasses from being incomplete or misused.
  • Facilitates OCP: You can create a flexible system where adding new functionality only involves extending the abstract class without changing existing, tested code.
  • Encourages Polymorphism: ABCs enable the creation of polymorphic systems where objects can be used interchangeably as long as they share the same interface.

Example: ABC in Python

Let’s revisit the payment processing example, but this time, we will use ABC to define a common interface for different payment methods.

from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    @abstractmethod
    def process(self):
        pass

In this example, PaymentMethod is an abstract base class that declares the process() method as abstract. Any subclass of PaymentMethod must implement the process() method.

Defining Subclasses with ABC

Now, let’s implement different payment methods that inherit from the PaymentMethod ABC.

class CreditCardPayment(PaymentMethod):
    def process(self):
        print("Processing credit card payment")

class PayPalPayment(PaymentMethod):
    def process(self):
        print("Processing PayPal payment")

Benefits of ABC in OCP

  1. Maintainable Code: ABCs allow you to define core behavior without locking the system into specific implementations, making it easier to maintain and extend.
  2. Less Risk: Since abstract base classes and the core system are closed for modification, you avoid the risk of introducing bugs when adding new functionality.
  3. Polymorphic Flexibility: ABCs allow you to treat different subclasses interchangeably as long as they follow the same abstract interface. This makes the code more flexible and reusable.
from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    @abstractmethod
    def process(self):
        pass

class CreditCardPayment(PaymentMethod):
    def process_payment(self):
        print("Processing credit card payment")


payment = CreditCardPayment()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[29], line 13
      9     def process_payment(self):
     10         print("Processing credit card payment")
---> 13 payment = CreditCardPayment()

TypeError: Can't instantiate abstract class CreditCardPayment without an implementation for abstract method 'process'

Example 1: Simple Payment Processing System

In this example, we have a payment processing system that processes different types of payments, like credit card and PayPal payments. However, the class violates OCP because every time we want to add a new payment method (e.g., bank transfer), we need to modify the existing PaymentProcessor class.

#### Bad Example: Violating OCP
class PaymentProcessor:
    def process_payment(self, payment_type: str):
        if payment_type == "credit_card":
            self.process_credit_card_payment()
        elif payment_type == "paypal":
            self.process_paypal_payment()
        else:
            raise ValueError("Unknown payment type")

    def process_credit_card_payment(self):
        print("Processing credit card payment")

    def process_paypal_payment(self):
        print("Processing PayPal payment")

In this example, every time a new payment method is added, such as "bank_transfer," the existing class has to be modified to handle that new type. This violates the OCP because the PaymentProcessor is not closed for modification.

We can refactor this example by introducing an abstract PaymentMethod class and using polymorphism. This way, the PaymentProcessor can handle new payment methods without requiring changes to its code.

#### Refactored Example: Applying OCP
from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    @abstractmethod
    def process(self):
        pass

class CreditCardPayment(PaymentMethod):
    def process(self):
        print("Processing credit card payment")

class PayPalPayment(PaymentMethod):
    def process(self):
        print("Processing PayPal payment")

class PaymentProcessor:
    def process_payment(self, payment_method: PaymentMethod):
        payment_method.process()

Now, if we want to add a new payment method, such as "bank transfer," we can simply extend the PaymentMethod class without modifying the PaymentProcessor:

class BankTransferPayment(PaymentMethod):
    def process(self):
        print("Processing bank transfer payment")

Example 2: Discount System

Consider a discount system where we have different types of discounts (e.g., seasonal discounts, loyalty discounts). Initially, the system calculates only seasonal discounts. When a new loyalty discount is introduced, we modify the existing DiscountCalculator class. This violates the Open/Closed Principle because each new discount type requires changes to the class.

#### Bad Example: Violating OCP
class DiscountCalculator:
    def calculate(self, order_amount: float, discount_type: str) -> float:
        if discount_type == "seasonal":
            return order_amount * 0.9  # 10% off for seasonal discount
        elif discount_type == "loyalty":
            return order_amount * 0.85  # 15% off for loyalty discount
        else:
            raise ValueError("Unknown discount type")
#### Refactored Example: Applying OCP
from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    @abstractmethod
    def apply_discount(self, order_amount: float) -> float:
        pass

class SeasonalDiscount(DiscountStrategy):
    def apply_discount(self, order_amount: float) -> float:
        return order_amount * 0.9  # 10% off

class LoyaltyDiscount(DiscountStrategy):
    def apply_discount(self, order_amount: float) -> float:
        return order_amount * 0.85  # 15% off

class DiscountCalculator:
    def calculate(self, order_amount: float, discount_strategy: DiscountStrategy) -> float:
        return discount_strategy.apply_discount(order_amount)
class HolidayDiscount(DiscountStrategy):
    def apply_discount(self, order_amount: float) -> float:
        return order_amount * 0.8  # 20% off

Example 3: Reporting System

Let’s imagine a reporting system that generates reports in different formats, such as PDF and Excel. The initial implementation might look like this:

#### Bad Example: Violating OCP
class ReportGenerator:
    def generate(self, report_type: str):
        if report_type == "pdf":
            self.generate_pdf_report()
        elif report_type == "excel":
            self.generate_excel_report()
        else:
            raise ValueError("Unknown report type")

    def generate_pdf_report(self):
        print("Generating PDF report")

    def generate_excel_report(self):
        print("Generating Excel report")
#### Refactored Example: Applying OCP
from abc import ABC, abstractmethod

class ReportFormat(ABC):
    @abstractmethod
    def generate(self):
        pass

class PDFReport(ReportFormat):
    def generate(self):
        print("Generating PDF report")

class ExcelReport(ReportFormat):
    def generate(self):
        print("Generating Excel report")

class ReportGenerator:
    def generate(self, report_format: ReportFormat):
        report_format.generate()
class HTMLReport(ReportFormat):
    def generate(self):
        print("Generating HTML report")

Excercise: OCP Discount

Task: Implement a new discount strategy without modifying the old one

  • Someone has already implemented a strategy to compute discounts for customers in the calculate_discount_percentage function.

  • Now, you need to reuse the logic from this function to implement a new strategy.

    • The only difference is that loyal customers who have been with us for 10 years should get a 15% discount, instead of the 20% discount they currently receive.
  • Your goal is to implement this new strategy in the calculate_discount_percentage_new function.

Key Requirements:

  1. Keep the existing discount strategy intact for old customers, as it is still in use.

    • You are not allowed to change the rules you have previously agreed upon with your old customers.
  2. Implement the new rules for new customers without modifying the original function.

  3. Do not copy and paste the existing code from the old function.

    • Why? Because there may be more changes in the future, and copying and pasting the code would make it difficult to manage.
  4. The tests provided only test the old strategy.

    • Do not modify the tests or any existing logic for the old strategy.

Your task is to refactor the code so that both strategies can coexist without duplicating the logic, keeping the code clean and maintainable.

Intial Code

# %%writefile ocp_before.py

#### Initial Code
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional


@dataclass
class Customer:
    first_purchase_date: Optional[datetime]  # datetime or None
    birth_date: datetime
    is_veteran: bool


def calculate_discount_percentage(customer):
    discount = 0
    now = datetime.now()
    year = timedelta(days=365)
    if customer.birth_date <= now - 65*year:
        # senior discount
        discount = 5
    if customer.first_purchase_date is not None:
        if customer.first_purchase_date <= now - year:
            # after one year, loyal customers get 10%
            discount = 10
            if customer.first_purchase_date <= now - 5*year:
                # after five years, 12%
                discount = 12
                if customer.first_purchase_date <= now - 10*year:
                    # after ten years, 20%
                    discount = 20
    else:
        # first time purchase ==> 15% discount
        discount = 15
    if customer.is_veteran:
        discount = max(discount, 10)
    return discount
Overwriting ocp_before.py

Tests

# %%writefile ocp-tests.py


from datetime import datetime, timedelta

import pytest

### Update import: ocp_before or ocp_after
from ocp_after import Customer, calculate_discount_percentage

YEAR = timedelta(days=365)

@pytest.fixture
def now():
    return datetime.now()

def test_should_return_zero_for_casual_customer(now):
    customer = Customer(
        first_purchase_date=now,
        birth_date=now-20*YEAR,
        is_veteran=False,
    )
    got = calculate_discount_percentage(customer)
    expected = 0
    assert got == expected

def test_should_return_15_for_new_client(now):
    customer = Customer(
        first_purchase_date=None,
        birth_date=now-20*YEAR,
        is_veteran=False,
    )
    got = calculate_discount_percentage(customer)
    expected = 15
    assert got == expected

def test_should_return_10_for_veteran(now):
    customer = Customer(
        first_purchase_date=now,
        birth_date=now-20*YEAR,
        is_veteran=True,
    )
    got = calculate_discount_percentage(customer)
    expected = 10
    assert got == expected

def test_should_return_5_for_a_senior(now):
    customer = Customer(
        first_purchase_date=now,
        birth_date=now-65*YEAR,
        is_veteran=False,
    )
    got = calculate_discount_percentage(customer)
    expected = 5
    assert got == expected

def test_should_return_10_for_a_loyal_customer(now):
    customer = Customer(
        first_purchase_date=now-1*YEAR,
        birth_date=now-20*YEAR,
        is_veteran=False,
    )
    got = calculate_discount_percentage(customer)
    expected = 10
    assert got == expected

def test_should_return_12_for_a_more_loyal_customer(now):
    customer = Customer(
        first_purchase_date=now-5*YEAR,
        birth_date=now-20*YEAR,
        is_veteran=False,
    )
    got = calculate_discount_percentage(customer)
    expected = 12
    assert got == expected

def test_should_return_20_for_a_most_loyal_customer(now):
    customer = Customer(
        first_purchase_date=now-10*YEAR,
        birth_date=now-20*YEAR,
        is_veteran=False,
    )
    got = calculate_discount_percentage(customer)
    expected = 20
    assert got == expected

def test_should_return_maximum_discount(now):
    customer = Customer(
        first_purchase_date=None,
        birth_date=now-20*YEAR,
        is_veteran=True,
    )
    # eligible for 15% discount as a new client and 10% as a veteran
    got = calculate_discount_percentage(customer)
    expected = 15
    assert got == expected
Overwriting ocp-tests.py
! pytest ocp-tests.py
============================= test session starts ==============================
platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0
rootdir: /Users/a563420/python_training/design_patterns
plugins: anyio-4.4.0
collected 8 items                                                              

ocp-tests.py ........                                                    [100%]

============================== 8 passed in 0.06s ===============================

Hint

def first_purchase_rule(customer):
    if customer.first_purchase_date is None:
        return 15
    else:
        return 0

def senior_rule(customer):
    ...

# Loyalty Rule?

# 1 rok => 10%
# 5 lat => 12%
# 10 lat => 20%

class LoyalCustomerRule:
    def __init__(self, years, discount):
        self.years = years
        self.discount = discount

    def __call__(self, customer):
        if ...:
            return self.discount
        else:
            return 0


def veteran_rule(customer):
    ...

def calculate_discount_percentage(customer):
    strategy = [
        first_purchase_rule,
        senior_rule,
        LoyalCustomerRule(1, 10),
        ...
    ]
    # compute maximum discount and return it

def calculate_discount_percentage_new(customer):
    strategy = [
        first_purchase_rule,
        senior_rule,
        LoyalCustomerRule(1, 10),
        ...
    ]
    # compute maximum discount and return it

Solution 1

# %%writefile ocp_after.py

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional



YEAR = timedelta(days=365)
SENIOR_AGE = 65 * YEAR


@dataclass
class Customer:
    first_purchase_date: Optional[datetime]  # datetime or None
    birth_date: datetime
    is_veteran: bool

#     @property
#     def age(self):
#         now = datetime.now()
#         return now - customer.birth_date


def senior_rule(customer):
    now = datetime.now()
    if customer.birth_date <= now - SENIOR_AGE:
        return 5
    else:
        return 0


def first_purchase_rule(customer):
    if customer.first_purchase_date is None:
        return 15
    else:
        return 0


@dataclass
class LoyalCustomerRule:
    years: int
    discount: float

    def __call__(self, customer):
        now = datetime.now()
        if (customer.first_purchase_date is not None and
            customer.first_purchase_date <= now - self.years * YEAR):
            return self.discount
        else:
            return 0


def veteran_rule(customer):
    #int x = cond ? value_if_true : value_if_false
    # x = value_if_true if cond else value_if_else
    return 10 if customer.is_veteran else 0


def calculate_discount_percentage(customer):
    rules = [
        senior_rule,
        first_purchase_rule,
        LoyalCustomerRule(years=1, discount=10),
        LoyalCustomerRule(years=5, discount=12),
        LoyalCustomerRule(years=10, discount=20),
        veteran_rule,
    ]
    discounts = [rule(customer) for rule in rules]
    return max(discounts, default=0)


def calculate_discount_percentage_new(customer):
    rules = [
        senior_rule,
        first_purchase_rule,
        LoyalCustomerRule(years=1, discount=10),
        LoyalCustomerRule(years=5, discount=12),
        LoyalCustomerRule(years=10, discount=15),
        veteran_rule,
    ]
    discounts = [rule(customer) for rule in rules]
    return max(discounts, default=0)

Solution 2

# %%writefile ocp_after.py

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional

import pytest


YEAR = timedelta(days=365)
SENIOR_AGE = 65 * YEAR


@dataclass
class Customer:
    first_purchase_date: Optional[datetime]  # datetime or None
    birth_date: datetime
    is_veteran: bool

#     @property
#     def age(self):
#         now = datetime.now()
#         return now - customer.birth_date


class Rule:
    def __call__(self, customer: Customer) -> float:
        raise NotImplementedError


@dataclass
class SeniorRule(Rule):
    discount: float

    def __call__(self, customer):
        now = datetime.now()
        if customer.birth_date <= now - SENIOR_AGE:
            return self.discount
        else:
            return 0


@dataclass
class FirstPurchaseRule(Rule):
    discount: float

    def __call__(self, customer):
        if customer.first_purchase_date is None:
            return self.discount
        else:
            return 0


@dataclass
class LoyalCustomerRule(Rule):
    years: int
    discount: float

    def __call__(self, customer):
        now = datetime.now()
        if (customer.first_purchase_date is not None and
            customer.first_purchase_date <= now - self.years * YEAR):
            return self.discount
        else:
            return 0


@dataclass
class VeteranRule(Rule):
    discount: float

    def __call__(self, customer):
        #int x = cond ? value_if_true : value_if_false
        # x = value_if_true if cond else value_if_else
        return self.discount if customer.is_veteran else 0


class DiscountCalculator:
    def __init__(self, rules):
        self.rules = rules

    def __call__(self, customer):
        discounts = [rule(customer) for rule in self.rules]
        return max(discounts, default=0)


calculate_discount_percentage = DiscountCalculator([
    SeniorRule(discount=5),
    FirstPurchaseRule(discount=15),
    LoyalCustomerRule(years=1, discount=10),
    LoyalCustomerRule(years=5, discount=12),
    LoyalCustomerRule(years=10, discount=20),
    VeteranRule(discount=10),
])

calculate_discount_percentage_new = DiscountCalculator([
    SeniorRule(discount=5),
    FirstPurchaseRule(discount=15),
    LoyalCustomerRule(years=1, discount=10),
    LoyalCustomerRule(years=5, discount=12),
    LoyalCustomerRule(years=10, discount=15),
    VeteranRule(discount=10),
])
class PaymentMethod:
    def process_payment(self, amount: float):
        raise NotImplementedError

class CreditCardPayment(PaymentMethod):
    def process_payment(self, amount: float):
        print(f"Processing credit card payment of ${amount}")

class PayPalPayment(PaymentMethod):
    def process_payment(self, email: str, amount: float):
        print(f"Processing PayPal payment of ${amount} from {email}")

Solution 3

# %%writefile ocp_after.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional

import pytest


YEAR = timedelta(days=365)
SENIOR_AGE = 65 * YEAR


@dataclass
class Customer:
    first_purchase_date: Optional[datetime]  # datetime or None
    birth_date: datetime
    is_veteran: bool

#     @property
#     def age(self) -> int:
#         now = datetime.now()
#         return now - customer.birth_date



class Rule(ABC):
    @abstractmethod
    def __call__(self, customer: 'Customer') -> float:
        raise NotImplementedError

    @abstractmethod
    def is_eligible(self, customer: 'Customer') -> bool:
        raise NotImplementedError


@dataclass
class SimpleRule(Rule):
    discount: float

    def is_eligible(self, customer: Customer) -> float:
        raise NotImplementedError # child implement please

    def __call__(self, customer: Customer) -> float: # child please don't chnage this logic
        if self.is_eligible(customer):
            return self.discount
        else:
            return 0


@dataclass
class SeniorRule(SimpleRule):
    def is_eligible(self, customer):
        return customer.birth_date <= datetime.now() - SENIOR_AGE


@dataclass
class FirstPurchaseRule(SimpleRule):
    def is_eligible(self, customer):
        return customer.first_purchase_date is None


@dataclass
class LoyalCustomerRule(SimpleRule):
    # implicit discount: float
    years: int

    def is_eligible(self, customer):
        now = datetime.now()
        return (customer.first_purchase_date is not None and
                customer.first_purchase_date <= now - self.years * YEAR)


@dataclass
class VeteranRule(SimpleRule):
    def is_eligible(self, customer):
        return customer.is_veteran


class DiscountCalculator:
    def __init__(self, rules):
        self.rules = rules

    def __call__(self, customer):
        discounts = [rule(customer) for rule in self.rules]
        return max(discounts, default=0)


calculate_discount_percentage = DiscountCalculator([
    SeniorRule(discount=5),
    FirstPurchaseRule(discount=15),
    LoyalCustomerRule(years=1, discount=10),
    LoyalCustomerRule(years=5, discount=12),
    LoyalCustomerRule(years=10, discount=20),
    VeteranRule(discount=10),
])

calculate_discount_percentage_new = DiscountCalculator([
    SeniorRule(discount=5),
    FirstPurchaseRule(discount=15),
    LoyalCustomerRule(years=1, discount=10),
    LoyalCustomerRule(years=5, discount=12),
    LoyalCustomerRule(years=10, discount=15),
    VeteranRule(discount=10),
])

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is one of the five SOLID principles and helps guide how we structure the relationship between parent classes and their subclasses. The basic idea behind LSP is:

"You should be able to replace an object of a parent class with an object of its subclass without breaking the program."

In simpler terms, subclasses should behave in a way that doesn't contradict or change the expected behavior of the parent class. When you replace a parent class with a subclass, the program should still function as intended without any surprises or errors.

Why is LSP Important?

LSP is important because it ensures that your code is flexible, modular, and reusable. If subclasses behave in unexpected ways or don’t fully respect the behavior of their parent classes, you could introduce bugs into the system. By following LSP, your code will be easier to understand, maintain, and extend.

When subclasses follow LSP, you can build systems where components are interchangeable. This makes your program more flexible and reduces the risk of errors when extending your code.

Key Rules of LSP (In Simple Terms)

To follow the Liskov Substitution Principle, there are three important rules to keep in mind when creating subclasses:

  1. You can't make the rules stricter in a subclass.

    • This means that if the parent class allows something, the subclass should allow it too. You shouldn’t make it harder for the subclass to do the same job as the parent class.

    Example: If the parent class accepts a wide range of inputs, the subclass should not narrow it down by only accepting a more specific set of inputs.

  2. You can’t relax the promises made by the parent class.

    • If the parent class promises to give a certain output (called the “postcondition”), the subclass can’t lower that expectation. It can provide more detailed or specific results, but it should always meet the base expectations set by the parent.

    Example: If a parent class promises that a method returns a number, a subclass can’t suddenly return a text string. It can return more specific types of numbers, but it can’t break the original promise.

  3. You must preserve the overall behavior of the parent class.

    • The subclass should maintain the core rules and logic of the parent class. It shouldn’t introduce new behaviors that contradict how the parent class works. This is called preserving the “invariants,” or the things that are always true for the parent class.

    Example: If a parent class guarantees that certain properties of an object won’t change during its lifetime, the subclass should maintain those guarantees as well.

Example: Payment System with Credit Card and PayPal

Suppose you have a payment system with a base PaymentMethod class that processes payments. You want to add different payment methods, such as credit cards and PayPal, but they should behave in a way that follows the Liskov Substitution Principle.

Bad Example: Violating LSP

In this example, the PayPalPayment class violates LSP by introducing different parameters for processing a payment, making it incompatible with the PaymentMethod interface.

class PaymentMethod:
    def process_payment(self, amount: float):
        raise NotImplementedError

class CreditCardPayment(PaymentMethod):
    def process_payment(self, amount: float):
        print(f"Processing credit card payment of ${amount}")

class PayPalPayment(PaymentMethod):
    def process_payment(self, email: str, amount: float):
        print(f"Processing PayPal payment of ${amount} from {email}")

Here, if we try to substitute PayPalPayment for a PaymentMethod, the code will break because the parameters for process_payment do not match.

Refactored Example: Following LSP

To follow LSP, we should make sure that all payment methods adhere to the same contract (i.e., the method signatures should be consistent).

class PaymentMethod:
    def process_payment(self, amount: float):
        raise NotImplementedError

class CreditCardPayment(PaymentMethod):
    def process_payment(self, amount: float):
        print(f"Processing credit card payment of ${amount}")

class PayPalPayment(PaymentMethod):
    def __init__(self, email: str):
        self.email = email

    def process_payment(self, amount: float):
        print(f"Processing PayPal payment of ${amount} from {self.email}")
class PaymentMethod:
    def __init__(self, customer: object):
        self.customer = customer

    def process_payment(self, amount: float):
        raise NotImplementedError

class CreditCardPayment(PaymentMethod):
    def process_payment(self, amount: float):
        print(f"Processing credit card payment of ${amount}")

class PayPalPayment(PaymentMethod):
    def process_payment(self, amount: float):
        print(f"Processing PayPal payment of ${amount} from {self.customer.email}")

Example: Rectangle and Square Problem

A classic example used to demonstrate LSP is the Rectangle and Square problem. At first glance, it might seem reasonable to make Square a subclass of Rectangle, since a square is a type of rectangle. But this leads to issues when applying the LSP.

Bad Example: Violating LSP

In this example, we create a Square class as a subclass of Rectangle. The Rectangle class has two methods, set_width and set_height, but when substituting a Square object in place of a Rectangle, unexpected behavior occurs.

class Rectangle:
    def __init__(self, width: int, height: int):
        self.width = width
        self.height = height

    def set_width(self, width: int):
        self.width = width

    def set_height(self, height: int):
        self.height = height

    def get_area(self):
        return self.width * self.height

class Square(Rectangle):
    def set_width(self, width: int):
        self.width = width
        self.height = width  # Both sides are equal

    def set_height(self, height: int):
        self.height = height
        self.width = height  # Both sides are equal

In this case, if we substitute a Square for a Rectangle, the set_width or set_height method will modify both dimensions, which breaks the expected behavior of a Rectangle.

Refactored Example: Following LSP

To properly follow LSP, the Square class should not be a subclass of Rectangle since they behave differently. Instead, we can refactor the design by separating them into two distinct classes.

class Rectangle:
    def __init__(self, width: int, height: int):
        self.width = width
        self.height = height

    def get_area(self):
        return self.width * self.height

class Square:
    def __init__(self, side_length: int):
        self.side_length = side_length

    def get_area(self):
        return self.side_length * self.side_length

Example: Bird and Penguin Problem

Let’s consider a scenario where we have a Bird class that can fly, and we create a subclass Penguin. Since penguins can't fly, this is a violation of LSP because a Penguin object will not behave correctly when substituted for a Bird object.

Bad Example: Violating LSP

class Bird:
    def fly(self):
        print("Flying in the sky!")

class Penguin(Bird):
    def fly(self):
        raise Exception("Penguins can't fly!")

n this example, if we treat a Penguin as a Bird and call the fly method, it will raise an exception, which breaks the expected behavior.

Refactored Example: Following LSP

To fix this, we can refactor the design so that not all birds are expected to fly. We can introduce an abstract base class Bird, and separate flying birds and non-flying birds.

from abc import ABC, abstractmethod

class Bird(ABC):
    @abstractmethod
    def make_sound(self):
        pass

class FlyingBird(Bird):
    def fly(self):
        print("Flying in the sky!")

class NonFlyingBird(Bird):
    def walk(self):
        print("Walking on the ground!")

class Penguin(NonFlyingBird):
    def make_sound(self):
        print("Penguin sound!")

class Sparrow(FlyingBird):
    def make_sound(self):
        print("Chirping sound!")

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is the fourth principle in the SOLID design principles and deals with how interfaces should be designed and used. Simply put:

"No client should be forced to depend on methods it does not use."

In other words, large interfaces should be broken down into smaller, more specific ones. Classes should only implement the methods they need and use, and not be forced to implement unnecessary functionality.

Why is ISP Important?

The Interface Segregation Principle is important because it helps keep your code more modular, flexible, and easy to maintain. By having smaller, focused interfaces, you avoid situations where classes are burdened with methods they don’t need, which can lead to bloated code, difficulty in maintaining, and potential bugs.

ISP helps prevent what’s known as “fat interfaces” or “God interfaces”, which are interfaces that have too many responsibilities or methods. Such interfaces force classes to implement methods they don’t need, which violates the principle of “separation of concerns.” By following ISP, you ensure that each class and interface has a clear and focused responsibility.

Example: Printer Interface Problem

Consider an interface for a multifunction printer that includes various operations like printing, scanning, and faxing. Not all printers support all of these operations, so it doesn’t make sense for every printer class to implement every method.

Bad Example: Violating ISP

In this example, a single interface Printer has too many methods, forcing classes to implement methods they don’t use:

class Printer:
    def print_document(self, document):
        raise NotImplementedError

    def scan_document(self, document):
        raise NotImplementedError

    def fax_document(self, document):
        raise NotImplementedError

class BasicPrinter(Printer):
    def print_document(self, document):
        print("Printing document")

    def scan_document(self, document):
        pass  # Basic printer cannot scan, but still needs to implement this method

    def fax_document(self, document):
        pass  # Basic printer cannot fax either

Here, the BasicPrinter class is forced to implement scan_document and fax_document even though it doesn’t support these features. This violates ISP because it’s making the class depend on methods it does not need.

Refactored Example: Following ISP

To follow ISP, we can split the Printer interface into smaller, more focused interfaces that group related functionality. Each class will only implement the interfaces it needs:

class Printer:
    def print_document(self, document):
        raise NotImplementedError

class Scanner:
    def scan_document(self, document):
        raise NotImplementedError

class FaxMachine:
    def fax_document(self, document):
        raise NotImplementedError

class BasicPrinter(Printer):
    def print_document(self, document):
        print("Printing document")

class AdvancedPrinter(Printer, Scanner, FaxMachine):
    def print_document(self, document):
        print("Printing document")

    def scan_document(self, document):
        print("Scanning document")

    def fax_document(self, document):
        print("Faxing document")

Example: Media Player Problem

Let’s consider a media player that can play different types of media like audio and video. Not all media players support both, so it doesn’t make sense for them to implement methods for formats they can’t play.

Bad Example: Violating ISP

In this bad example, the MediaPlayer interface includes methods for playing both audio and video, forcing a class that only supports audio to implement the video method as well:

class MediaPlayer:
    def play_audio(self, audio_file):
        raise NotImplementedError

    def play_video(self, video_file):
        raise NotImplementedError

class AudioPlayer(MediaPlayer):
    def play_audio(self, audio_file):
        print(f"Playing audio: {audio_file}")

    def play_video(self, video_file):
        pass  # Audio players cannot play video, but still need to implement this method

The AudioPlayer class is forced to implement the play_video method even though it doesn’t support video playback, violating ISP.

Refactored Example: Following ISP

To adhere to ISP, we can break down the MediaPlayer interface into smaller, more focused interfaces for audio and video:

class AudioPlayerInterface:
    def play_audio(self, audio_file):
        raise NotImplementedError

class VideoPlayerInterface:
    def play_video(self, video_file):
        raise NotImplementedError

class AudioPlayer(AudioPlayerInterface):
    def play_audio(self, audio_file):
        print(f"Playing audio: {audio_file}")

class VideoPlayer(AudioPlayerInterface, VideoPlayerInterface):
    def play_audio(self, audio_file):
        print(f"Playing audio: {audio_file}")

    def play_video(self, video_file):
        print(f"Playing video: {video_file}")

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) is the fifth and final principle of the SOLID design principles. It helps guide how we structure dependencies in our code. In simple terms, the Dependency Inversion Principle states:

"High-level modules should not depend on low-level modules. Both should depend on abstractions."

Additionally:

"Abstractions should not depend on details. Details should depend on abstractions."

In essence, instead of having high-level components directly rely on low-level components, both should depend on an abstraction, such as an interface or abstract class. This makes your code more flexible, easier to test, and allows you to switch out implementations without affecting the overall system.

Why is DIP Important?

DIP is crucial for making code easier to maintain and modify. By ensuring that high-level modules (which contain your core business logic) don't depend on the nitty-gritty details of low-level modules (e.g., specific database interactions or APIs), you can change, extend, or replace parts of your system without breaking the whole program. This leads to better modularity, and a more flexible, scalable architecture.

Example: Notification System

Let’s consider a notification system where you need to send messages via different services such as email and SMS.

Bad Example: Violating DIP

In this example, the Notification class directly depends on the concrete classes EmailService and SMSService. This violates DIP because the high-level module (Notification) is tightly coupled to the low-level modules (specific services).

class EmailService:
    def send_email(self, message: str):
        print(f"Sending email: {message}")

class SMSService:
    def send_sms(self, message: str):
        print(f"Sending SMS: {message}")

class Notification:
    def __init__(self):
        self.email_service = EmailService()
        self.sms_service = SMSService()

    def notify(self, message: str):
        self.email_service.send_email(message)
        self.sms_service.send_sms(message)

Here, if you want to change the way messages are sent (e.g., adding a PushNotification service), you would need to modify the Notification class. This tightly couples the high-level Notification class to low-level implementations, making it harder to extend or maintain.

Refactored Example: Following DIP

To follow the Dependency Inversion Principle, we can create an abstraction (like an interface or abstract class) for message sending and make the high-level Notification class depend on this abstraction, not on the concrete implementations.

from abc import ABC, abstractmethod

class MessageService(ABC):
    @abstractmethod
    def send(self, message: str):
        pass

class EmailService(MessageService):
    def send(self, message: str):
        print(f"Sending email: {message}")

class SMSService(MessageService):
    def send(self, message: str):
        print(f"Sending SMS: {message}")

class Notification:
    def __init__(self, service: MessageService):
        self.service = service

    def notify(self, message: str):
        self.service.send(message)

Example 2: Payment Processing

Imagine you have a payment system where different payment methods like PayPal, credit cards, and bank transfers are used.

Bad Example: Violating DIP

Here, the PaymentProcessor class directly depends on the low-level classes PayPalService and CreditCardService. This makes the PaymentProcessor difficult to extend when new payment methods are added.

class PayPalService:
    def process_payment(self, amount: float):
        print(f"Processing PayPal payment of ${amount}")

class CreditCardService:
    def process_payment(self, amount: float):
        print(f"Processing credit card payment of ${amount}")

class PaymentProcessor:
    def __init__(self):
        self.paypal_service = PayPalService()
        self.credit_card_service = CreditCardService()

    def process(self, method: str, amount: float):
        if method == "paypal":
            self.paypal_service.process_payment(amount)
        elif method == "credit_card":
            self.credit_card_service.process_payment(amount)

In this setup, the PaymentProcessor is tightly coupled to specific payment services. Adding new methods like BankTransferService would require modifying the PaymentProcessor class.

Refactored Example: Following DIP

To follow DIP, we abstract the payment services into an interface and make the PaymentProcessor class depend on the abstraction, not the concrete implementations.

from abc import ABC, abstractmethod

class PaymentService(ABC):
    @abstractmethod
    def process_payment(self, amount: float):
        pass

class PayPalService(PaymentService):
    def process_payment(self, amount: float):
        print(f"Processing PayPal payment of ${amount}")

class CreditCardService(PaymentService):
    def process_payment(self, amount: float):
        print(f"Processing credit card payment of ${amount}")

class PaymentProcessor:
    def __init__(self, payment_service: PaymentService):
        self.payment_service = payment_service

    def process(self, amount: float):
        self.payment_service.process_payment(amount)

Example: Data Persistence Layer

Consider a system where you need to save data to different storage systems, such as a database or a file system.

from abc import ABC, abstractmethod

class StorageService(ABC):
    @abstractmethod
    def save_data(self, data):
        pass

class DatabaseStorage(StorageService):
    def save_data(self, data):
        print(f"Saving data to the database: {data}")

class FileStorage(StorageService):
    def save_data(self, data):
        print(f"Saving data to a file: {data}")

class DataManager:
    def __init__(self, storage_service: StorageService):
        self.storage_service = storage_service

    def save(self, data):
        self.storage_service.save_data(data)
database_storage = DatabaseStorage()
file_storage = FileStorage()

data_manager = DataManager(database_storage)
data_manager.save("Some important data")
Saving data to the database: Some important data

Example: Controlling a Light with a Button

This example demonstrates how tightly coupling a ToggleButton to a specific LEDLight implementation can violate the Dependency Inversion Principle (DIP) and how to refactor the design to follow the principle.

Bad Example: Violating DIP

In the following example, the ToggleButton class is directly dependent on the LEDLight class from an external library. The button directly controls the light by setting its RGB values, tightly coupling the button to this specific light implementation.

class LEDLight:  # External library
    def setRGB(self, r: float, g: float, b: float) -> None:
        print(f"Set color to {r}, {g}, {b}")

class ToggleButton:
    def __init__(self, light: LEDLight):
        self.light = light

    def click(self):
        # Directly controls the light, violating DIP
        self.light.setRGB(1, 1, 1)  # Turns the light on with a specific color

In this example, ToggleButton is tightly coupled to LEDLight, which means:

  • You can't reuse ToggleButton with other types of lights.
  • If the LEDLight class changes, the ToggleButton class also has to change, violating DIP.

Refactored Example: Following DIP

To follow the Dependency Inversion Principle, we can introduce an abstraction, like a Switch interface, that both the button and light depend on. The ToggleButton will depend on the abstraction, allowing the light's implementation details to change without affecting the button.

class LEDLight:  # External library
    def setRGB(self, r: float, g: float, b: float) -> None:
        print(f"Set color to {r}, {g}, {b}")

from abc import ABC, abstractmethod

class Switch(ABC):  # Abstraction for controlling lights
    @abstractmethod
    def on(self):
        pass

class LEDSwitch(Switch):  # Implementation of Switch for LED lights
    def __init__(self):
        self.led_light = LEDLight()

    def on(self):
        # Turns on the LED light with white color
        self.led_light.setRGB(1, 1, 1)

class ToggleButton:
    def __init__(self, switch: Switch):
        self.switch = switch  # Depends on abstraction, not specific light

    def click(self):
        self.switch.on()  # Triggers the on method without knowing the details

Now, ToggleButton is dependent on the Switch interface, which can have different implementations like LEDSwitch or any other type of switch. This follows the Dependency Inversion Principle:

  • The high-level ToggleButton no longer depends directly on low-level details like LEDLight.
  • You can easily swap out LEDSwitch with another implementation (e.g., a switch for a different type of light), without modifying the ToggleButton class.

Creational Patterns

Creational patterns are design patterns that focus on how objects are created. They help make systems more flexible by allowing objects to be created in different ways depending on the situation. Instead of directly instantiating objects (using new in some languages, or calling a class constructor), creational patterns allow us to delegate this responsibility to another part of the system. This makes the system more adaptable and easier to extend or modify when the requirements change.

The main goal of creational patterns is to manage and control the process of object creation, ensuring that the system remains decoupled and flexible. These patterns also often provide solutions to deal with complex object creation, especially when the exact type or configuration of the object isn't known until runtime.

Common Creational Patterns:

  • Factory Method: Creates objects without specifying the exact class.
  • Abstract Factory: Allows you to create families of related objects without specifying their concrete classes.
  • Singleton: Ensures a class has only one instance and provides a global point of access to it.
  • Builder: Separates the construction of a complex object from its representation, allowing you to create different types of objects with the same construction process.
  • Prototype: Creates new objects by copying existing objects, allowing for flexible and efficient object creation.

Prototype Pattern

The Prototype Pattern is a creational design pattern that allows you to create new objects by cloning existing objects. Instead of instantiating a new object directly, you clone an existing object, which can then be customized as needed. This pattern is useful when creating new objects is expensive or when we need many similar objects.

In simpler terms, the Prototype Pattern lets you make a copy of an object to create a new one. This is particularly handy when an object’s setup is complex or resource-intensive. By cloning an already configured object, you can skip the process of building everything from scratch.

When to Use the Prototype Pattern:

  • When object creation is costly: If the setup of an object requires a lot of time or resources, it’s easier to clone an existing instance instead of building a new one.
  • When you need to avoid subclasses: If you want to create new instances of a class without going through the inheritance or factory method approach, cloning a prototype is a simpler solution.
  • When objects are similar: If you need several objects that share the same structure or properties but have slight differences, the Prototype Pattern is a perfect fit.

How It Works:

  1. You create a prototype object (an existing instance).
  2. Instead of creating new objects directly, you make clones (copies) of this prototype.
  3. Each clone can be modified if needed, but the core structure remains the same as the prototype.

By using the Prototype Pattern, you avoid the complexity of recreating new objects from scratch, and you gain flexibility in how you customize each clone.

Use Cases

  • Clone a record in a DB: If you're versioning a record and each version needs to be stored as a separate record, you can clone the original record, modify the necessary fields, and save the clone.
  • Copy data to prevent accidental modification: When working on data that might be shared between different parts of the system, you can create a copy of it to ensure that changes don't affect the original.
  • Test data preparation: In testing, you may need to prepare slightly different test data for multiple tests. Instead of creating it from scratch each time, you can create a prototype, clone it for each test, and then modify the clone as needed.
  • Copy-pasting in editors: Any editor (such as a document editor) that allows you to copy and paste elements is using a form of the Prototype Pattern. You copy the original element and then make modifications to the clone.

xample:

class PersonModel(models.Model):
    first_name = models.CharField()
    last_name = models.CharField()

    def clone(self):
        ...

Example: Cloning a Database Record

Suppose we are dealing with a system that stores versions of records in a database. Each version is a new record. Instead of creating a new record from scratch, we clone an existing one.

import copy
class PersonModel:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    def clone(self):
        return copy.deepcopy(self)

# Usage
record = PersonModel("John", "Doe")
new_version = record.clone()
new_version.last_name = "Smith"  # Versioned record with slight modification

Example: Creating Test Data for Unit Tests

In unit testing, test data preparation is often repetitive. With the Prototype Pattern, you can create a prototype of the test data and clone it for each test.

import copy
class TestData:
    def __init__(self, data):
        self.data = data

    def clone(self):
        return copy.deepcopy(self)

# Usage in tests
prototype_data = TestData({"name": "John", "age": 30})

# Test 1
test_data_1 = prototype_data.clone()
test_data_1.data["age"] = 25

# Test 2
test_data_2 = prototype_data.clone()
test_data_2.data["name"] = "Jane"

print(test_data_1.data)  # Output: {'name': 'John', 'age': 25}
print(test_data_2.data)  # Output: {'name': 'Jane', 'age': 30}
{'name': 'John', 'age': 25}
{'name': 'Jane', 'age': 30}

Shallow vs Deep Copy

When cloning objects in Python, it’s important to understand the difference between shallow copy and deep copy:

  • Shallow Copy: Creates a new object, but references the original elements. If the object contains mutable elements, changes to them will reflect in the original object.
  • Deep Copy: Creates a completely independent copy of the object and all of its nested elements.
import copy

original = [1,2, ['one', 'two']]

deep_clone = copy.deepcopy(original)
shallow_clone = copy.copy(original)
original[2].append('three') # add "three to inner list"
original.append(4) # add 4 to outer list
print(original)
[1, 2, ['one', 'two', 'three'], 4]
print(deep_clone)
[1, 2, ['one', 'two']]
print(shallow_clone)
[1, 2, ['one', 'two', 'three']]

As you can see, the shallow copy (shallow_clone) references the same inner list, so changes to the inner list in the original are reflected in the shallow copy. The deep copy (deep_clone), on the other hand, is completely independent.

Point.clone()

What to Do

Your task is to implement the clone() method for the Point class, so that it correctly returns a copy of the Point object. Additionally, ensure that the method works with inheritance: if a subclass of Point calls .clone(), the method should return an instance of the subclass, not the base Point class.

Initial Code

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float
    z: float

    def clone(self):
        ...

Expected Behaviour

p1 = Point(3, 4, 5)
assert (p1.x, p1.y, p1.z) == (3, 4, 5)

p2 = p1.clone()
p2.z = 8
assert p2.z == 8, p2
assert p1.z == 5, p1

#### Expected Behaviour -- inheritance

class ExtendedPoint(Point):
    def distance_to(self, other):
        return ((self.x-other.x)**2 + (self.y-other.y)**2 + (self.z-other.z)**2) ** 0.5

ex_p = ExtendedPoint(3, 4, 0)
ex_p2 = ExtendedPoint(0, 0, 0)

assert ex_p.distance_to(ex_p2) == 5.0
assert type(ex_p) is ExtendedPoint
assert type(ex_p.clone()) is not Point
assert type(ex_p.clone()) is ExtendedPoint

Solution

from dataclasses import dataclass
import copy

@dataclass
class Point:
    x: float
    y: float
    z: float

    def clone(self):
        return copy.copy(self)
        # return Point(x=self.x, y=self.y, z=self.z) # zle !!!

# Extended example with inheritance
class ExtendedPoint(Point):
    def distance_to(self, other):
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2 + (self.z - other.z) ** 2) ** 0.5

ex_p = ExtendedPoint(3, 4, 0)
ex_p2 = ExtendedPoint(0, 0, 0)

assert ex_p.distance_to(ex_p2) == 5.0
assert type(ex_p) is ExtendedPoint
assert type(ex_p.clone()) is ExtendedPoint

Copy Interface

In some cases, you may need to customize the way an object is copied. Python’s copy module allows you to define how an object should be deep-copied by implementing the __deepcopy__ method.

import copy

class Logger:
    def __init__(self, name):
        self.name = name

    def log(self, message):
        print(f'Logging: {message}')

    def __deepcopy__(self, memo):
        # Custom deepcopy behavior: append an underscore to the name
        return Logger(self.name + '_')
logger = Logger('main')
copied_logger = copy.deepcopy(logger)
print(copied_logger.name)  # Output: main_

copied_copied_logger = copy.deepcopy(copied_logger)
print(copied_copied_logger.name)  # Output: main_
main_
main__
# Copying a list of loggers
loggers = [Logger('first'), Logger('second')]
loggers_copy = copy.deepcopy(loggers)
print(loggers_copy[1].name)  # Output: second_
second_

In this example, we customize the deep-copy behavior for the Logger class. When a Logger is copied, its name attribute is modified by appending an underscore (_).

Singleton Pattern

The Singleton Pattern is a creational design pattern that ensures a class has only one instance and provides a global point of access to that instance. This means that every time you request an instance of that class, you’ll get the same one, and no other instance will ever be created.

In simpler terms, the Singleton Pattern ensures that only one object of a particular class exists in the system, regardless of how many times you try to create it.

When to Use the Singleton Pattern:

  • When a single instance is sufficient for all uses: For example, when you're dealing with things like logging services, database connections, or configuration settings, it's wasteful and risky to have multiple instances. One instance should be shared across the application.
  • When you need a single point of control: This pattern is useful when one centralized object should manage some resource or provide services to other parts of your application.
  • When multiple instances would cause issues: If multiple instances would break functionality (e.g., conflicting data or resource usage), Singleton ensures that this never happens.

None is Singleton

In Python, some built-in objects like None already behave as singletons. When you assign variables to None, they all point to the same memory location. Let’s see how None behaves like a Singleton.

a = None
b = None

print(a is b)

print(id(a))   # Output: <same memory address>
print(id(b))   # Output: <same memory address>
print(id(copy.copy(a)))
True
4511203840
4511203840
4511203840
# Even if you create an instance of NoneType, it still behaves like a singleton.
NoneType = type(None)
my_none = NoneType()
print(id(my_none))  # Output: <same memory address>
4511203840

True and False are Singletons

Similarly, in Python, True and False also behave as singletons. Regardless of how many times you assign variables to True or False, they all refer to the same memory location.

a = False
b = False
print(a is b)  # Output: True
print(id(a))   # Output: <same memory address>
print(id(b))   # Output: <same memory address>
True
4511116552
4511116552

Ellipsis (...) is Singleton

Another interesting built-in singleton in Python is Ellipsis, represented by the ... symbol. It's often used as a placeholder in Python and, like None, there is only one instance of Ellipsis.

a = ...
b = Ellipsis
print(a is b)  # Output: True
print(id(a))   # Output: <same memory address>
print(id(b))   # Output: <same memory address>
True
4511212192
4511212192
# Even if you create an instance of the ellipsis class, it behaves like a singleton.
ellipsis_cls = type(...)
new_ellipsis = ellipsis_cls()
print(id(new_ellipsis))  # Output: <same memory address>
4511212192

Non-Singleton

Now, let’s see a non-singleton example. When you create instances of a normal class, each object is distinct, and they don’t share the same memory location.

class MyClass:
    pass

a = MyClass()
b = MyClass()
print(id(a))  # Output: <unique memory address>
print(id(b))  # Output: <different memory address>
4547322288
4547316672

Java-like Singleton Implementation

In Python, you can implement the Singleton pattern in a way that mimics Java's typical approach. The idea is to provide a method to access the instance of the class, but ensure that the constructor is called only once. If the instance already exists, it should return the existing one.

What to Do

  • Implement a class CounterSingleton with a class-level attribute instance that holds the singleton instance.
  • The constructor should only be called once. For further access to the object, a class method get_instance() should be used.

Initial Code

class CounterSingleton:

    def __init__(self):
        self._next_id = 0


    def get_instance(cls):
        ...

    def get_next_id(self):
        self._next_id += 1
        return self._next_id

# Expected Behavior
a = CounterSingleton.get_instance()
b = CounterSingleton.get_instance()
print(id(a))  # Output: same memory address for both a and b
print(id(b))  # Output: same memory address for both a and b
print(a.get_next_id())  # Output: 1
print(a.get_next_id())  # Output: 2
print(b.get_next_id())  # Output: 3

Solution

class CounterSingleton:
    instance = None

    def __init__(self):
        self._next_id = 0

    @classmethod
    def get_instance(cls):
        if cls.instance is None:
            cls.instance = cls()  # Create only one instance
        return cls.instance

    def get_next_id(self):
        self._next_id += 1
        return self._next_id

# Expected Behavior
a = CounterSingleton.get_instance()
b = CounterSingleton.get_instance()
print(id(a))  # Both instances have the same memory address
print(id(b))  # Both instances have the same memory address
print(a.get_next_id())  # Output: 1
print(a.get_next_id())  # Output: 2
print(b.get_next_id())  # Output: 3

# Accessing it from different files/modules:
# one_file.py
counter = CounterSingleton.get_instance()
print(counter.get_next_id())  # Output: 4

# another_module.py
counter = CounterSingleton.get_instance()
print(counter.get_next_id())  # Output: 5
4547321280
4547321280
1
2
3
4
5
c = CounterSingleton()
print(id(a))
print(id(b))
print(id(c))
4547321280
4547321280
4549132992

Singleton with Modules

In Python, modules naturally behave as singletons. When a module is imported, it's loaded and initialized once. Every subsequent import of that module returns the same instance. This makes Python modules behave like singletons without extra code.

# %%writefile counter_singleton.py
_next_id = 0

def get_next_id():
    global _next_id
    _next_id += 1
    return _next_id

def reset_counter():
    global _next_id
    _next_id = 0
Writing counter_singleton.py

Once this module is imported, you can call get_next_id() and it will behave like a singleton.

from counter_singleton import get_next_id, reset_counter

print(get_next_id())  # Output: 1
print(get_next_id())  # Output: 2
print(get_next_id())  # Output: 3
reset_counter()
print(get_next_id())  # Output: 1
1
2
3
1

Singleton with __new__ Method in Python

In Python, another way to implement the Singleton pattern is by overriding the __new__ method. The __new__ method is responsible for creating a new instance of a class. By controlling this method, we can ensure that only one instance of the class is ever created, regardless of how many times we try to instantiate it.

Here’s how you can implement a Singleton using the __new__ method:

class Singleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

# Testing the Singleton
singleton1 = Singleton()
singleton2 = Singleton()

print(singleton1 is singleton2)  # Output: True
True

Parametrized Singleton

In some cases, you may need a Parametrized Singleton. This is a variation of the Singleton Pattern where different parameters result in different singleton instances. This means that a singleton instance is created per unique parameter, and requests with the same parameter will return the same instance.

class ParametrizedSingleton:
    _instances = {}

    def __new__(cls, param1, param2):
        key = (param1, param2)
        if key not in cls._instances:
            cls._instances[key] = super().__new__(cls)
        return cls._instances[key]

    def __init__(self, param1, param2):
        self.param1 = param1
        self.param2 = param2

# Testing the Parametrized Singleton
singleton1 = ParametrizedSingleton(10, 20)
singleton2 = ParametrizedSingleton(10, 20)
singleton3 = ParametrizedSingleton(30, 40)

# All instances with the same parameters should point to the same object
print(singleton1 is singleton2)  # Output: True

# Instances with different parameters should be different
print(singleton1 is singleton3)  # Output: False

# Checking the values stored in each instance
print(singleton1.param1, singleton1.param2)  # Output: 10 20
print(singleton3.param1, singleton3.param2)  # Output: 30 40
True
False
10 20
30 40

One common example of a parametrized singleton in Python is the logging module. The getLogger() method returns a singleton logger for a given name. If you request a logger with the same name multiple times, it will return the same logger instance. If you request a logger with a different name, a new singleton instance is created for that name.

Let’s look at an example of how the logging module works as a parametrized singleton:

import logging

# Get logger with the name 'A'
logger_a = logging.getLogger('A')
print(logger_a)  # Output: <Logger A (WARNING)>

# Check that repeated calls to getLogger('A') return the same instance
print(id(logging.getLogger('A')))  # Output: Same memory address as logger_a
print(id(logging.getLogger('A')))  # Output: Same memory address as logger_a

# Get a different logger with the name 'B'
logger_b = logging.getLogger('B')
print(id(logger_b))  # Output: Different memory address from logger_a
<Logger A (WARNING)>
4549138320
4549138320
4547608256

Builder Pattern

The Builder Pattern is a creational design pattern that simplifies the construction of complex objects. It allows you to create different representations of an object by breaking down the creation process into steps. The key idea behind the Builder Pattern is to separate the object construction process from its representation, meaning that the same process can be used to create different types of objects or variations of the same object.

Why Use the Builder Pattern?

The Builder Pattern solves several challenges related to object creation:

  • Creating complex objects: When an object is made up of multiple parts or requires multiple configuration steps, a traditional constructor may become cumbersome. The Builder Pattern makes this more manageable by splitting the creation into logical steps.
  • Improving flexibility: The Builder Pattern allows you to create different versions of the same object using the same process. This is useful when dealing with objects that can have various configurations or optional parameters.
  • Encapsulation of construction: The Builder Pattern encapsulates the code responsible for creating the object, ensuring the class itself doesn't break the Single Responsibility Principle (SRP). It also keeps the construction logic separate from the object’s internal logic.

Use Cases for the Builder Pattern:

  • Creating complex hierarchies of objects: When building composite objects like trees or object graphs, a builder can make the construction easier and cleaner.
  • Working with immutable objects that need to be temporarily mutable during their creation: The Builder Pattern allows you to mutate the object during the building phase, and then deliver it as immutable.
  • Building complex SQL queries: Instead of manually concatenating query strings, a builder can generate SQL queries step by step.
  • Form creation in UI systems: Builders are often used in user interfaces to create complex forms with multiple fields and configurations.
  • Command-line argument parsers: Libraries like Python’s argparse use the Builder Pattern to construct parsers with multiple optional parameters and configurations.
  • Document generation: PDF builders are a common use case where text, images, and formatting are added step by step before generating the final document.

Database Example

Here’s an example of how a builder can simplify the creation of a complex database schema. Without a builder, setting up tables and filling them with data might require writing a lot of procedural code. With a builder, you can streamline this process by chaining methods.

class MSSQLDatabaseBuilder:
    def __init__(self):
        self.commands = []

    def create_table(self, table_name):
        self.commands.append(f"CREATE TABLE {table_name};")
        return self

    def create_string_column(self, column_name):
        self.commands.append(f"ALTER TABLE ADD COLUMN {column_name} VARCHAR(255);")
        return self

    def fill_with_random_usernames(self):
        self.commands.append("INSERT INTO user (username) VALUES ('random_user');")
        return self

    def fill_with_password_hashes(self, algorithm='bcrypt2'):
        self.commands.append(f"INSERT INTO user (password) VALUES ('hashed_password_with_{algorithm}');")
        return self

    def build(self):
        return "\n".join(self.commands)
# Example usage:
db = (MSSQLDatabaseBuilder()
    .create_table('user')
    .create_string_column('username')
    .fill_with_random_usernames()
    .create_string_column('password')
    .fill_with_password_hashes(algorithm='bcrypt2')
    .create_table('items')
    .build())

print(db)
CREATE TABLE user;
ALTER TABLE ADD COLUMN username VARCHAR(255);
INSERT INTO user (username) VALUES ('random_user');
ALTER TABLE ADD COLUMN password VARCHAR(255);
INSERT INTO user (password) VALUES ('hashed_password_with_bcrypt2');
CREATE TABLE items;

PDF Example

Generating documents, like PDFs, often involves adding various elements such as text, images, and formatting. Using a builder makes this process more intuitive and less error-prone.

class PDFBuilder:
    def __init__(self):
        self.content = []

    def add_text(self, text, page=1, loc=(0, 0)):
        self.content.append(f"Text '{text}' added at {loc} on page {page}")
        return self

    def add_image(self, image_path, page=1):
        self.content.append(f"Image '{image_path}' added on page {page}")
        return self

    def generate(self, file_name):
        # Simulate PDF generation by printing the commands
        print(f"Generating PDF '{file_name}' with the following content:")
        for item in self.content:
            print(item)
        return self


# Example usage:
doc = PDFBuilder()
doc.add_text('Hello, world!', page=1, loc=(0, 0)) \
   .add_image('path/to/image.png', page=1) \
   .add_image('path/to/image.png', page=2) \
   .generate('output.pdf')
Generating PDF 'output.pdf' with the following content:
Text 'Hello, world!' added at (0, 0) on page 1
Image 'path/to/image.png' added on page 1
Image 'path/to/image.png' added on page 2
<__main__.PDFBuilder at 0x10f0e6a50>

Matplotlib Example

Matplotlib is a powerful plotting library in Python, and it also uses a builder-like interface. You can build plots step by step, adding data series, titles, labels, and so on.

from matplotlib import pyplot as plt

# Builder-like process for creating a plot
plt.figure(figsize=(10, 5))  # Set up the figure size
plt.plot([1, 3, 2])          # Add the first plot line
plt.plot([3, 4, 2])          # Add another plot line
plt.grid()                   # Add grid lines
plt.show()                   # Render and display the plot

Complex Builder Example: Building a Car

Let’s consider a more complex example of the Builder Pattern by building a Car object step by step. A car can have multiple components, such as an engine, wheels, color, and seats. These components might vary based on the model or customer preferences. The Builder Pattern allows us to construct different cars using the same process, but with variations in the components.

In this example, we’ll create a CarBuilder that helps build a Car object with multiple parts. The builder will allow us to specify different configurations for the car, and at the end, we’ll call build() to get the final Car object.

Car Components:

  • Engine: The type of engine (e.g., electric, gasoline).
  • Wheels: The number of wheels.
  • Color: The car’s exterior color.
  • Seats: The number of seats.
class Car:
    def __init__(self):
        self.engine = None
        self.wheels = None
        self.color = None
        self.seats = None

    def __str__(self):
        return (f"Car with {self.engine} engine, {self.wheels} wheels, "
                f"painted {self.color}, and {self.seats} seats.")


class CarBuilder:
    def __init__(self):
        self.car = Car()

    def add_engine(self, engine_type):
        self.car.engine = engine_type
        return self

    def add_wheels(self, wheel_count):
        self.car.wheels = wheel_count
        return self

    def paint(self, color):
        self.car.color = color
        return self

    def add_seats(self, seat_count):
        self.car.seats = seat_count
        return self

    def build(self):
        return self.car  # Return the final car object


# Example usage
car_builder = CarBuilder()

# Build a sports car
sports_car = (car_builder
    .add_engine("V8")
    .add_wheels(4)
    .paint("Red")
    .add_seats(2)
    .build())

print(sports_car)  # Output: Car with V8 engine, 4 wheels, painted Red, and 2 seats.

# Build a family car
family_car = (car_builder
    .add_engine("Electric")
    .add_wheels(4)
    .paint("Blue")
    .add_seats(5)
    .build())

print(family_car)  # Output: Car with Electric engine, 4 wheels, painted Blue, and 5 seats.
Car with V8 engine, 4 wheels, painted Red, and 2 seats.
Car with Electric engine, 4 wheels, painted Blue, and 5 seats.

How It Works:

  1. CarBuilder Class: This class is responsible for adding various parts to the Car object. Each method returns the builder itself (self), allowing for method chaining.
  2. Method Chaining: The pattern allows you to add each component step by step, resulting in clear and readable code.
  3. Flexibility: You can create different types of cars (sports cars, family cars, etc.) using the same CarBuilder class, just by chaining different methods together.
  4. Car Class: The Car class represents the final object that we are building. It contains the properties (engine, wheels, color, seats) that can be configured through the builder.

Advantages of This Approach:

  • Separation of Concerns: The CarBuilder focuses only on constructing the car, while the Car class handles the details of what a car is.
  • Easy to Extend: If you need to add more components to the car (e.g., a sunroof, GPS system), you can easily extend the CarBuilder with new methods without modifying existing logic.
  • Flexibility: Different configurations of the car can be created using the same builder, which makes the process flexible and reusable.

Exercise: MongoDB Aggregation Query Builder

In this exercise, you will implement a MongoDB aggregation query builder using the Builder Pattern, following the Single Responsibility and Open-Closed principles.

MongoDB aggregation pipelines are used to process data in stages, such as filtering documents, grouping results, and sorting. Your task is to build a query builder that constructs these aggregation stages step by step.

To maintain immutability and allow for method chaining in your query builder, you'll need to create a copy of the current state of the query at each step. This allows you to add new stages to the query without modifying the original query. By using new = self.copy(), you can ensure that the original object remains unchanged while returning a new object with the updated query stages.

This approach adheres to the Builder Pattern by allowing incremental, step-by-step construction of the query while preserving immutability, ensuring that previous versions of the query remain intact. Each method (match(), group(), sort(), etc.) will return a new query object, allowing for flexible combinations of query stages without side effects.

Objectives:

  1. Build a Query in Stages: Implement methods like match(), group(), sort(), and limit() to add stages to the query. These methods should use new = self.copy() to create a new version of the query object with the added stage, maintaining immutability.

  2. Use Method Chaining: Each method should return a new instance of the query builder with the added stage, allowing for method chaining (e.g., query.match(...).group(...).sort(...)).

  3. Adhere to SOLID Principles:

    • Single Responsibility Principle: Each class (e.g., MatchStage, GroupStage) should handle only one specific task (building that stage of the query).
    • Open-Closed Principle: Your builder should be easily extendable with new stages (e.g., adding $unwind or $project stages) without modifying the existing code.

The use of new = self.copy() ensures that each operation returns a new, updated query object, making the builder flexible and supporting method chaining while avoiding unintended modifications to previous query states.

Initial Code

from typing import List, Dict
import copy

# MongoDB Aggregation Stages
class MongoAggregationStage:
    """Represents an aggregation stage in MongoDB."""
    def to_stage(self) -> Dict:
        """Convert this stage to its MongoDB representation."""
        raise NotImplementedError

class MatchStage(MongoAggregationStage):
    def __init__(self, criteria: Dict):
        ...

    ...

class GroupStage(MongoAggregationStage):
    def __init__(self, group_by: str, accumulator: Dict):

       ...

    ...

class SortStage(MongoAggregationStage):
    def __init__(self, sort_by: str, order: int = 1):
        ...

    ...

# TODO: Add the LimitStage class
# class LimitStage ...

# MongoDB Aggregation Query Builder
class MongoAggregationQueryBuilder:
    def __init__(self):
        self.stages: List[MongoAggregationStage] = []

    def match(self, criteria: Dict):
        """Add a match stage to the aggregation pipeline."""
        new = self.copy()
        # TODO: Implement the match method using MatchStage
        pass


    # TODO: Implement the group, sort, limit methods

    def copy(self):
        return copy.deepcopy(self)

    def build(self) -> List[Dict]:
        """Return the aggregation pipeline as a list of stages."""
        # TODO: Implement the build method to return the stages
        pass

Hints for Implementation

  • MongoDB Aggregation Basics:
    • $match: Filters documents based on the criteria provided.
      { "$match": { "age": { "$gte": 18 } } }
      
    • $group: Groups documents by a specified field and applies an accumulator function like $sum.
      { "$group": { "_id": "$city", "total": { "$sum": 1 } } }
      
    • $sort: Orders documents based on the specified field in ascending (1) or descending (-1) order.
      { "$sort": { "age": 1 } }
      
    • $limit: Restricts the number of documents returned.
      { "$limit": 5 }
      

Testing

query = MongoAggregationQueryBuilder()

# Test 1: Match only
q1 = query.match({"username": "admin"})
expected_q1 = [{ "$match": { "username": "admin" } }]
assert q1.build() == expected_q1, f"Expected: {expected_q1}, but got: {q1.build()}"

# Test 2: Match and Group
q2 = query.match({"username": "admin"}).group("role", {"count": {"$sum": 1}})
expected_q2 = [
    { "$match": { "username": "admin" } },
    { "$group": { "_id": "$role", "count": { "$sum": 1 } } }
]
assert q2.build() == expected_q2, f"Expected: {expected_q2}, but got: {q2.build()}"

# Test 3: Sort and Limit
q3 = query.sort("created_at", -1).limit(5)
expected_q3 = [
    { "$sort": { "created_at": -1 } },
    { "$limit": 5 }
]
assert q3.build() == expected_q3, f"Expected: {expected_q3}, but got: {q3.build()}"
# Multi-stage test: Match, Group, Sort, Limit
multi_stage_query = (
    query
    .match({"age": {"$gte": 18}})                      # Match stage: Filter documents where age >= 18
    .group("city", {"total": {"$sum": 1}})              # Group stage: Group by city and count total
    .sort("total", -1)                                  # Sort stage: Sort by total in descending order
    .limit(10)                                          # Limit stage: Limit results to 10 documents
)

expected_multi_stage_query = [
    { "$match": { "age": { "$gte": 18 } } },
    { "$group": { "_id": "$city", "total": { "$sum": 1 } } },
    { "$sort": { "total": -1 } },
    { "$limit": 10 }
]
assert multi_stage_query.build() == expected_multi_stage_query, \
    f"Expected: {expected_multi_stage_query}, but got: {multi_stage_query.build()}"

Solution

from typing import List, Dict
import copy

# MongoDB Aggregation Stages
class MongoAggregationStage:
    """Represents an aggregation stage in MongoDB."""
    def to_stage(self) -> Dict:
        """Convert this stage to its MongoDB representation."""
        raise NotImplementedError

class MatchStage(MongoAggregationStage):
    def __init__(self, criteria: Dict):
        self.criteria = criteria

    def to_stage(self) -> Dict:
        return {"$match": self.criteria}

class GroupStage(MongoAggregationStage):
    def __init__(self, group_by: str, accumulator: Dict):
        self.group_by = group_by
        self.accumulator = accumulator

    def to_stage(self) -> Dict:
        return {"$group": {"_id": f"${self.group_by}", **self.accumulator}}

class SortStage(MongoAggregationStage):
    def __init__(self, sort_by: str, order: int = 1):
        self.sort_by = sort_by
        self.order = order

    def to_stage(self) -> Dict:
        return {"$sort": {self.sort_by: self.order}}

class LimitStage(MongoAggregationStage):
    def __init__(self, limit: int):
        self.limit = limit

    def to_stage(self) -> Dict:
        return {"$limit": self.limit}

# MongoDB Aggregation Query Builder
class MongoAggregationQueryBuilder:
    def __init__(self):
        self.stages: List[MongoAggregationStage] = []

    def copy(self):
        return copy.deepcopy(self)

    def match(self, criteria: Dict):
        new = self.copy()
        new.stages.append(MatchStage(criteria))
        return new

    def group(self, group_by: str, accumulator: Dict):
        new = self.copy()
        new.stages.append(GroupStage(group_by, accumulator))
        return new

    def sort(self, sort_by: str, order: int = 1):
        new = self.copy()
        new.stages.append(SortStage(sort_by, order))
        return new

    def limit(self, count: int):
        new = self.copy()
        new.stages.append(LimitStage(count))
        return new

    def build(self) -> List[Dict]:
        return [stage.to_stage() for stage in self.stages]


# Testing the solution
query = MongoAggregationQueryBuilder()
q1 = query.match({"username": "admin"})
q2 = query.match({"username": "admin"}).group("role", {"count": {"$sum": 1}})
q3 = query.sort("created_at", -1).limit(5)

print(q1.build())  # [{ "$match": { "username": "admin" } }]
print(q2.build())  # [{ "$match": { "username": "admin" } }, { "$group": { "_id": "$role", "count": { "$sum": 1 } } }]
print(q3.build())  # [{ "$sort": { "created_at": -1 } }, { "$limit": 5 }]
[{'$match': {'username': 'admin'}}]
[{'$match': {'username': 'admin'}}, {'$group': {'_id': '$role', 'count': {'$sum': 1}}}]
[{'$sort': {'created_at': -1}}, {'$limit': 5}]
# Example of a multi-stage MongoDB aggregation query

# Initialize the query builder
query = MongoAggregationQueryBuilder()

# Build a query with multiple stages
multi_stage_query = (
    query
    .match({"age": {"$gte": 18}})                      # Match stage: Filter documents where age >= 18
    .group("city", {"total": {"$sum": 1}})              # Group stage: Group by city and count total
    .sort("total", -1)                                  # Sort stage: Sort by total in descending order
    .limit(10)                                          # Limit stage: Limit results to 10 documents
)

# Print the resulting query pipeline
print(multi_stage_query.build())
[{'$match': {'age': {'$gte': 18}}}, {'$group': {'_id': '$city', 'total': {'$sum': 1}}}, {'$sort': {'total': -1}}, {'$limit': 10}]

Exercise: Builder in Tests

Write tests for Order class. You can find example data below:

shipping_address = Address(
    name='John Doe',
    address='123 Street',
    city='Springfield',
    state='MO',
    zipcode=65807,
)
billing_information = BillingInformation(
    billing_address=shipping_address,
    credit_card_number='1234567890123456',
    expiration_month=12,
    expiration_year=2022,
    card_holder_name='John P. Doe',
)
card = [
    Item(
        guid=1234,
        quantity=2,
        price=10.0,
        name='water',
        description='spilled water',
        category='drinking',
    )
]

Check:

  • If Order.total() returns valid result (20)?
  • If the order can be processed without any error?
  • If you change the credit card number to empty, if you get an error (ValueError) when you try to proceed?
  • If Order.total() returns valid result, when the order is exactly as above, except that it contains an additional Item (make sure that guid is unique)?
  • If Order.total() returns valid result, when the order is exactly as above, except that it contains two additional Items (make sure that guid is unique)?
  • ★ If Order.total() returns valid result, when the order is exactly as above, except that it contains items from the file below.
%%writefile items.csv
guid,price,quantity
1,5,15
5,8,20
5,8,20
Writing items.csv

Initial Code

# %%writefile builder_example.py
from dataclasses import dataclass
from typing import List

@dataclass
class Item:
    guid: str
    name: str
    description: str
    category: str
    quantity: float
    price: float

@dataclass
class Address:
    name: str
    address: str
    city: str
    state: str
    zipcode: str

@dataclass
class BillingInformation:
    billing_address: Address
    credit_card_number: str
    expiration_month: int
    expiration_year: int
    card_holder_name: str

@dataclass
class Order:
    cart: List[Item]
    billing_information: BillingInformation
    shipping_address: Address

    def total(self):
        return sum(item.quantity * item.price for item in self.cart)

    def proceed(self):
        if len(self.billing_information.credit_card_number) != 16:
            raise ValueError('Invalid credit card number')

        print("Proceeding")
Writing builder_example.py

Factory Method Pattern

The Factory Method Pattern is a creational design pattern that defines an interface for creating objects but allows subclasses to determine which class to instantiate. The primary goal of this pattern is to encapsulate the logic for object creation in a method, giving flexibility to handle complex object creation while decoupling the client from specific implementations.

When Should You Use the Factory Method Pattern?

The Factory Method is particularly useful when:

  • Object creation involves complex decision-making or configuration.
  • Different types of objects need to be created depending on context (e.g., environment, user preferences, configurations).
  • You want to decouple the creation process from the object usage, allowing flexibility in changing or extending object types without modifying existing code.

Simple Factory Pattern

  1. Purpose: The Simple Factory Pattern provides a way to encapsulate object creation logic. The goal is to create objects without having to specify the exact class of object that will be created.

  2. How It Works:

    • It defines an interface for creating an object, but lets subclasses or other components decide which class to instantiate.
    • This pattern typically uses a single method (often create() or getInstance()) that returns different types of objects depending on the input parameters.
  3. Example: If you have a VehicleFactory that creates different types of vehicles (Car, Bike, etc.) based on the input, it decides which specific class to instantiate but returns a general Vehicle interface or abstract class.

    class VehicleFactory:
        def create_vehicle(self, vehicle_type):
            if vehicle_type == 'car':
                return Car()
            elif vehicle_type == 'bike':
                return Bike()
    
    # Client Code
    factory = VehicleFactory()
    vehicle = factory.create_vehicle('car')  # Creates a Car object
    
  4. Use Case: Factory Pattern is used when you need to create instances of a class from a family of derived classes without knowing the exact subclass that will be instantiated.

Abstract Factory Pattern

  1. Purpose: The Abstract Factory Pattern provides an interface to create families of related or dependent objects without specifying their concrete classes. It is more complex and powerful than the Factory Pattern.

  2. How It Works:

    • Instead of a single method to create objects, the abstract factory has multiple methods to create various related objects (a family of objects).
    • The goal is to ensure that related products (objects) are created together in a consistent way.
    • Each factory class (concrete factory) is responsible for creating objects from a particular product family.
  3. Example: If you’re building an application that deals with multiple UI themes (e.g., MacOS vs. Windows UI), an abstract factory might produce related components like Button, Window, and Checkbox specific to each theme.

    class AbstractUIFactory:
        def create_button(self):
            pass
        def create_window(self):
            pass
    
    class MacOSFactory(AbstractUIFactory):
        def create_button(self):
            return MacButton()
        def create_window(self):
            return MacWindow()
    
    class WindowsFactory(AbstractUIFactory):
        def create_button(self):
            return WindowsButton()
        def create_window(self):
            return WindowsWindow()
    
    # Client Code
    def create_ui(factory: AbstractUIFactory):
        button = factory.create_button()
        window = factory.create_window()
    
    factory = MacOSFactory()
    create_ui(factory)  # Creates MacOS-style button and window
    
  4. Use Case: Abstract Factory Pattern is used when you need to create families of related or dependent objects that should be used together and ensure that the objects created are from the same family. For example, when creating multiple types of objects (like buttons, windows, scrollbars) that are related and need to conform to a theme or style (e.g., MacOS, Windows).

Key Differences between Simple and Abstract Factories

  1. Complexity: The Factory Pattern is simpler, typically dealing with the creation of one type of object at a time, while the Abstract Factory Pattern handles the creation of multiple related objects (families of objects).

  2. Object Scope:

    • Simple Factory Pattern: Focuses on creating one object at a time.
    • Abstract Factory Pattern: Deals with families of objects and ensures consistency across them.
  3. Usage:

    • Use Factory Pattern when you need to decouple the object creation process but are only creating a single product.
    • Use Abstract Factory Pattern when creating groups of related objects that need to maintain consistency.

Both patterns promote loose coupling by abstracting the process of object creation, but the Abstract Factory Pattern is more versatile when managing complex scenarios involving multiple types of related objects.

Example Scenario: A Cloud Resource Manager

Let’s consider a cloud resource manager that provisions different types of virtual machines (VMs) on AWS, Google Cloud, and Azure. The process of provisioning a VM is complex, as it depends on:

  • The cloud provider (AWS, Google Cloud, Azure).
  • Different configuration parameters (CPU, memory, disk space).
  • Credentials (each provider has its own method of authentication).
  • The availability of certain resources on each platform.

In this case, the Factory Method pattern is ideal because it encapsulates all the complexity of provisioning a VM, which is different depending on the cloud provider.

Without Factory Method

The code below shows how the process of provisioning VMs might look without the Factory Method. Each time we need to provision a VM, we have to handle the specific logic for each cloud provider, which leads to repetition and tightly coupled code.

class AWSVM:
    def provision(self, cpu, memory, disk_size):
        print(f"Provisioning AWS VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")

class GoogleCloudVM:
    def provision(self, cpu, memory, disk_size):
        print(f"Provisioning Google Cloud VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")

class AzureVM:
    def provision(self, cpu, memory, disk_size):
        print(f"Provisioning Azure VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")

def provision_vm(provider, cpu, memory, disk_size):
    if provider == "aws":
        vm = AWSVM()
    elif provider == "google_cloud":
        vm = GoogleCloudVM()
    elif provider == "azure":
        vm = AzureVM()
    else:
        raise ValueError(f"Unknown provider: {provider}")

    vm.provision(cpu, memory, disk_size)

# Usage
provision_vm("aws", 4, 16, 100)        # Provisioning AWS VM with 4 CPUs, 16 GB RAM, 100 GB Disk
provision_vm("google_cloud", 2, 8, 50) # Provisioning Google Cloud VM with 2 CPUs, 8 GB RAM, 50 GB Disk
Provisioning AWS VM with 4 CPUs, 16 GB RAM, 100 GB Disk
Provisioning Google Cloud VM with 2 CPUs, 8 GB RAM, 50 GB Disk

Issues:

  1. Tight Coupling: The client is tightly coupled with the logic of determining which VM to create.
  2. Limited Extensibility: Adding a new provider requires modifying the client logic, which violates the Open-Closed Principle.
  3. Scattered Logic: The logic of selecting the correct VM type is repeated every time the provision_vm function is called.

With Factory Method Pattern

Now, let’s apply the Factory Method Pattern to handle this situation. The factory method will encapsulate all the decision-making logic for provisioning VMs, allowing for better flexibility and future extensibility.

# Abstract VM class (Product)
class VM:
    def provision(self, cpu: int, memory: int, disk_size: int):
        raise NotImplementedError

# AWS VM class (Concrete Product)
class AWSVM(VM):
    def provision(self, cpu: int, memory: int, disk_size: int):
        print(f"Provisioning AWS VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")

# Google Cloud VM class (Concrete Product)
class GoogleCloudVM(VM):
    def provision(self, cpu: int, memory: int, disk_size: int):
        print(f"Provisioning Google Cloud VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")

# Azure VM class (Concrete Product)
class AzureVM(VM):
    def provision(self, cpu: int, memory: int, disk_size: int):
        print(f"Provisioning Azure VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")


# Abstract Factory (Creator)
class CloudProviderFactory:
    def create_vm(self) -> VM:
        raise NotImplementedError

    def authenticate(self):
        raise NotImplementedError

    def provision_vm(self, cpu: int, memory: int, disk_size: int):
        self.authenticate()  # Authenticate to cloud provider
        vm = self.create_vm()  # Factory Method: Create VM based on cloud provider
        vm.provision(cpu, memory, disk_size)  # Provision VM with specific config


# AWS Factory (Concrete Creator)
class AWSFactory(CloudProviderFactory):
    def create_vm(self) -> VM:
        return AWSVM()

    def authenticate(self):
        print("Authenticating to AWS using IAM credentials...")


# Google Cloud Factory (Concrete Creator)
class GoogleCloudFactory(CloudProviderFactory):
    def create_vm(self) -> VM:
        return GoogleCloudVM()

    def authenticate(self):
        print("Authenticating to Google Cloud using OAuth tokens...")


# Azure Factory (Concrete Creator)
class AzureFactory(CloudProviderFactory):
    def create_vm(self) -> VM:
        return AzureVM()

    def authenticate(self):
        print("Authenticating to Azure using Active Directory...")


# Usage
def deploy_vm(factory: CloudProviderFactory, cpu: int, memory: int, disk_size: int):
    factory.provision_vm(cpu, memory, disk_size)

# Test the Factory Method with different providers
deploy_vm(AWSFactory(), 4, 16, 100)        # Authenticates and provisions an AWS VM
deploy_vm(GoogleCloudFactory(), 2, 8, 50)  # Authenticates and provisions a Google Cloud VM
deploy_vm(AzureFactory(), 8, 32, 200)      # Authenticates and provisions an Azure VM
Authenticating to AWS using IAM credentials...
Provisioning AWS VM with 4 CPUs, 16 GB RAM, 100 GB Disk
Authenticating to Google Cloud using OAuth tokens...
Provisioning Google Cloud VM with 2 CPUs, 8 GB RAM, 50 GB Disk
Authenticating to Azure using Active Directory...
Provisioning Azure VM with 8 CPUs, 32 GB RAM, 200 GB Disk

Key Benefits of the Factory Method Pattern Here:

  1. Encapsulation of Complex Logic: The factories (AWSFactory, GoogleCloudFactory, AzureFactory) encapsulate the complex logic of authentication and VM creation. The client (deploy_vm) doesn't need to worry about which cloud provider it’s dealing with.

  2. Decoupling from Specific Implementations: The deploy_vm function relies on the CloudProviderFactory interface and doesn’t care about the specific type of VM being created. This makes the code more flexible and easier to maintain.

  3. Extensibility: If a new cloud provider (e.g., DigitalOcean) needs to be supported, you can simply create a new DigitalOceanFactory class without modifying any existing code. This adheres to the Open-Closed Principle.

  4. Real-World Relevance: This example mimics real-world systems where complex setup and authentication processes are handled differently for different cloud providers. Each cloud provider requires specific credentials, configurations, and logic for creating virtual machines.

Example: Document Generator Factory (Different File Formats)

# Document class (Product)
class Document:
    def export(self, content: str):
        raise NotImplementedError

# PDF Document class (Concrete Product)
class PDFDocument(Document):
    def export(self, content: str):
        print(f"Exporting content to PDF: {content}")

# Word Document class (Concrete Product)
class WordDocument(Document):
    def export(self, content: str):
        print(f"Exporting content to Word: {content}")

# HTML Document class (Concrete Product)
class HTMLDocument(Document):
    def export(self, content: str):
        print(f"Exporting content to HTML: {content}")

# Abstract Document Factory (Creator)
class DocumentFactory:
    def create_document(self) -> Document:
        raise NotImplementedError

    def prepare_document(self, content: str):
        document = self.create_document()
        document.export(content)

# PDF Document Factory (Concrete Creator)
class PDFDocumentFactory(DocumentFactory):
    def create_document(self) -> Document:
        return PDFDocument()

# Word Document Factory (Concrete Creator)
class WordDocumentFactory(DocumentFactory):
    def create_document(self) -> Document:
        return WordDocument()

# HTML Document Factory (Concrete Creator)
class HTMLDocumentFactory(DocumentFactory):
    def create_document(self) -> Document:
        return HTMLDocument()

# Usage
def generate_report(factory: DocumentFactory, content: str):
    factory.prepare_document(content)

# Test the Factory Method
generate_report(PDFDocumentFactory(), "This is the PDF content")  # Exports to PDF
generate_report(WordDocumentFactory(), "This is the Word content")  # Exports to Word
generate_report(HTMLDocumentFactory(), "This is the HTML content")  # Exports to HTML
Exporting content to PDF: This is the PDF content
Exporting content to Word: This is the Word content
Exporting content to HTML: This is the HTML content

Excercise: Shapes

Implement Drawing.process_line, so that you can load different shapes from a file.

Initial Code

from dataclasses import dataclass

@dataclass
class Circle:
    x: float
    y: float
    r: float

@dataclass
class Rectangle:
    x: float
    y: float
    w: float
    h: float

@dataclass
class Drawing:
    _shapes: list

    @classmethod
    def from_string(cls, string):
        shapes = []
        for line in string.split('\n'):
            line = line.strip()
            if not line:
                continue
            shape = cls.process_line(line)
            shapes.append(shape)
        return cls(shapes)

    @classmethod
    def process_line(cls, line):
        raise NotImplementedError

Test

raw_shapes = '''
Circle 15 10 14
Rectangle 30 30 100 150
Circle 40 20 5
Square 30 100 20
'''
expected_shapes = [
    Circle(15, 10, 14),
    Rectangle(30, 30, 100, 150),
    Circle(40, 20, 5),
    Rectangle(30, 100, 20, 20),
]

drawing = Drawing.from_string(raw_shapes)
assert drawing == Drawing(expected_shapes), drawing

Hint

shape_factory = {
    'Circle': Circle,
    # pozostałe kształty
}

Bad Solution

### Solution without Factory Method

from dataclasses import dataclass

@dataclass
class Circle:
    x: float
    y: float
    r: float

@dataclass
class Rectangle:
    x: float
    y: float
    w: float
    h: float

@dataclass
class Drawing:
    _shapes: list

    @classmethod
    def from_string(cls, string):
        shapes = []
        for line in string.split('\n'):
            line = line.strip()
            if not line:
                continue
            shape = cls.process_line(line)
            shapes.append(shape)
        return cls(shapes)

    @classmethod
    def process_line(cls, line):
        tokens = line.split()
        shape_name = tokens[0]
        parameters = tokens[1:]
        # shape_name, *parameters = line.split()  # Python 3
        parameters = map(int, parameters)
        if shape_name == 'Circle':
            shape = Circle(*parameters)
        elif shape_name == 'Rectangle':
            shape = Rectangle(*parameters)
        elif shape_name == 'Square':
            x, y, a = parameters
            shape = Rectangle(x, y, a, a)
        else:
            raise TypeError
        return shape

raw_shapes = '''
Circle 15 10 14
Rectangle 30 30 100 150
Circle 40 20 5
Square 30 100 20
'''
expected_shapes = [
    Circle(15, 10, 14),
    Rectangle(30, 30, 100, 150),
    Circle(40, 20, 5),
    Rectangle(30, 100, 20, 20),
]

drawing = Drawing.from_string(raw_shapes)
assert drawing == Drawing(expected_shapes), drawing

Better Solution

from dataclasses import dataclass

@dataclass
class Circle:
    x: float
    y: float
    r: float

@dataclass
class Rectangle:
    x: float
    y: float
    w: float
    h: float

def create_rectangle_from_square(x, y, a):
    return Rectangle(x, y, a, a)

shape_factory = {
    'Circle': Circle,
    'Rectangle': Rectangle,
    'Square': create_rectangle_from_square,
}

@dataclass
class Drawing:
    _shapes: list

    @classmethod
    def from_string(cls, string):
        shapes = []
        for line in string.split('\n'):
            line = line.strip()
            if not line:
                continue
            shape = cls.process_line(line)
            shapes.append(shape)
        return cls(shapes)

    @classmethod
    def process_line(cls, line):
        tokens = line.split()
        shape_name = tokens[0]
        parameters = tokens[1:]
        # shape_name, *parameters = line.split()  # Python 3
        parameters = map(int, parameters)
        try:
            shape_creator = shape_factory[shape_name]
        except KeyError:
            raise ValueError
        else:
            shape = shape_creator(*parameters)
            return shape

raw_shapes = '''
Circle 15 10 14
Rectangle 30 30 100 150
Circle 40 20 5
Square 30 100 20
'''
expected_shapes = [
    Circle(15, 10, 14),
    Rectangle(30, 30, 100, 150),
    Circle(40, 20, 5),
    Rectangle(30, 100, 20, 20),
]

drawing = Drawing.from_string(raw_shapes)
assert drawing == Drawing(expected_shapes), drawing

Good Solution

from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class Circle:
    x: float
    y: float
    r: float

@dataclass
class Rectangle:
    x: float
    y: float
    w: float
    h: float

# Abstract Factory Interface
class ShapeFactory(ABC):
    @abstractmethod
    def create_shape(self, *args):
        pass

# Concrete Factory for Circle
class CircleFactory(ShapeFactory):
    def create_shape(self, x, y, r):
        return Circle(x, y, r)

# Concrete Factory for Rectangle
class RectangleFactory(ShapeFactory):
    def create_shape(self, x, y, w, h):
        return Rectangle(x, y, w, h)

# Concrete Factory for Square (using Rectangle)
class SquareFactory(ShapeFactory):
    def create_shape(self, x, y, a):
        return Rectangle(x, y, a, a)

# Shape Factory Mapping
shape_factory = {
    'Circle': CircleFactory(),
    'Rectangle': RectangleFactory(),
    'Square': SquareFactory(),
}

@dataclass
class Drawing:
    _shapes: list

    @classmethod
    def from_string(cls, string):
        shapes = []
        for line in string.split('\n'):
            line = line.strip()
            if not line:
                continue
            shape = cls.process_line(line)
            shapes.append(shape)
        return cls(shapes)

    @classmethod
    def process_line(cls, line):
        tokens = line.split()
        shape_name = tokens[0]
        parameters = tokens[1:]
        parameters = map(int, parameters)
        try:
            shape_creator = shape_factory[shape_name]
        except KeyError:
            raise ValueError(f"Unknown shape: {shape_name}")
        else:
            shape = shape_creator.create_shape(*parameters)
            return shape

# Test with raw shape data
raw_shapes = '''
Circle 15 10 14
Rectangle 30 30 100 150
Circle 40 20 5
Square 30 100 20
'''

expected_shapes = [
    Circle(15, 10, 14),
    Rectangle(30, 30, 100, 150),
    Circle(40, 20, 5),
    Rectangle(30, 100, 20, 20),  # Square is represented as a Rectangle with equal sides
]

# Create a Drawing from the raw string
drawing = Drawing.from_string(raw_shapes)

# Verify the result
assert drawing == Drawing(expected_shapes), drawing

Structural Patterns

Structural design patterns are patterns that focus on how to compose objects and classes into larger structures while keeping them flexible and efficient. These patterns are particularly useful when dealing with complex systems that involve many interacting objects or when adapting and modifying existing systems. They help define clear, maintainable relationships between classes and objects.

Why Use Structural Patterns?

  • Simplify complex structures: Structural patterns help you build efficient and scalable object structures.
  • Promote reusability: These patterns allow you to reuse components in a flexible manner, often adapting them to work in new contexts.
  • Decouple components: They often promote loose coupling between components, making the system easier to maintain and extend.

Some of the key structural patterns include:

  • Adapter
  • Bridge
  • Composite
  • Decorator
  • Facade
  • Flyweight
  • Proxy

Adapter Pattern

The Adapter Pattern is a structural design pattern that allows objects with incompatible interfaces to work together. In simple terms, it acts as a bridge between two incompatible interfaces, enabling existing classes to interact without modifying their source code.

What Problem Does the Adapter Pattern Solve?

Imagine you have a system that uses one interface, but you're given a class that implements a completely different interface. You can't or don't want to change either interface, but you still need them to work together. The Adapter Pattern allows you to adapt the incompatible class to work within your system by creating a new class (the adapter) that converts the interface of the class into one that your system can use.

Use Cases for the Adapter Pattern:

  1. Hardware Adapters:

    • A real-world analogy is a VGA-to-HDMI adapter. You have a monitor that only accepts HDMI input, but your computer has a VGA port. The adapter allows you to connect these incompatible devices.
  2. Data Format Conversion:

    • Suppose you’re working with two systems that use different data formats (e.g., CSV vs. JSON). An adapter can convert one format to the other, allowing the systems to communicate.
  3. SQL Database Backends:

    • If you need to support a new database backend (e.g., PostgreSQL instead of MySQL), you can create an adapter that translates the SQL commands used by the old backend into commands supported by the new one.
  4. Legacy Code Integration:

    • When integrating old, legacy systems into modern architectures, the Adapter Pattern is perfect for wrapping old interfaces and adapting them to work with newer APIs.

Example: Using the Adapter Pattern with io.StringIO

In Python, the io.StringIO class is a great example of an adapter. It adapts a string to behave like a file object, allowing you to use file-like operations such as read(), write(), and seek() on a string. This is particularly useful when you have functions that expect file-like objects, but you want to provide them with a string.

In this example, we will adapt a string to be used as if it were a file using io.StringIO, which acts as an adapter.

Problem

You have a function that works with file objects, but you want to provide it with a string instead. Instead of rewriting the function, you can use io.StringIO to adapt the string so that it behaves like a file.

import io

# A function that expects a file object
def process_file(file_obj):
    print("Reading first 4 characters:", file_obj.read(4))
    print("Reading next 4 characters:", file_obj.read(4))
    file_obj.seek(0)  # Go back to the beginning of the file
    print("Reading first 4 characters again:", file_obj.read(4))

# You have a string, but the function expects a file-like object
string_data = "This is a string that behaves like a file"

# Using io.StringIO as an adapter to make the string behave like a file object
file_like_string = io.StringIO(string_data)


# Process the string as if it were a file
process_file(file_like_string)
Reading first 4 characters: This
Reading next 4 characters:  is 
Reading first 4 characters again: This

Example: Hardware Adapter

In the real world, a VGA-to-HDMI adapter allows you to connect an old VGA monitor to a modern HDMI port. The adapter converts VGA signals into HDMI, making them compatible.

Let’s represent this concept in code:

# Old VGA interface (incompatible)
class VGAConnector:
    def connect_vga(self):
        return "VGA connected"

# New HDMI interface (desired)
class HDMIConnector:
    def connect_hdmi(self):
        return "HDMI connected"

# Adapter to make VGA work with HDMI
class HDMIToVGAAdapter(HDMIConnector):
    def __init__(self, vga_device: VGAConnector):
        self.vga_device = vga_device

    def connect_hdmi(self):
        # Adapts the VGA output to HDMI
        return self.vga_device.connect_vga()

# Usage
vga_monitor = VGAConnector()
adapter = HDMIToVGAAdapter(vga_monitor)

# Now, we can use the VGA monitor with an HDMI connection
print(adapter.connect_hdmi())  # Output: VGA connected
VGA connected

Example: Adapting Data Formats (CSV to JSON)

In this example, you have a system that processes data in CSV format, but you now need to support an external system that works with JSON. Instead of rewriting the entire data processing pipeline, you can create an adapter to convert CSV to JSON.

import json
import csv
from io import StringIO

# The existing system that expects data in JSON format
class JSONProcessor:
    def process(self, data):
        return f"Processing JSON data: {data}"

# The new data source that provides data in CSV format
class CSVSource:
    def get_csv(self):
        return "name,age\nAlice,30\nBob,25"

# Adapter to convert CSV to JSON
class CSVToJSONAdapter:
    def __init__(self, csv_source: CSVSource):
        self.csv_source = csv_source

    def convert_to_json(self):
        csv_data = self.csv_source.get_csv()
        f = StringIO(csv_data)
        reader = csv.DictReader(f)
        json_data = json.dumps([row for row in reader])
        return json_data


# Usage
csv_source = CSVSource()
adapter = CSVToJSONAdapter(csv_source)
json_processor = JSONProcessor()
json_data = adapter.convert_to_json()

print(json_processor.process(json_data))  # Output: Processing JSON data: [{"name": "Alice", "age": "30"}, {"name": "Bob", "age": "25"}]
Processing JSON data: [{"name": "Alice", "age": "30"}, {"name": "Bob", "age": "25"}]
from google.compute import instance as gcp_instance

class VM:
    vm_model: dict
    def provision(self):
        ...


class GCPVM(VM):
    def provision(self):
        self.gcp_instance.create(vm_model)

Exercise: Implement Stream2String Class

Implement the Stream2String class that accepts an opened stream and has the interface of a string. For simplicity, it's enough if you implement the __getitem__, __len__, and __repr__ methods. The catch is that you should read everything from the stream and cache it on the first demand, that is first time that any of the above methods are used. Don't read from the stream in __init__!

Initial Code

class Stream2String:
    ...

Testing

from io import StringIO
stream = StringIO('asdf qwer')
stringish = Stream2String(stream)

assert stream.tell() == 0  # Make sure that Stream2String.__init__ doesn't read from the stream
assert stringish[0] == 'a'  # string.__getitem__(0)
assert stream.tell() == 9
print(repr(stringish))
assert repr(stringish) == 'asdf qwer'  # string.__repr__()
assert len(stringish) == 9  # string.__len__()

stream = StringIO('asdf qwer')
stringish = Stream2String(stream)
assert repr(stringish) == 'asdf qwer'

Solution

class Stream2String:
    def __init__(self, stream):
        self._stream = stream
        self._content = None

    @property
    def content(self):
        if self._content is None:
            self._content = self._stream.read()
        return self._content

    def __getitem__(self, index):
        return self.content[index]

    def __len__(self):
        return len(self.content)

    def __repr__(self):
        return self.content


### Expected Behaviour

from io import StringIO
stream = StringIO('asdf qwer')
stringish = Stream2String(stream)

assert stream.tell() == 0  # Make sure that Stream2String.__init__ doesn't read from the stream
assert stringish[0] == 'a'  # string.__getitem__(0)
assert stream.tell() == 9
print(repr(stringish))
assert repr(stringish) == 'asdf qwer'  # string.__repr__()
assert len(stringish) == 9  # string.__len__()

stream = StringIO('asdf qwer')
stringish = Stream2String(stream)
assert repr(stringish) == 'asdf qwer'
asdf qwer

Proxy Pattern

The Proxy Pattern is a structural design pattern that provides a surrogate or placeholder for another object. In simple terms, a proxy is an object that controls access to another object, usually by intercepting method calls or requests before passing them on to the real object.

Why Use the Proxy Pattern?

There are several reasons to use the Proxy Pattern:

  1. Control Access: A proxy can control access to an object, preventing direct access to sensitive resources.
  2. Lazy Initialization: A proxy can delay the creation of expensive objects until they are actually needed.
  3. Logging or Monitoring: A proxy can be used to log interactions with the object, helping to track usage or debug issues.
  4. Security: A proxy can ensure that only authorized users have access to certain operations.
  5. Remote Access: A proxy can act as a local representative for an object that resides in a different location (e.g., a remote server).

Use Cases:

  • Virtual Proxy: To delay the creation of resource-heavy objects until they are actually needed.
  • Remote Proxy: To act as a local object that represents a resource located in a different system (e.g., a web service or a database).
  • Access Proxy: To control access to sensitive resources, ensuring security or limiting who can perform certain actions.
  • Logging/Monitoring Proxy: To intercept method calls and log or monitor them for debugging or audit purposes.

Example: Lazy Initialization (Virtual Proxy)

Imagine you have a resource-heavy object like a large image file. You don’t want to load the image until it's actually needed (e.g., when the user requests to view it). The proxy delays the creation of this object until it’s really necessary.

import time
# The Real Object (expensive to load)
class RealImage:
    def __init__(self, filename):
        self.filename = filename
        self.load_image()

    def load_image(self):
        print(f"Loading image from {self.filename}...")
        time.sleep(2)  # Simulate time-consuming image loading
        self.data = "Image data"

    def display(self):
        print(f"Displaying {self.filename}: {self.data}")


# The Proxy Object
class ImageProxy:
    def __init__(self, filename):
        self.filename = filename
        self._real_image = None

    def display(self):
        if self._real_image is None:
            self._real_image = RealImage(self.filename)  # Load only when needed
        self._real_image.display()

# Usage
print("Creating the proxy...")
image = ImageProxy("large_image.jpg")

print("Image not loaded yet.")
print("Displaying the image for the first time:")
image.display()  # Image is loaded here for the first time

print("\nDisplaying the image again:")
image.display()  # Image is already loaded, no delay this time

Example: Protection Proxy (Access Control)

In this example, we’ll use a proxy to control access to a sensitive resource, such as a bank account. Only authorized users can perform transactions, and the proxy ensures that users without sufficient privileges are denied access.

# Real Bank Account (Protected Resource)
class BankAccount:
    def __init__(self, owner):
        self.owner = owner
        self.balance = 0

    def deposit(self, amount):
        self.balance += amount
        print(f"{amount} deposited. New balance: {self.balance}")

    def withdraw(self, amount):
        if self.balance >= amount:
            self.balance -= amount
            print(f"{amount} withdrawn. New balance: {self.balance}")
        else:
            print(f"Insufficient funds. Current balance: {self.balance}")


# Proxy for controlling access to the BankAccount
class BankAccountProxy:
    def __init__(self, bank_account, user):
        self.bank_account = bank_account
        self.user = user

    def deposit(self, amount):
        if self._is_authorized():
            self.bank_account.deposit(amount)
        else:
            print(f"Access denied for {self.user}")

    def withdraw(self, amount):
        if self._is_authorized():
            self.bank_account.withdraw(amount)
        else:
            print(f"Access denied for {self.user}")

    def _is_authorized(self):
        return self.user == self.bank_account.owner


# Usage
account = BankAccount("Alice")

# Authorized user
authorized_user = BankAccountProxy(account, "Alice")
authorized_user.deposit(100)  # Output: 100 deposited. New balance: 100
authorized_user.withdraw(50)  # Output: 50 withdrawn. New balance: 50

# Unauthorized user
unauthorized_user = BankAccountProxy(account, "Bob")
unauthorized_user.deposit(100)  # Output: Access denied for Bob
unauthorized_user.withdraw(50)  # Output: Access denied for Bob
100 deposited. New balance: 100
50 withdrawn. New balance: 50
Access denied for Bob
Access denied for Bob

Exercise: Implement LazyStream Class

Implement the LazyStream class that behaves like a file stream, except that it creates the stream as late as possible, that is, on the first call to read (or any other method). Don't create the stream in __init__! For simplicity, implement only the read, tell, and seek methods.

★ Use __getattr__ to delegate all method calls to the stream. This way you don't have to repeat read, tell, and seek methods.

Initial Code

class LazyStream:
    def __init__(self, file_name):
        ...

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, exc_traceback):
        self.close()

    ...

Tests

s = LazyStream('test.txt')

s.read()
# is equivalent to:
m = s.read  # s.__getattr__('read')
m()
### Expected Behaviour

from pytest import raises

with raises(FileNotFoundError):
    s = open('non-existent')

s = LazyStream('non-existent')  # no error

with raises(FileNotFoundError):
    s.read()
s = LazyStream('test.txt')
print(s.read())
s.seek(0)
print(s.read())
s.close()

with LazyStream('test.txt') as s:  # s = LazyStream('test.txt').__enter__()
    print(s.read())
    s.seek(0)
    print(s.read())

Solution

class LazyStream:
    def __init__(self, file_name):
        self._file_name = file_name
        self._stream = None

    @property
    def stream(self):
        if self._stream is None:
            self._stream = open(self._file_name)
        return self._stream

    def tell(self, *args, **kwargs):
        return self.stream.tell(*args, **kwargs)

    def read(self, *args, **kwargs):
        return self.stream.read(*args, **kwargs)

    def seek(self, *args, **kwargs):
        return self.stream.seek(*args, **kwargs)

    def __getattr__(self, attr):
        return getattr(self.stream, attr)

    def close(self):
        if self._stream is not None:
            self._stream.close()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, exc_traceback):
        self.close()
from pytest import raises

with raises(FileNotFoundError):
    s = open('non-existent')

s = LazyStream('non-existent')  # no error

with raises(FileNotFoundError):
    s.read()
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[76], line 1
----> 1 from pytest import raises
      3 with raises(FileNotFoundError):
      4     s = open('non-existent')

File ~/python_training/design_patterns/.venv/lib/python3.12/site-packages/pytest/__init__.py:21
     19 from _pytest.config.argparsing import OptionGroup
     20 from _pytest.config.argparsing import Parser
---> 21 from _pytest.debugging import pytestPDB as __pytestPDB
     22 from _pytest.doctest import DoctestItem
     23 from _pytest.fixtures import fixture

File ~/python_training/design_patterns/.venv/lib/python3.12/site-packages/_pytest/debugging.py:17
     15 from typing import TYPE_CHECKING
     16 from typing import Union
---> 17 import unittest
     19 from _pytest import outcomes
     20 from _pytest._code import ExceptionInfo

ModuleNotFoundError: No module named 'unittest'

Decorator Pattern

The Decorator Pattern is a design pattern used to add new functionality to an existing object without altering its structure. This is achieved by wrapping the original object with a new "decorator" object that can enhance or modify its behavior. The decorator pattern is particularly useful when you want to add responsibilities to individual objects dynamically and transparently, without affecting other objects.

Example Use Cases

  1. Extending Functionality: Suppose you have a basic logging system, and you want to add additional features such as timestamping and log level filtering without modifying the existing logging code.
  2. User Interface Components: In a graphical user interface, you might have a basic window object. Using the decorator pattern, you can add features like scrollbars, borders, or shadows to the window without changing its core functionality.
  3. Data Processing Pipelines: When processing data, you might want to add various processing steps (e.g., filtering, transforming) to the data stream. The decorator pattern allows you to chain these processing steps together in a flexible and reusable way.

Example: Basic Logging with Decorators

Let's start with a simple example of a logging system where we want to add a timestamp to each log message.

import datetime

class Logger:
    def log(self, message):
        print(message)

class TimestampLoggerDecorator:
    def __init__(self, logger):
        self._logger = logger

    def log(self, message):
        timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        self._logger.log(f"{timestamp} - {message}")


basic_logger = Logger()
timestamped_logger = TimestampLoggerDecorator(basic_logger)
basic_logger.log("This is a basic log message.")
timestamped_logger.log("This is a timestamped log message.")
This is a basic log message.
2024-10-08 10:42:18 - This is a timestamped log message.

Example: User Interface Components

class Window:
    def render(self):
        return "Rendering window"

class BorderDecorator:
    def __init__(self, window):
        self._window = window

    def render(self):
        return f"{self._window.render()} with border"

class ScrollbarDecorator:
    def __init__(self, window):
        self._window = window

    def render(self):
        return f"{self._window.render()} with scrollbar"
# Usage
basic_window = Window()
bordered_window = BorderDecorator(basic_window)
scrollbar_bordered_window = ScrollbarDecorator(bordered_window)
print(basic_window.render())  # Output: Rendering window
print(bordered_window.render())  # Output: Rendering window with border
print(scrollbar_bordered_window.render())  # Output: Rendering window with border with scrollbar
Rendering window
Rendering window with border
Rendering window with border with scrollbar

Exercise✏️ Starbucks

There are some basic drinks: Espresso and Cappuccino classes. Every drink has its own algorithm to be prepared (prepare method). For simplicity, it prints "Making a perfect espresso" or "Making cappuccino". Every drink has methods to get the total price (get_total_price) and the description of the drink (get_description).

You want to let people add a whipped cream to both Espresso and Cappuccino. To prepare such a drink with whipped cream you need to prepare the original drink, and then, add the whipped cream, which is to print "Adding a whipped cream" after preparing the original drink. The price of such a drink is the price of the original drink plus an extra 2.5. The description should be "Espresso + Whipped Cream" or "Cappuccino + Whipped Cream".

You want to add more additions, i.e., Whisky for an extra 10.0. When preparing, print "Pouring 50cl of whisky". The description should be "original_drink + Whisky".

Initial Code

class Drink:
    def prepare(self) -> None:
        raise NotImplementedError

    def get_total_price(self) -> float:
        raise NotImplementedError

    def get_description(self) -> str:
        raise NotImplementedError

class BaseDrink(Drink):
    def __init__(self):
        super().__init__()

class Espresso(BaseDrink):
    def get_total_price(self):
        return 4.0

    def get_description(self):
        return "Espresso"

    def prepare(self):
        print("Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar")

class Cappuccino(BaseDrink):
    def get_total_price(self):
        return 5.0

    def get_description(self):
        return "Cappuccino"

    def prepare(self):
        print("Making cappuccino (an espresso combined with a perfect milk foam)")

drink = Espresso()
drink.prepare()
print(f"Price: {drink.get_total_price()}")
print(f"Description: {drink.get_description()}")

# How are you going to let people create an espresso with whipped cream?

Test

coffee = Whipped(Whipped(Espresso()))
assert coffee.get_total_price() == 9.0  # 4.0 + 2.5 + 2.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whipped Cream"
coffee.prepare()

# Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar
# Adding whipped cream
# Adding whipped cream
coffee = Whisky(Whipped(Espresso()))
assert coffee.get_total_price() == 16.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whisky"
coffee.prepare()

# Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar
# Adding whipped cream
# Pouring 50cl of whisky

Solution

class Drink:
    def prepare(self) -> None:
        raise NotImplementedError

    def get_total_price(self) -> float:
        raise NotImplementedError

    def get_description(self) -> str:
        raise NotImplementedError

class BaseDrink(Drink):
    def __init__(self):
        super().__init__()

class Espresso(BaseDrink):
    def get_total_price(self):
        return 4.0

    def get_description(self):
        return "Espresso"

    def prepare(self):
        print("Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar")

class Cappuccino(BaseDrink):
    def get_total_price(self):
        return 5.0

    def get_description(self):
        return "Cappuccino"

    def prepare(self):
        print("Making cappuccino (an espresso combined with a perfect milk foam)")


class Addition(Drink): # keep Drink interface
    price: float
    name: str

    def __init__(self, drink: Drink):
        self._drink = drink

    def get_total_price(self):
        return self._drink.get_total_price() + self.price

    def get_description(self):
        return f"{self._drink.get_description()} + {self.name}"


class Whipped(Addition):
    price = 2.5
    name = 'Whipped Cream'


    def prepare(self):
        self._drink.prepare()
        print("Adding whipped cream")


class Whisky(Addition):
    price = 10.0
    name = 'Whisky'

    def prepare(self):
        self._drink.prepare()
        print("Pouring 50cl of whisky")
coffee = Whipped(Whipped(Espresso()))
assert coffee.get_total_price() == 9.0  # 4.0 + 2.5 + 2.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whipped Cream"
coffee.prepare()
Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar
Adding whipped cream
Adding whipped cream
coffee = Whisky(Whipped(Espresso()))
assert coffee.get_total_price() == 16.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whisky"
coffee.prepare()
Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar
Adding whipped cream
Pouring 50cl of whisky

Exercise✏️ Builder For Starbucks

Implement a Builder for a Starbucks.

Initial Code

from typing import Type

class CoffeeBuilder:
    ...

Test

coffee = (CoffeeBuilder()
    .add(Whipped)
    .set_base(Espresso)
    .add(Whisky)
    .get_coffee())

assert coffee.get_total_price() == 16.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whisky"
coffee.prepare()

# Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar
# Adding whipped cream
# Pouring 50cl of whisky
from typing import Type

class CoffeeBuilder:
    def __init__(self):
        self._base = None
        self._additions = []

    def add(self, addition: Type[Addition]): # pass a definition of a class not an instance
        self._additions.append(addition)
        return self

    def set_base(self, base: Type[BaseDrink]): # pass a definition of a class not an instance
        self._base = base
        return self

    def get_coffee(self) -> Drink:
        if self._base is None:
            raise ValueError("Base is not defined")
        coffee = self._base()
        for addition in self._additions:
            coffee = addition(coffee)
        return coffee
### Expected Behaviour

coffee = (CoffeeBuilder()
    .add(Whipped)
    .set_base(Espresso)
    .add(Whisky)
    .get_coffee())

assert coffee.get_total_price() == 16.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whisky"
coffee.prepare()
Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar
Adding whipped cream
Pouring 50cl of whisky
### Example how the builder is useful in event-driven application
def on_init():
    global builder
    builder = CoffeeBuilder()

def on_set_espresso():
    builder.set_base(Espresso)

def on_set_cappuccino():
    builder.set_base(Cappuccino)

def on_add_whipped_cream():
    builder.add(Whipped)

def on_add_whisky():
    builder.add(Whisky)

def on_make_drink():
    global builder
    coffee = builder.get_coffee()
    coffee.prepare()
    builder = CoffeeBuilder()

Facade Pattern

The Facade Pattern is a structural design pattern that provides a simplified interface to a complex subsystem. It aims to make the subsystem easier to use by hiding its complexity and exposing only the necessary functionalities. The Facade Pattern is particularly useful when dealing with large systems that have many interdependent classes, as it allows clients to interact with the system through a single, unified interface.

Example: Library Management System

class Book:
    def __init__(self, title):
        self.title = title

class Member:
    def __init__(self, name):
        self.name = name

class Transaction:
    def __init__(self, book, member):
        self.book = book
        self.member = member


class LibraryFacade:
    def __init__(self):
        self.books = []
        self.members = []
        self.transactions = []

    def add_book(self, title):
        book = Book(title)
        self.books.append(book)
        print(f"Book '{title}' added to the library.")

    def add_member(self, name):
        member = Member(name)
        self.members.append(member)
        print(f"Member '{name}' added to the library.")

    def borrow_book(self, title, member_name):
        book = next((b for b in self.books if b.title == title), None)
        member = next((m for m in self.members if m.name == member_name), None)
        if book and member:
            transaction = Transaction(book, member)
            self.transactions.append(transaction)
            print(f"Book '{title}' borrowed by '{member_name}'.")
        else:
            print("Book or member not found.")
# Usage
library = LibraryFacade()
library.add_book("1984")
library.add_member("Alice")
library.borrow_book("1984", "Alice")
Book '1984' added to the library.
Member 'Alice' added to the library.
Book '1984' borrowed by 'Alice'.

Example: Media Conversion

class AudioConverter:
    def convert_to_mp3(self, file):
        print(f"Converting {file} to MP3 format.")

class VideoConverter:
    def convert_to_mp4(self, file):
        print(f"Converting {file} to MP4 format.")

class MediaConverterFacade:
    def __init__(self):
        self.audio_converter = AudioConverter()
        self.video_converter = VideoConverter()

    def convert_audio(self, file, format):
        if format == "mp3":
            self.audio_converter.convert_to_mp3(file)
        else:
            print("Unsupported audio format.")

    def convert_video(self, file, format):
        if format == "mp4":
            self.video_converter.convert_to_mp4(file)
        else:
            print("Unsupported video format.")

# Usage
media_converter = MediaConverterFacade()
media_converter.convert_audio("song.wav", "mp3")
media_converter.convert_video("movie.avi", "mp4")
Converting song.wav to MP3 format.
Converting movie.avi to MP4 format.

Composite Pattern in Structural Patterns

The Composite Pattern is a structural design pattern that allows you to compose objects into tree structures to represent part-whole hierarchies. This pattern lets clients treat individual objects and compositions of objects uniformly. It is particularly useful when you need to work with tree-like structures, such as file systems, organizational hierarchies, or graphical user interfaces.

Example Use Cases

  1. File System: A file system where directories can contain files and other directories. The Composite Pattern allows you to treat files and directories uniformly.
  2. Organization Hierarchy: An organization where employees can be managers or regular employees. Managers can have subordinates, who can be either other managers or regular employees.
  3. Macros: Both single instructions and entire macros should have a .run() method.
  4. JSON, DOM (HTML/XML): Any recursive tree-like structure where both leaves and nodes have the same interface.
  5. Graphical User Interface: Components like buttons, panels, and windows can be composed into complex UI elements.

Example: File System

In this example, we'll create a file system where directories can contain files and other directories. We'll add more functions to demonstrate the capabilities of the Composite Pattern.

from abc import ABC, abstractmethod

class FileSystemComponent(ABC):
    @abstractmethod
    def show_details(self):
        pass

    @abstractmethod
    def rename(self, new_name):
        pass

    @abstractmethod
    def move(self, new_location):
        pass

class File(FileSystemComponent):
    def __init__(self, name, size):
        self.name = name
        self.size = size

    def show_details(self):
        print(f"File: {self.name}, Size: {self.size}KB")

    def rename(self, new_name):
        self.name = new_name
        print(f"File renamed to: {self.name}")

    def move(self, new_location):
        print(f"File {self.name} moved to {new_location}")

class Directory(FileSystemComponent):
    def __init__(self, name):
        self.name = name
        self.children = []

    def add(self, component):
        self.children.append(component)

    def remove(self, component):
        self.children.remove(component)

    def show_details(self):
        print(f"Directory: {self.name}")
        for child in self.children:
            child.show_details()

    def rename(self, new_name):
        self.name = new_name
        print(f"Directory renamed to: {self.name}")

    def move(self, new_location):
        print(f"Directory {self.name} moved to {new_location}")
        for child in self.children:
            child.move(new_location)
# Usage
root = Directory("root")
home = Directory("home")
user = Directory("user")
file1 = File("file1.txt", 100)
file2 = File("file2.txt", 200)
file3 = File("file3.txt", 300)

root.add(home)
home.add(user)
user.add(file1)
user.add(file2)
root.add(file3)

root.show_details()
root.rename("new_root")
root.move("/new_location")
Directory: root
Directory: home
Directory: user
File: file1.txt, Size: 100KB
File: file2.txt, Size: 200KB
File: file3.txt, Size: 300KB
Directory renamed to: new_root
Directory new_root moved to /new_location
Directory home moved to /new_location
Directory user moved to /new_location
File file1.txt moved to /new_location
File file2.txt moved to /new_location
File file3.txt moved to /new_location

Example: Graphical User Interface

class GUIComponent(ABC):
    @abstractmethod
    def render(self):
        pass

class ComposableComponent(ABC):

    @abstractmethod
    def add(self, component):
        pass

    @abstractmethod
    def remove(self, component):
        pass

class Button(GUIComponent):
    def __init__(self, name):
        self.name = name

    def render(self):
        print(f"Rendering Button: {self.name}")

class Panel(GUIComponent, ComposableComponent):
    def __init__(self, name):
        self.name = name
        self.children = []

    def render(self):
        print(f"Rendering Panel: {self.name}")
        for child in self.children:
            child.render()

    def add(self, component):
        self.children.append(component)

    def remove(self, component):
        self.children.remove(component)
# Usage
main_window = Panel("Main Window")
sidebar = Panel("Sidebar")
button1 = Button("Button 1")
button2 = Button("Button 2")
button3 = Button("Button 3")

main_window.add(sidebar)
sidebar.add(button1)
sidebar.add(button2)
main_window.add(button3)

main_window.render()
Rendering Panel: Main Window
Rendering Panel: Sidebar
Rendering Button: Button 1
Rendering Button: Button 2
Rendering Button: Button 3
class GUIComponent(ABC):
    @abstractmethod
    def render(self):
        pass

class ComposableComponent(ABC):

    children = []

    @abstractmethod
    def add(self, component):
        pass

    @abstractmethod
    def remove(self, component):
        pass

class Button(GUIComponent):
    def __init__(self, name):
        self.name = name

    def render(self):
        print(f"Rendering Button: {self.name}")

class Panel(GUIComponent):
    def __init__(self, name):
        self.name = name

    def render(self):
        print(f"Rendering Panel: {self.name}")


class ComposablePanel(ComposableComponent):

    def __init__(self, panel: Panel):
        self._panel = panel

    def add(self, component):
        self.children.append(component)

    def remove(self, component):
        self.children.remove(component)

    def render(self):
        for child in self.children:
            child.render()

Flyweight Pattern in Structural Patterns

The Flyweight Pattern is a structural design pattern that allows you to minimize memory usage or computational expenses by sharing as much data as possible with similar objects. It is particularly useful when you need to create a large number of similar objects, and you want to reduce the overhead associated with creating and maintaining these objects. The Flyweight Pattern achieves this by storing shared states in a central location and only storing unique states within the individual objects.

Example Use Cases

  1. Text Editors: In a text editor, characters can be represented using flyweights to avoid storing duplicate font and style information for each character.
  2. Game Development: In a game, you might have many instances of similar objects, such as trees, rocks, or enemies. Using flyweights can reduce memory usage by sharing common data among these objects.
  3. GUI Elements: In a graphical user interface, elements like buttons or icons that appear multiple times can use flyweights to share common properties like color, shape, or size.
  4. Network Connections: When managing a large number of network connections, you can use flyweights to share common configuration settings among connections.

Example: Text Editor

class CharacterFlyweight:
    def __init__(self, font, size, color):
        print('Create new flyweight')
        self.font = font
        self.size = size
        self.color = color

    def render(self, character, position):
        print(f"Rendering '{character}' at {position} with font={self.font}, size={self.size}, color={self.color}")

class CharacterFlyweightFactory:
    def __init__(self):
        self.flyweights = {}

    def get_flyweight(self, font, size, color):
        key = (font, size, color)
        if key not in self.flyweights:
            self.flyweights[key] = CharacterFlyweight(font, size, color)
        return self.flyweights[key]
# Usage
factory = CharacterFlyweightFactory()
position = 0

text = "Hello Flyweight"
for char in text:
    flyweight = factory.get_flyweight("Arial", 12, "black")
    flyweight.render(char, position)
    position += 1
create new flyweight
Rendering 'H' at 0 with font=Arial, size=12, color=black
Rendering 'e' at 1 with font=Arial, size=12, color=black
Rendering 'l' at 2 with font=Arial, size=12, color=black
Rendering 'l' at 3 with font=Arial, size=12, color=black
Rendering 'o' at 4 with font=Arial, size=12, color=black
Rendering ' ' at 5 with font=Arial, size=12, color=black
Rendering 'F' at 6 with font=Arial, size=12, color=black
Rendering 'l' at 7 with font=Arial, size=12, color=black
Rendering 'y' at 8 with font=Arial, size=12, color=black
Rendering 'w' at 9 with font=Arial, size=12, color=black
Rendering 'e' at 10 with font=Arial, size=12, color=black
Rendering 'i' at 11 with font=Arial, size=12, color=black
Rendering 'g' at 12 with font=Arial, size=12, color=black
Rendering 'h' at 13 with font=Arial, size=12, color=black
Rendering 't' at 14 with font=Arial, size=12, color=black

Example: Game Development

In this example, we'll create a game where multiple trees are rendered using the Flyweight Pattern to share common data among tree objects.

class TreeType:
    def __init__(self, name, color, texture):
        self.name = name
        self.color = color
        self.texture = texture

    def render(self, position):
        print(f"Rendering tree '{self.name}' at {position} with color={self.color} and texture={self.texture}")

class TreeFactory:
    def __init__(self):
        self.tree_types = {}

    def get_tree_type(self, name, color, texture):
        key = (name, color, texture)
        if key not in self.tree_types:
            self.tree_types[key] = TreeType(name, color, texture)
        return self.tree_types[key]

class Tree:
    def __init__(self, x, y, tree_type):
        self.x = x
        self.y = y
        self.tree_type = tree_type

    def render(self):
        self.tree_type.render((self.x, self.y))
# Usage
factory = TreeFactory()

forest = []
forest.append(Tree(1, 2, factory.get_tree_type("Oak", "Green", "Rough")))
forest.append(Tree(3, 4, factory.get_tree_type("Pine", "Green", "Smooth")))
forest.append(Tree(5, 6, factory.get_tree_type("Oak", "Green", "Rough")))
forest.append(Tree(7, 8, factory.get_tree_type("Cherry Blossom", "Pink", "Smooth")))

for tree in forest:
    tree.render()
Rendering tree 'Oak' at (1, 2) with color=Green and texture=Rough
Rendering tree 'Pine' at (3, 4) with color=Green and texture=Smooth
Rendering tree 'Oak' at (5, 6) with color=Green and texture=Rough
Rendering tree 'Cherry Blossom' at (7, 8) with color=Pink and texture=Smooth

Example: Python

a = 256
b = 256

print(id(a))
print(id(b))
print(a is b)

print(id(0))
print(id(0))
4512078256
4512078256
True
4512070064
4512070064
a = 'asdfqwer'
b = 'asdfqwer'
print(id(a))
print(id(b))
print(a is b)
4735574256
4735574256
True
a = 'asdfqwer'*512
b = 'asdfqwer'*512
print(id(a))
print(id(b))
print(a is b)
140181105731584
140181105731584
True
c = 'asdf!@#$'*512
d = 'asdf!@#$'*512
print(id(c))
print(id(d))
print(c is d)
140181098507264
140181098486784
False
import sys
e = sys.intern('asdf!@#$'*512)
f = sys.intern('asdf!@#$'*512)
print(id(e))
print(id(f))
print(e is f)
140181349154304
140181349154304
True
%timeit a == b
%timeit c == d
%timeit e == f
24.7 ns ± 2.97 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
242 ns ± 5.16 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
24.4 ns ± 1.09 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

Bridge Pattern in Structural Patterns

The Bridge Pattern is a structural design pattern that decouples an abstraction from its implementation, allowing the two to vary independently. This pattern is particularly useful when you want to avoid a permanent binding between an abstraction and its implementation, enabling you to change the implementation without affecting the client code.

Example Use Cases

  1. Graphic Rendering: You might have different rendering engines (e.g., OpenGL, DirectX) and different shapes (e.g., circle, rectangle). The Bridge Pattern allows you to change the rendering engine without changing the shape classes.
  2. Remote Control Devices: In a home automation system, you might have different devices (e.g., TV, lights) and different remote controls (e.g., basic remote, advanced remote). The Bridge Pattern allows you to mix and match devices and remote controls.
  3. Database Abstraction: You might have different databases (e.g., MySQL, PostgreSQL) and different data access methods. The Bridge Pattern allows you to switch databases without changing the data access code.
  4. Cross-Platform UI: You might have different UI components (e.g., buttons, checkboxes) and different platforms (e.g., Windows, macOS). The Bridge Pattern allows you to vary the UI components and platforms independently.

Example: Graphic Rendering

In this example, we'll create a graphic rendering system where different shapes can be rendered using different rendering engines.

from abc import ABC, abstractmethod

# Implementor
class Renderer(ABC):
    @abstractmethod
    def render_circle(self, radius):
        pass

# Concrete Implementor 1
class OpenGLRenderer(Renderer):
    def render_circle(self, radius):
        print(f"Rendering circle with radius {radius} using OpenGL")

# Concrete Implementor 2
class DirectXRenderer(Renderer):
    def render_circle(self, radius):
        print(f"Rendering circle with radius {radius} using DirectX")

# Abstraction
class Shape(ABC):
    def __init__(self, renderer: Renderer):
        self.renderer = renderer

    @abstractmethod
    def draw(self):
        pass

# Refined Abstraction
class Circle(Shape):
    def __init__(self, renderer: Renderer, radius):
        super().__init__(renderer)
        self.radius = radius

    def draw(self):
        self.renderer.render_circle(self.radius)

# Usage
opengl_renderer = OpenGLRenderer()
directx_renderer = DirectXRenderer()

circle1 = Circle(opengl_renderer, 5)
circle2 = Circle(directx_renderer, 10)

circle1.draw()
circle2.draw()
Rendering circle with radius 5 using OpenGL
Rendering circle with radius 10 using DirectX

Example: Remote Control Devices

In this example, we'll create a home automation system where different devices can be controlled using different remote controls.

from abc import ABC, abstractmethod

# Implementor
class Device(ABC):
    @abstractmethod
    def turn_on(self):
        pass

    @abstractmethod
    def turn_off(self):
        pass

# Concrete Implementor 1
class TV(Device):
    def turn_on(self):
        print("TV is turned on")

    def turn_off(self):
        print("TV is turned off")

# Concrete Implementor 2
class Light(Device):
    def turn_on(self):
        print("Light is turned on")

    def turn_off(self):
        print("Light is turned off")

# Abstraction
class RemoteControl(ABC):
    def __init__(self, device):
        self.device = device

    @abstractmethod
    def turn_on(self):
        pass

    @abstractmethod
    def turn_off(self):
        pass

# Refined Abstraction
class BasicRemoteControl(RemoteControl):
    def turn_on(self):
        self.device.turn_on()

    def turn_off(self):
        self.device.turn_off()

# Refined Abstraction
class AdvancedRemoteControl(RemoteControl):
    def turn_on(self):
        print("Advanced Remote: Turning on device")
        self.device.turn_on()

    def turn_off(self):
        print("Advanced Remote: Turning off device")
        self.device.turn_off()

Exercise: Shapes

  1. We want to be able to draw drawings (Drawing class).
  2. Every drawing is a set of Shapes: Line and Rectangle. This is the first hierarchy - the hierarchy of shapes.
  3. We want to use one of two backends: either turtle or TKinter. This is the second hierarchy - the hierarchy of backends.
  4. Implementation of drawing a line in turtle is completely different than drawing a line in tkinter. Similarly, drawing a rectangle in turtle is completely different than drawing a rectangle in tkinter. We have two backends and two shapes, so there are (2 \times 2 = 4) different implementations. If we had three backends and three shapes, there would be (3 \times 3 = 9) different implementations.

  5. Design the code.

  6. How easy will it be to add a new shape (i.e., an ellipse)?
  7. How easy will it be to add a new backend (i.e., Qt)?

Installing TKinter

Initial Code

### Tkinter backend

import tkinter

def draw_tkinter():
    root = tkinter.Tk()
    frame = tkinter.Frame(master=root)
    canvas = tkinter.Canvas(frame)
    canvas.pack()
    frame.pack()
    root.geometry('400x400')

    # for each line call draw_line_tkinter(..., canvas)
    draw_line_tkinter(x1=10, y1=10, x2=30, y2=30, canvas=canvas)
    # for each rectangle call draw_rectangle_tkinter(..., canvas)
    draw_rectangle_tkinter(x=10, y=10, w=50, h=50, canvas=canvas)

    root.mainloop()

def draw_line_tkinter(x1, y1, x2, y2, canvas):
    canvas.create_line(x1, y1, x2, y2)

def draw_rectangle_tkinter(x, y, w, h, canvas):
    canvas.create_line(x, y, x+w, y)
    canvas.create_line(x, y+h, x+w, y+h)
    canvas.create_line(x, y, x, y+h)
    canvas.create_line(x+w, y, x+w, y+h)

draw_tkinter()
### Turtle backend

import turtle

def draw_turtle():
    turtle.resetscreen()
    turtle.penup()

    # for each line call draw_line_turtle(...)
    draw_line_turtle(x1=10, y1=10, x2=30, y2=30)
    # for each rectangle call draw_rectangle_turtle(...)
    draw_rectangle_turtle(x=10, y=10, w=50, h=50)

    turtle.done()

def draw_line_turtle(x1, y1, x2, y2):
    turtle.goto(x1, -y1)
    turtle.pendown()
    turtle.goto(x2, -y2)
    turtle.penup()

def draw_rectangle_turtle(x, y, w, h):
    turtle.goto(x, -y)
    turtle.pendown()
    turtle.goto(x+w, -y)
    turtle.goto(x+w, -y-h)
    turtle.goto(x, -y-h)
    turtle.goto(x, -y)
    turtle.penup()

draw_turtle()

Initial Solution

@dataclass
class Shape:
    def draw(self, drawing): 
        raise NotImplementedError
        # use backend.draw_line

@dataclass
class Line(Shape):
    x1: int
    y1: int
    x2: int
    y2: int

    def draw(self, backend: Backend):
        pass
        # use backend.draw_line

@dataclass
class Rectangle(Shape):
    x: int
    y: int
    w: int
    h: int

    def draw(self, backend: Backend):
        pass
        # use backend.draw_line

@dataclass
class Drawing:
    shapes: List[Shape]

class Backend:
    def draw(self, drawing: Drawing): raise NotImplementedError  # iterate over all shapes in the drawing and call their .draw() method
    def draw_line(self, x1, y1, x2, y2): raise NotImplementedError


class TKinterBackend(Backend): pass  # implement draw and draw_line methods
class TurtleBackend(Backend): pass  # implement draw and draw_line methods

# TKinterBackend.draw and TurtleBackend.draw should delegate to Line.draw and Rectangle.draw.
# Line.draw and Rectangle.draw should delegate to backend.draw_line.

Test

shapes = [
    Line(10, 10, 50, 10),
    Line(10, 10, 10, 50),
    Rectangle(50, 50, 100, 150),
]
drawing = Drawing(shapes)

backend = TKinterBackend()
backend.draw(drawing)

backend = TurtleBackend()
backend.draw(drawing)

Solution

from __future__ import annotations

from dataclasses import dataclass
import tkinter
import turtle


@dataclass
class Shape:
    def draw(self, drawing):
        raise NotImplementedError

@dataclass
class Line(Shape):
    x1: int
    y1: int
    x2: int
    y2: int

    def draw(self, backend: Backend):
        backend.draw_line(self.x1, self.y1, self.x2, self.y2)

@dataclass
class Rectangle(Shape):
    x: int
    y: int
    w: int
    h: int

    def draw(self, backend: Backend):
        backend.draw_line(self.x, self.y, self.x+self.w, self.y)
        backend.draw_line(self.x, self.y+self.h, self.x+self.w, self.y+self.h)
        backend.draw_line(self.x, self.y, self.x, self.y+self.h)
        backend.draw_line(self.x+self.w, self.y, self.x+self.w, self.y+self.h)

@dataclass
class Drawing:
    shapes: List[Shape]

class Backend:
    def draw(self, drawing: Drawing): raise NotImplementedError  # iterate over all shapes in the drawing and call their .draw() method
    def draw_line(self, x1, y1, x2, y2): raise NotImplementedError


class TKinterBackend(Backend):   # implement draw and draw_line methods
    def draw(self, drawing: Drawing):
        root = tkinter.Tk()
        frame = tkinter.Frame(master=root)
        self.canvas = tkinter.Canvas(frame)
        self.canvas.pack()
        frame.pack()
        root.geometry('400x400')

        for shape in drawing.shapes:
            shape.draw(self)

        root.mainloop()

    def draw_line(self, x1, y1, x2, y2):
        self.canvas.create_line(x1, y1, x2, y2)

class TurtleBackend(Backend):   # implement draw and draw_line methods
    def draw(self, drawing: Drawing):
        turtle.resetscreen()
        turtle.penup()

        for shape in drawing.shapes:
            shape.draw(self)

        turtle.done()

    def draw_line(self, x1, y1, x2, y2):
        turtle.goto(x1, -y1)
        turtle.pendown()
        turtle.goto(x2, -y2)
        turtle.penup()

# TKinterBackend.draw and TurtleBackend.draw should delegate to Line.draw and Rectangle.draw.
# Line.draw and Rectangle.draw should delegate to backend.draw_line.

Iterators & Generators

Iterators and generators are powerful features in Python that allow for efficient looping and data streaming.

Iterators are objects that can be iterated over, meaning you can traverse through all the values. They implement two methods: __iter__() and __next__(). The __iter__() method returns the iterator object itself and is implicitly called at the start of loops. The __next__() method returns the next value and is implicitly called at each loop iteration. When there are no more items to return, it raises a StopIteration exception, signaling the end of the iteration.

Generators are a special type of iterator, defined using a function rather than a class. They use the yield keyword to return values one at a time, suspending the function’s state between each yield and resuming from where it left off when called again. This makes generators memory-efficient and well-suited for large data streams or sequences.

Example Use Cases

  1. Reading Large Files: Iterators and generators can be used to read large files line by line without loading the entire file into memory.
  2. Streaming Data: In applications where data is received in streams, such as network packets or sensor data, generators can be used to process data on-the-fly.
  3. Infinite Sequences: Generators can be used to create infinite sequences, such as an endless sequence of prime numbers, without running out of memory.
  4. Pipeline Processing: Generators can be chained together to create data processing pipelines, allowing for modular and reusable code.

Iteration Protocol

class NonIterableObject:
    def __init__(self):
        pass


a = NonIterableObject()

for x in a:
    pass
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[131], line 8
      3         pass
      6 a = NonIterableObject()
----> 8 for x in a:
      9     pass

TypeError: 'NonIterableObject' object is not iterable
iter(a)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[132], line 1
----> 1 iter(a)

TypeError: 'NonIterableObject' object is not iterable
class MyRange: # iterable object
    def __init__(self, start, end):
        self.current = start
        self.end = end

    def __iter__(self): # iterator factory
        return self

    def __next__(self): # iterator
        if self.current >= self.end:
            raise StopIteration
        current = self.current
        self.current += 1
        return current

# Usage
for num in MyRange(1, 5):
    print(num)
1
2
3
4
x = iter(MyRange(1, 5))
print(next(x))
print(next(x))
print(next(x))
print(next(x))
print(next(x))
1
2
3
4
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[135], line 6
      4 print(next(x))
      5 print(next(x))
----> 6 print(next(x))

Cell In[133], line 11, in MyRange.__next__(self)
      9 def __next__(self): # iterator
     10     if self.current >= self.end:
---> 11         raise StopIteration
     12     current = self.current
     13     self.current += 1

StopIteration: 
items = [1, 4]  # obiekt iterowalny (iterable)
iterator = iter(items)  # delegates to items.__iter__()
print(next(iterator))  # delegates to iterator.__next__()
print(next(iterator))
print(next(iterator))
1
4
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[136], line 5
      3 print(next(iterator))  # delegates to iterator.__next__()
      4 print(next(iterator))
----> 5 print(next(iterator))

StopIteration: 
items = [1, 4]
iterator = items.__iter__()
print(iterator.__next__())
print(iterator.__next__())
print(iterator.__next__())
1
4
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[137], line 5
      3 print(iterator.__next__())
      4 print(iterator.__next__())
----> 5 print(iterator.__next__())

StopIteration: 

Exercise: One-time Iterable

  1. Implement countdown class that is an iterable and iterator at the same time.
  2. It lets you count down from a number passed to the constructor.
  3. This is one-time iterable, that is you can iterate over it only once.

Initial Code

# Avoid range(5) or hardcoding [5, 4, 3, 2, 1] list.
class countdown:
    ...

Test

assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == []

for i in countdown(5):
    print(i)

Solution

class countdown:
    def __init__(self, start):
        self.start = start

    def __iter__(self):
        # reset conter if I need
        return self

    def __next__(self):
        if self.start <= 0:
            raise StopIteration
        else:
            previous = self.start
            self.start -= 1
            return previous

assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == []

for i in countdown(5):
    print(i)
5
4
3
2
1

Exercise: Multiple-time Iterable

  1. Implement countdown and CountdownIterator classes. The first one is an interable, the second one is an iterator.
  2. It should be possible to iterate over the same countdown many times. It should work even when there are two parallel for loops over the same countdown in two execution threads.

Initial Code

class countdown:  # iterable
    ...


class CountdownIterator:  # iterator
    ...

Test

assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == [5, 4, 3, 2, 1]

c = countdown(5)
for i in c:
    print(i)
for i in c:
    print(i)

Solution

class countdown:  # iterable
    def __init__(self, start):
        self.start = start

    def __iter__(self):
        return CountdownIterator(self.start)


class CountdownIterator:  # iterator
    def __init__(self, start):
        self.counter = start

    def __next__(self):
        if self.counter <= 0:
            raise StopIteration
        else:
            previous = self.counter
            self.counter -= 1
            return previous

assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == [5, 4, 3, 2, 1]

c = countdown(5)
for i in c:
    print(i)
for i in c:
    print(i)
5
4
3
2
1
5
4
3
2
1

Generators

A generator is a special type of iterator that is defined using a function with one or more yield statements.

You create a generator by defining a function with yield statements. When the function is called, it returns a generator object.

Generators automatically manage their state and resume execution where they left off when yield was last called.

Generators are more memory efficient than iterators because they generate values on the fly and do not store the entire sequence in memory.

Essentials

def foo():
    print('asdf')
    return 42
    print('finish')

print(foo())
asdf
42
def gen():
    d = 5
    print('start')
    a = yield 1
    print('a =', a)
    print('d =', d)
    b = yield 2
    print('b =', b)
    print('d =', d)
g = gen()  # it = countdown.__iter__()
print(next(g))  # next(it)
start
1
print(next(g)) # allows generator to continue
a = None
d = 5
2
print(next(g))
b = None
d = 5
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[147], line 1
----> 1 print(next(g))

StopIteration: 
g = gen()
for i in g:
    print(i)
start
1
a = None
d = 5
2
b = None
d = 5

Generators are Iterables

g = gen()
print(g.send)
print(g.throw)
print(g.close)
print(g.__iter__)
print(g.__next__)
<built-in method send of generator object at 0x11a3482e0>
<built-in method throw of generator object at 0x11a3482e0>
<built-in method close of generator object at 0x11a3482e0>
<method-wrapper '__iter__' of generator object at 0x11a3482e0>
<method-wrapper '__next__' of generator object at 0x11a3482e0>
it = iter(g)
print(g)
print(it)
print(it is g)
<generator object gen at 0x11a3482e0>
<generator object gen at 0x11a3482e0>
True

send()

The send method is used to send a value to the generator. This value becomes the result of the current yield expression.

g = gen()
print(g.__next__())
start
1
print(g.send(12)) # send value and implicitly call __next__
a = 12
d = 5
2
print(g.send(23))
b = 23
d = 5
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[153], line 1
----> 1 print(g.send(23))

StopIteration: 

throw() and close()

The throw method is used to raise an exception inside the generator, while the close method is used to stop the generator.

def gen():
    try:
        yield 1 # stop, g,throw(Excception,...)
        yield 2
    except Exception as e:
        print(f"Exception: {e}")
    finally:
        print("Generator closed")

g = gen()
print(next(g))
g.throw(Exception, "An error occurred")
1
Exception: An error occurred
Generator closed
/var/folders/w9/9hmtpfzj64v0x0j841ny9y280000gn/T/ipykernel_47413/3039691535.py:12: DeprecationWarning: the (type, exc, tb) signature of throw() is deprecated, use the single-arg signature instead.
  g.throw(Exception, "An error occurred")
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[156], line 12
     10 g = gen()
     11 print(next(g))
---> 12 g.throw(Exception, "An error occurred")

StopIteration: 

Example: Simple Generator

Here, we define a generator that yields the first n Fibonacci numbers.

def fibonacci(n):
    a, b = 0, 1
    for _ in range(n):
        yield a
        a, b = b, a + b

# Usage
for num in fibonacci(10):
    print(num)
0
1
1
2
3
5
8
13
21
34

Excercise: One-time generator

Implement countdown as a generator function.

Initial Code

def countdown(start):
    ...

Expected Behaviour:

assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == []

for i in countdown(5):
    print(i)

5 4 3 2 1

Solution

def countdown(start):
    while start > 0:
        yield start
        start -= 1

assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == []

for i in countdown(5):
    print(i)
5
4
3
2
1

Excercise: Multiple-time generator

Implement countdown as a class where iter method is a generator function. This time it should be possible to iterate over the same countdown many times. It should work even when there are two parallel for loops over the same countdown in two execution threads.

Initial Code

class countdown:
    def __init__(self, start):
        self.start = start

    def __iter__(self):
        ...
    def generator(self, counter):  # iterator
       ...

Expected Behaviour:

assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == [5, 4, 3, 2, 1]

c = countdown(5)
for i in c:
    print(i)
for i in c:
    print(i)

5 4 3 2 1 5 4 3 2 1

Solution

class countdown:
    def __init__(self, start):
        self.start = start

    def __iter__(self):
        return self.generator(self.start)

    def generator(self, counter):  # iterator
        while counter > 0:
            yield counter
            counter -= 1

#     # albo w jednej metodzie:

#     def __iter__(self):
#         counter = self.start
#         while counter > 0:
#             yield counter
#             counter -= 1


assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == [5, 4, 3, 2, 1]

c = countdown(5)
for i in c:
    print(i)
for i in c:
    print(i)
5
4
3
2
1
5
4
3
2
1

Use Case

%%writefile numbers.txt
123
543 -
987 678 234 -

# a comment
123.0  # another comment
543
Overwriting numbers.txt
with open('numbers.txt', 'r') as stream:
    total = 0
    for line in stream:
        index = line.find('#')
        if index != -1:
            line = line[:index]
        for number in line.split():
            if number != '-':
                number = float(number)
                total += number
    print(total)
3231.0
def remove_comments(stream):
    for line in stream:
        index = line.find('#')
        if index != -1:
            yield line[:index]
        else:
            yield line

def split_by_whitespaces(stream):
    for line in stream:
#         for number in line.split():
#             yield number
#         # is equivalent to:
        yield from line.split() # from attach iter to output of the line.split()


def ignore_dashes(stream):
    for number in stream:
        if number != '-':
            yield number


def convert_to_float(stream):
    for number in stream:
        yield float(number)


with open('numbers.txt', 'r') as stream:
    stream = remove_comments(stream)
    stream = split_by_whitespaces(stream)
    stream = ignore_dashes(stream)
#     stream = convert_to_float(stream)
    stream = map(float, stream)
    summed = sum(stream)
    print(summed)
3231.0

Behavioral Patterns

Behavioral patterns are design patterns that focus on how objects interact and communicate with each other. These patterns help define the responsibilities between objects and the way they collaborate to achieve a specific task. They are particularly useful for managing complex interactions.

Observer Pattern

The Observer Pattern is a behavioral design pattern that allows an object, known as the subject, to maintain a list of its dependents, called observers, and automatically notify them of any state changes. This pattern is particularly useful for implementing distributed event-handling systems.

Examples and Use Cases

  1. Newsletter: Subscribers get notified when a new edition of the newsletter is published.
  2. News Feed: Users get updates when new articles or posts are published.
  3. Push Notifications: Mobile apps send push notifications to users when certain events occur.
  4. Store with New Products: Customers are notified when a new product they are interested in becomes available.
  5. Publisher-Subscriber Pattern: Any system where you need a publisher-subscriber model, such as messaging systems.
  6. Push Model: Any scenario where you need to push updates to subscribers instead of them pulling the information.
  7. Event-Driven Systems: Especially useful in graphical user interfaces (GUIs) where various components need to react to user actions.
  8. Queues: Systems like RabbitMQ that handle message queues and notify subscribers when new messages arrive.
  9. Logging: Logging systems that notify different logging handlers about new log entries.

Two Kinds of Objects

  1. Observer = Receiver = Subscriber = Listener = Handler: These are the objects that receive updates from the subject.
  2. Subject = Sender = Publisher = Topic: These are the objects that send updates to observers.

Example: Simple Weather Station

class WeatherStationStatus:
    temperature: float

class WeatherStationStatusV2:
    temperature: int

class Subject:
    def __init__(self):
        self._observers = []

    def register_observer(self, observer):
        self._observers.append(observer)

    def remove_observer(self, observer):
        self._observers.remove(observer)

    def notify_observers(self, message):
        for observer in self._observers:
            observer.update(message)

class WeatherStation(Subject):
    def __init__(self):
        super().__init__()
        self._temperature = 0

    def set_temperature(self, temperature):
        self._temperature = temperature
        self.notify_observers(f"Temperature updated to {temperature}°C")
        # self.notify_observers(WeatherStationStatus(temperature=temperature))
        # self.notify_observers(WeatherStationStatusV2(temperature=temperature))

class Observer:
    def update(self, message):
        pass

class TemperatureDisplay(Observer):
    def update(self, message):
        print(f"Temperature Display: {message}")

class TemperatureLogger(Observer):
    def update(self, message):
        print(f"Temperature Logger: {message}")
# Usage
weather_station = WeatherStation()
display = TemperatureDisplay()
logger = TemperatureLogger()
weather_station.register_observer(display)
weather_station.register_observer(logger)
weather_station.set_temperature(25)
Temperature Display: Temperature updated to 25°C
Temperature Logger: Temperature updated to 25°C
weather_station.set_temperature(30)
Temperature Display: Temperature updated to 30°C
Temperature Logger: Temperature updated to 30°C

Example: Event Logging System

In this example, we'll use the Logger class to create an event logging system where different handlers can be registered to process log messages in various ways, such as printing to the console, writing to a file, and sending alerts.

class Logger:
    def __init__(self):
        self._handlers = []

    def register_handler(self, handler):
        self._handlers.append(handler)

    def log(self, msg):
        for handler in self._handlers:
            handler(msg)
# Usage
logger = Logger()

# Define some handlers
def print_log(msg):
    print("LOG: " + msg)

def file_log(msg):
    with open('log.txt', 'a') as f:
        f.write(msg + '\n')

def alert_log(msg):
    print("ALERT: " + msg)
# Register handlers
logger.register_handler(print_log)
logger.register_handler(file_log)
logger.register_handler(alert_log)
# Log a message
logger.log('System started')
LOG: System started
ALERT: System started
# Log another message
logger.log('An error occurred')
LOG: An error occurred
ALERT: An error occurred

Excercise: Observing a Directory

  1. Observe a directory. When a new file is created in the directory, print its name.
  2. Use watchdog library. You can install it with pip install watchdog.
  3. Watchdog Documentation

Initial Code

import time
import logging
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler


class MyEventHandler(FileSystemEventHandler):
    ...

Test

### Expected Behaviour (below, the current working directory will be observed for 5 seconds).
# Your Code should observe it infinitely long.
event_handler = MyEventHandler()
observer = Observer()
observer.schedule(event_handler, '.', recursive=True)
observer.start()
time.sleep(5)
observer.stop()
observer.join()
import time
import logging
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
### Solution

class MyEventHandler(FileSystemEventHandler):
    def on_created(self, event):
        if not event.is_directory:
            print(event.src_path)
### Expected Behaviour (below, the current working directory will be observed for 5 seconds).
# Your Code should observe it infinitely long.

event_handler = MyEventHandler()
observer = Observer()
observer.schedule(event_handler, '.', recursive=True)
observer.start()
time.sleep(5)
observer.stop()
observer.join()

Mediator Pattern

The Mediator Pattern is a behavioral design pattern that provides a way to encapsulate how objects interact with each other. Instead of objects communicating directly, they communicate through a mediator object. This helps reduce the dependencies between communicating objects, promoting loose coupling.

Example: Message Handling System with Differentiated Handlers Using Message Classes

class Message:
    pass

class InfoMessage(Message):
    def __init__(self, content):
        self.content = content

class ErrorMessage(Message):
    def __init__(self, content):
        self.content = content

class AlertMessage(Message):
    def __init__(self, content):
        self.content = content

class CriticalMessage(Message):
    def __init__(self, content):
        self.content = content

class MessageMediator:
    def __init__(self):
        self.handlers = {}

    def register_handler(self, message_type, handler):
        if message_type not in self.handlers:
            self.handlers[message_type] = []
        self.handlers[message_type].append(handler)

    def dispatch_message(self, message):
        message_type = type(message)
        if message_type in self.handlers:
            for handler in self.handlers[message_type]:
                handler.handle(message)

class MessageHandler:
    def handle(self, message):
        pass

class PrintHandler(MessageHandler):
    def handle(self, message):
        print(f"PrintHandler: {message.content}")

class LogHandler(MessageHandler):
    def handle(self, message):
        with open('messages.log', 'a') as file:
            file.write(message.content + '\n')
        print(f"LogHandler: {message.content} logged")

class AlertHandler(MessageHandler):
    def handle(self, message):
        print(f"AlertHandler: ALERT! {message.content}")
# Usage
mediator = MessageMediator()
print_handler = PrintHandler()
log_handler = LogHandler()
alert_handler = AlertHandler()
# Register handlers for specific message types
mediator.register_handler(InfoMessage, print_handler)
mediator.register_handler(ErrorMessage, log_handler)
mediator.register_handler(AlertMessage, alert_handler)
# Register multiple handlers for a single message type
mediator.register_handler(CriticalMessage, log_handler)
mediator.register_handler(CriticalMessage, alert_handler)
# Dispatch messages to the appropriate handlers
mediator.dispatch_message(InfoMessage("System started"))
mediator.dispatch_message(ErrorMessage("An error occurred"))
mediator.dispatch_message(AlertMessage("High CPU usage detected"))
mediator.dispatch_message(CriticalMessage("System failure imminent"))
PrintHandler: System started
LogHandler: An error occurred logged
AlertHandler: ALERT! High CPU usage detected
LogHandler: System failure imminent logged
AlertHandler: ALERT! System failure imminent

Template Method Pattern

The Template Method Pattern is a behavioral design pattern that defines the skeleton of an algorithm in a method, deferring some steps to subclasses. This pattern allows subclasses to redefine certain steps of an algorithm without changing its structure. The main idea is to outline the algorithm's structure in a base class and let subclasses implement the specific details.

Subfunctions

### Before Refactorization
def foo():
    print('1')
    print('2')
    print('foo')

def bar():
    print('1')
    print('2')
    print('bar')
### After Refactorization
def print_1_2():
    print('1')
    print('2')

def foo():
    print_1_2()
    print('foo')

def bar():
    print_1_2()
    print('bar')

DRY

%%writefile file.txt
content
more content
end
Overwriting file.txt
### Before Refactorization

import logging
from time import time

def divide_numbers(a, b):
    start = time()
    try:
        return a / b
    finally:
        elapsed = time() - start
        print(f'Elapsed {elapsed:.3f} sec in divide_numbers')

def print_file(filename):
    start = time()
    try:
        with open(filename) as stream:
            content = stream.read()
        print(content)
    finally:
        elapsed = time() - start
        print(f'Elapsed {elapsed:.3f} sec in print file')
print(divide_numbers(2, 3))
Elapsed 0.000 sec in divide_numbers
0.6666666666666666
print_file('file.txt')
content
more content
end

Elapsed 0.001 sec in print file

Bad Approach

def timeit(try_, finally_):
    start = time()
    try:
        return try_()
    finally:
        elapsed = time() - start
        finally_(elapsed)

def divide_numbers(a, b):
    def try_():
        return a / b
    def finally_(elapsed):
        print(f'Elapsed {elapsed:.3f} sec in divide_numbers')
    return timeit(try_, finally_)

def print_file(filename):
    def try_():
        with open(filename) as stream:
            content = stream.read()
        print(content)
    def finally_(elapsed):
        logging.warning(f'Elapsed {elapsed:.3f} sec in divide_numbers')
    return timeit(try_, finally_)

Good Approach

class TimeIt:
    def run(self, *args, **kwargs):
        start = time()
        try:
            return self.try_(*args, **kwargs)
        finally:
            elapsed = time() - start
            self.finally_(elapsed)

    def try_(self):
        raise NotImplementedError

    def finally_(self, elapsed: float):
        raise NotImplementedError
class DivideNumbers(TimeIt):
    def try_(self, a, b):
        return a / b

    def finally_(self, elapsed):
        print(f'Elapsed {elapsed:.3f} sec in divide_numbers')

class PrintFile(TimeIt):
    def try_(self, filename):
        with open(filename) as stream:
            content = stream.read()
        print(content)

    def finally_(self, elapsed):
        logging.warning(f'Elapsed {elapsed:.3f} sec in divide_numbers')
dn = DivideNumbers()
print(dn.run(2, 3))
Elapsed 0.000 sec in divide_numbers
0.6666666666666666

Decorator Approach

import functools

def timeit(f):
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        start = time()
        try:
            return f(*args, **kwargs)
        finally:
            elapsed = time() - start
            print(f'Elapsed {elapsed:.3f} sec in {f.__name__}')
    return wrapper


@timeit
def divide_numbers(a, b):
    return a / b


@timeit
def print_file(filename):
    with open(filename) as stream:
        content = stream.read()
    print(content)

Example: Document Generation

from abc import ABC, abstractmethod

class DocumentGenerator(ABC):
    def generate_document(self):
        self.add_header()
        self.add_content()
        self.add_footer()

    @abstractmethod
    def add_header(self):
        pass

    @abstractmethod
    def add_content(self):
        pass

    @abstractmethod
    def add_footer(self):
        pass
class ReportGenerator(DocumentGenerator):
    def add_header(self):
        print("Report Header")

    def add_content(self):
        print("Report Content")

    def add_footer(self):
        print("Report Footer")

class InvoiceGenerator(DocumentGenerator):
    def add_header(self):
        print("Invoice Header")

    def add_content(self):
        print("Invoice Content")

    def add_footer(self):
        print("Invoice Footer")
# Usage
report = ReportGenerator()
report.generate_document()

invoice = InvoiceGenerator()
invoice.generate_document()
Report Header
Report Content
Report Footer
Invoice Header
Invoice Content
Invoice Footer

Example: Data Processing Pipeline

from abc import ABC, abstractmethod

class DataProcessor(ABC):
    def process_data(self, data):
        data = self.load_data(data)
        data = self.transform_data(data)
        self.save_data(data)

    @abstractmethod
    def load_data(self, data):
        pass

    @abstractmethod
    def transform_data(self, data):
        pass

    @abstractmethod
    def save_data(self, data):
        pass
class CSVDataProcessor(DataProcessor):
    def load_data(self, data):
        print("Loading CSV data")
        return data

    def transform_data(self, data):
        print("Transforming CSV data")
        return data

    def save_data(self, data):
        print("Saving CSV data")

class JSONDataProcessor(DataProcessor):
    def load_data(self, data):
        print("Loading JSON data")
        return data

    def transform_data(self, data):
        print("Transforming JSON data")
        return data

    def save_data(self, data):
        print("Saving JSON data")

Example: HTML parsing

from html.parser import HTMLParser

class MyHTMLParser(HTMLParser):
    def handle_starttag(self, tag, attrs):
        print("Encountered a start tag:", tag, attrs)

    def handle_endtag(self, tag):
        print("Encountered an end tag :", tag)

    def handle_data(self, data):
        print("Encountered some data  :", data)

parser = MyHTMLParser()

parser.feed('<html><head><title attr="value" attr2="value2">Test</title></head>'
            '<body><h1>Parse me!</h1></body></html>')
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[196], line 1
----> 1 from html.parser import HTMLParser
      3 class MyHTMLParser(HTMLParser):
      4     def handle_starttag(self, tag, attrs):

ModuleNotFoundError: No module named 'html.parser'

Exercise: CLI do turtle

Installing TKinter Implement a command line tool that works in question-answer way. Use builtin cmd framework. Documentation The tool should let you control the turtle. Implement commands like right, left, home, circle, reset. ★ Implement commands like position and heading.

Initial Code

import cmd, sys
import turtle

class TurtleShell(cmd.Cmd):
    intro = 'Welcome to the turtle shell.   Type help or ? to list commands.\n'
    prompt = '(turtle) '

    # ----- basic turtle commands -----
    def do_forward(self, arg):
        'Move the turtle forward by the specified distance:  FORWARD 10'
        turtle.forward(int(arg))
    def do_bye(self, arg):
        'Close the turtle window, and exit:  BYE'
        print('Thank you for using Turtle')
        turtle.bye()
        return True

if __name__ == '__main__':
    TurtleShell().cmdloop()

Welcome to the turtle shell. Type help or ? to list commands.

(turtle) forward 50 (turtle) exit *** Unknown syntax: exit (turtle) bye Thank you for using Turtle

Solution

import cmd, sys
import turtle

class TurtleShell(cmd.Cmd):
    intro = 'Welcome to the turtle shell.   Type help or ? to list commands.\n'
    prompt = '(turtle) '

    # ----- basic turtle commands -----
    def do_forward(self, arg):
        'Move the turtle forward by the specified distance:  FORWARD 10'
        turtle.forward(int(arg))
    def do_right(self, arg):
        'Turn turtle right by given number of degrees:  RIGHT 20'
        turtle.right(int(arg))
    def do_left(self, arg):
        'Turn turtle left by given number of degrees:  LEFT 90'
        turtle.left(int(arg))
    def do_home(self, arg):
        'Return turtle to the home position:  HOME'
        turtle.home()
    def do_circle(self, arg):
        'Draw circle with given radius an options extent and steps:  CIRCLE 50'
        turtle.circle(int(arg))
    def do_position(self, arg):
        'Print the current turtle position:  POSITION'
        print('Current position is {} {}\n'.format(*turtle.position()))
    def do_heading(self, arg):
        'Print the current turtle heading in degrees:  HEADING'
        print(f'Current heading is {turtle.heading()}\n')
    def do_reset(self, arg):
        'Clear the screen and return turtle to center:  RESET'
        turtle.reset()
    def do_bye(self, arg):
        'Close the turtle window, and exit:  BYE'
        print('Thank you for using Turtle')
        turtle.bye()
        return True

if __name__ == '__main__':
    TurtleShell().cmdloop()

Chain of Responsibility Pattern

The Chain of Responsibility Pattern is a behavioral design pattern that allows a request to be passed along a chain of handlers. Each handler in the chain either processes the request or passes it to the next handler. This pattern is useful when multiple objects can handle a request, but the handler isn't determined until runtime.

How Does the Chain of Responsibility Work?

Imagine you have a sequence of objects, each with the ability to process a request. The request starts at the beginning of the chain and moves along until it's handled by one of the objects, or it reaches the end of the chain. This pattern promotes loose coupling because the sender of a request doesn't need to know which object will handle it.

There are two main variations of the Chain of Responsibility:

  1. Pass Until Handled: The request is passed along the chain until one handler processes it.
  2. Pass Until Failure: The request is passed along the chain, but the process stops if one handler fails, either passing or failing the entire chain.

Example: Pass Until Handled

class HelpDeskHandler:
    def __init__(self, next_handler=None):
        self.next_handler = next_handler

    def handle(self, request):
        if self.next_handler:
            return self.next_handler.handle(request)
        return f"Request {request} could not be handled"

# Level 1 Handler
class Level1Support(HelpDeskHandler):
    def handle(self, request):
        if request == "password_reset":
            return "Level 1 Support: Handling password reset"
        return super().handle(request)

# Level 2 Handler
class Level2Support(HelpDeskHandler):
    def handle(self, request):
        if request == "software_issue":
            return "Level 2 Support: Handling software issue"
        return super().handle(request)

# Level 3 Handler
class Level3Support(HelpDeskHandler):
    def handle(self, request):
        if request == "hardware_issue":
            return "Level 3 Support: Handling hardware issue"
        return super().handle(request)


# Create the chain of handlers
chain = Level1Support(Level2Support(Level3Support()))


# Test the chain
print(chain.handle("password_reset"))  # Output: Level 1 Support: Handling password reset
print('--------')
print(chain.handle("software_issue"))  # Output: Level 2 Support: Handling software issue
print('--------')
print(chain.handle("hardware_issue"))  # Output: Level 3 Support: Handling hardware issue
print('--------')
print(chain.handle("unknown_request")) # Output: Request unknown_request could not be handled
Level 1 Support: Handling password reset
--------
Level 2 Support: Handling software issue
--------
Level 3 Support: Handling hardware issue
--------
Request unknown_request could not be handled

Example: Pass Until Failure

In this variation, the request is passed along the chain, but the process stops if one handler fails to process it successfully. In this example, a request goes through a series of validation handlers for a credit card payment. If any validation fails, the process stops and returns an error.

class PaymentValidationHandler:
    def __init__(self, next_handler=None):
        self.next_handler = next_handler

    def handle(self, request):
        if self.next_handler:
            return self.next_handler.handle(request)
        return "All validations passed."

# Check if card number is valid
class CardNumberValidation(PaymentValidationHandler):
    def handle(self, request):
        if len(request["card_number"]) == 16:
            print("Card number validation passed")
            return super().handle(request)
        return "Invalid card number."

# Check if expiry date is valid
class ExpiryDateValidation(PaymentValidationHandler):
    def handle(self, request):
        if request["expiry_year"] >= 2024:
            print("Expiry date validation passed")
            return super().handle(request)
        return "Card has expired."

# Check if CVV is valid
class CVVValidation(PaymentValidationHandler):
    def handle(self, request):
        if len(request["cvv"]) == 3:
            print("CVV validation passed")
            return super().handle(request)
        return "Invalid CVV."


# Create the chain of validation handlers
validation_chain = CardNumberValidation(ExpiryDateValidation(CVVValidation()))

# Test data
valid_request = {
    "card_number": "1234567812345678",
    "expiry_year": 2025,
    "cvv": "123"
}

invalid_card_request = {
    "card_number": "1234",
    "expiry_year": 2025,
    "cvv": "123"
}

expired_card_request = {
    "card_number": "1234567812345678",
    "expiry_year": 2023,
    "cvv": "123"
}

invalid_cvv_request = {
    "card_number": "1234567812345678",
    "expiry_year": 2025,
    "cvv": "12"
}

# Test the chain with valid and invalid requests
print(validation_chain.handle(valid_request))        # Output: All validations passed.
print('------')
print(validation_chain.handle(invalid_card_request)) # Output: Invalid card number.
print('------')
print(validation_chain.handle(expired_card_request)) # Output: Card has expired.
print('------')
print(validation_chain.handle(invalid_cvv_request))  # Output: Invalid CVV.
Card number validation passed
Expiry date validation passed
CVV validation passed
All validations passed.
------
Invalid card number.
------
Card number validation passed
Card has expired.
------
Card number validation passed
Expiry date validation passed
Invalid CVV.

Exercise: Build a Chain of Responsibility

In this exercise, you will create a Chain of Responsibility Pattern to process different types of requests. The request goes through multiple handlers, and each handler either processes the request or passes it to the next handler.

You need to:

  1. Implement a base handler class that can pass the request to the next handler in the chain.
  2. Implement three concrete handlers:
    • LogHandler: Logs the incoming request.
    • AuthorizationHandler: Checks if the request is authorized.
    • DataProcessingHandler: Processes the request if it passes the previous handlers.
  3. Create the chain of handlers and ensure that requests flow through the chain correctly.

You will practice structuring the chain and defining how handlers pass the request along until it is processed.

Initial Code:

# 1. Base Handler class: Should pass the request to the next handler if it exists
class Handler:
    def __init__(self, next_handler=None):
        # Exercise: Implement the base handler constructor and store the next handler
        pass

    def handle(self, request):
        # Exercise: Implement the handle method to pass the request to the next handler if present
        pass

# 2. LogHandler: Logs the incoming request
class LogHandler(Handler):
    def handle(self, request):
        # Exercise: Implement logging and call the next handler
        pass

# 3. AuthorizationHandler: Checks if the request is authorized
class AuthorizationHandler(Handler):
    def handle(self, request):
        # Exercise: Implement authorization check and call the next handler or stop the chain
        pass

# 4. DataProcessingHandler: Processes the request data
class DataProcessingHandler(Handler):
    def handle(self, request):
        # Exercise: Implement data processing and call the next handler if needed
        pass

# Create the chain of handlers
# Exercise: Construct the chain of handlers here
chain_of_handlers = None

Test Code:

# Test requests
authorized_request = {"user": "admin", "data": "important data"}
unauthorized_request = {"user": "guest", "data": "important data"}

# Test the chain with authorized and unauthorized requests
print(chain_of_handlers.handle(authorized_request))    # Expected: Request processed.
print(chain_of_handlers.handle(unauthorized_request))  # Expected: Unauthorized request.

Solution:

# 1. Base Handler class: Should pass the request to the next handler if it exists
class Handler:
    def __init__(self, next_handler=None):
        self.next_handler = next_handler

    def handle(self, request):
        if self.next_handler:
            return self.next_handler.handle(request)
        return "Request processed."

# 2. LogHandler: Logs the incoming request
class LogHandler(Handler):
    def handle(self, request):
        print(f"Logging request: {request}")
        return super().handle(request)

# 3. AuthorizationHandler: Checks if the request is authorized
class AuthorizationHandler(Handler):
    def handle(self, request):
        if request.get("user") == "admin":
            print("Authorization successful")
            return super().handle(request)
        print("Authorization failed")
        return "Unauthorized request."

# 4. DataProcessingHandler: Processes the request data
class DataProcessingHandler(Handler):
    def handle(self, request):
        print(f"Processing data: {request['data']}")
        return super().handle(request)

# Create the chain of handlers
chain_of_handlers = LogHandler(AuthorizationHandler(DataProcessingHandler()))

# Test requests
authorized_request = {"user": "admin", "data": "important data"}
unauthorized_request = {"user": "guest", "data": "important data"}

# Test the chain with authorized and unauthorized requests
print(chain_of_handlers.handle(authorized_request))    # Expected: Request processed.
print(chain_of_handlers.handle(unauthorized_request))  # Expected: Unauthorized request.
Logging request: {'user': 'admin', 'data': 'important data'}
Authorization successful
Processing data: important data
Request processed.
Logging request: {'user': 'guest', 'data': 'important data'}
Authorization failed
Unauthorized request.

Exercise: GUI Event Propagation

  1. Implement Widget.handle method.
  2. This method should let the widget process the event. If this is not supported, it should pass the event to all its children.
  3. The widget supports an event if it implements on_XXX method specified in the event.handler_name.

Initial Code

### Events

from dataclasses import dataclass, field
from typing import ClassVar

@dataclass
class Event:
    handler_name: ClassVar[str]
    handled: bool = field(default=False, init=False)


@dataclass
class MouseClickEvent(Event):
    handler_name = 'on_mouse_click'
    x: int
    y: int

@dataclass
class KeyPressedEvent(Event):
    handler_name = 'on_key_pressed'
    key: str

@dataclass
class CloseEvent(Event):
    handler_name = 'on_close'

class Widget:
    def __init__(self):
        self.children = []

    def handle(self, event: Event):
        ...

class MainWindow(Widget):

    def on_close(self, event):
        print('Closing MainWindow...')

class Frame(Widget):
    pass

class TextBox(Widget):
    def on_key_pressed(self, event):
        print('TextBox handles keypressed event - {}'.format(event.key))

    def on_close(self, event):
        print('TextBox handles close event.')

class Button(Widget):
    def on_mouse_click(self, event):
        print('Button handles mouse clicked at ({}, {})'.format(event.x, event.y))

Tests

txt_box = TextBox()
btn_ok = Button()

frame = Frame()
frame.children = [txt_box, btn_ok]

wnd = MainWindow()
wnd.children = [frame]

wnd.handle(MouseClickEvent(1, 2))
# Button handles mouse clicked at (1, 2)

wnd.handle(KeyPressedEvent('K'))
# TextBox handles keypressed event - K

wnd.handle(CloseEvent())
Closing MainWindow...

Solution

### Events

from dataclasses import dataclass, field
from typing import ClassVar

@dataclass
class Event:
    handler_name: ClassVar[str]
    handled: bool = field(default=False, init=False)


@dataclass
class MouseClickEvent(Event):
    handler_name = 'on_mouse_click'
    x: int
    y: int

@dataclass
class KeyPressedEvent(Event):
    handler_name = 'on_key_pressed'
    key: str

@dataclass
class CloseEvent(Event):
    handler_name = 'on_close'

class Widget:
    def __init__(self):
        self.children = []

    ### Solutions
    # Version 1
    # Ask for forgiveness
    def handle(self, event: Event):
        try:
            handler = getattr(self, event.handler_name)
        except AttributeError:
            for child in self.children:
                child.handle(event)
                if event.handled:
                    break
        else:
            handler(event)
            event.handled = True

    # Version 2
    def handle(self, event) -> bool:
        try:
            handler = getattr(self, event.handler_name)
        except AttributeError:
            for child in self.children:
                handled = child.handle(event)
                if handled:
                    break
        else:
            handler(event)
            return True

    # Version 3
    def handle(self, event):
        if event.handled:
            return
        try:
            handler = getattr(self, event.handler_name)
        except AttributeError:
            for child in self.children:
                child.handle(event)
        else:
            handler(event)

    # Version 4
    def handle(self, event):
        # Ask for permission
        if hasattr(self, event.handler_name):
            handler = getattr(self, event.handler_name)
            handler(event)
            event.handled = True
        else:
            for child in self.children:
                child.handle(event)
                if event.handled:
                    break

class MainWindow(Widget):

    def on_close(self, event):
        print('Closing MainWindow...')

class Frame(Widget):
    pass

class TextBox(Widget):
    def on_key_pressed(self, event):
        print('TextBox handles keypressed event - {}'.format(event.key))

    def on_close(self, event):
        print('TextBox handles close event.')

class Button(Widget):
    def on_mouse_click(self, event):
        print('Button handles mouse clicked at ({}, {})'.format(event.x, event.y))

Strategy Pattern

The Strategy Pattern is a behavioral design pattern that defines a family of algorithms, encapsulates each one, and makes them interchangeable. The key idea behind the pattern is to enable selecting an algorithm's behavior at runtime. Instead of hardcoding multiple behaviors, the Strategy Pattern lets you choose from different strategies that can be swapped in and out as needed.

How Does the Strategy Pattern Work?

The Strategy Pattern allows a class to delegate its behavior to one of several interchangeable objects (strategies). Each strategy implements a specific behavior or algorithm, and the client class chooses which strategy to use at runtime. This makes the code more flexible and open to extension, as new strategies can be added without modifying the core logic.

Example: Payment Strategy

In this example, an e-commerce platform allows customers to pay using different payment methods. Instead of hardcoding the logic for each payment method, we use the Strategy Pattern to allow the system to switch between strategies dynamically (e.g., PayPal, Credit Card, Bank Transfer).

# Base Payment Strategy
class PaymentStrategy:
    def pay(self, amount):
        raise NotImplementedError

# PayPal Payment Strategy
class PayPalPayment(PaymentStrategy):
    def pay(self, amount):
        return f"Paying {amount} using PayPal."

# Credit Card Payment Strategy
class CreditCardPayment(PaymentStrategy):
    def pay(self, amount):
        return f"Paying {amount} using Credit Card."

# Bank Transfer Payment Strategy
class BankTransferPayment(PaymentStrategy):
    def pay(self, amount):
        return f"Paying {amount} using Bank Transfer."

# Context class that uses a Payment Strategy
class PaymentProcessor:
    def __init__(self, strategy: PaymentStrategy):
        self.strategy = strategy

    def process_payment(self, amount):
        return self.strategy.pay(amount)

# Usage
paypal = PayPalPayment()
credit_card = CreditCardPayment()
bank_transfer = BankTransferPayment()

# Choose a payment method at runtime
payment_processor = PaymentProcessor(paypal)
print(payment_processor.process_payment(100))  # Output: Paying 100 using PayPal.

payment_processor = PaymentProcessor(credit_card)
print(payment_processor.process_payment(200))  # Output: Paying 200 using Credit Card.

payment_processor = PaymentProcessor(bank_transfer)
print(payment_processor.process_payment(300))  # Output: Paying 300 using Bank Transfer.
Paying 100 using PayPal.
Paying 200 using Credit Card.
Paying 300 using Bank Transfer.

Exercise: 🏠 Discounts

  1. We have an online shop and there are different Orders.
  2. Each Order belongs to a Customer.
  3. On top of that, each Order has a list of LineItems.
  4. You can compute Order.total which sums up prices of all products in the order.
  5. So far, there were no discounts. Now, you want to implement Order.due property that returns the price after applying a discount.
  6. One day you want to give 5% discounts for customers that have at least 1000 fidelity points. However, old Orders should have no discount. 7.Another day you think it'll be better to change the strategy and give 7% discounts for orders that have 10 or more different products. This applies only to new orders. Old orders should use 5%-rule or no discount in case of the oldest orders.
  7. Implement this logic, so that later on you can add more strategies.

Initial Code

from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import List

@dataclass
class Customer:
    name: str
    fidelity: int

@dataclass
class LineItem:
    product: str
    price: float

class Promotion:
    def compute_discount(self, order: Order):
        """REturn discount or zero"""
        raise NotImplementedError

class NullPromo(Promotion):
    """ No discount at all."""
    def compute_discount(self, order):
        ...

class FidelityPromo(Promotion):
    """ 5% discount for customers that have at least 1000 fidelity points."""
    def compute_discount(self, order):
        ...

class LargeOrderPromo(Promotion):
    """ 7% discount for orders that have 10 or more different products. """
    def compute_discount(self, order):
        ...

@dataclass
class Order:
    customer: Customer
    cart: List[LineItem]
    promotion: Promotion = NullPromo()

    @property
    def total(self):
        return sum(item.price for item in self.cart)


    def due(self):
        ...

    def __repr__(self):
        return f'<Order total: {self.total:.2f} due: {self.due:.2f}>'

Tests

### Expected Behaviour -- NullPromo

cart = [LineItem('banana', 2.00)]
john = Customer('John Doe', 0)
order = Order(john, cart, NullPromo())
assert order.total == 2.00
assert order.due == 2.00

### Expected Behaviour -- FidelityPromo

cart = [
    LineItem('banana', 2.00), 
    LineItem('apple', 15.00), 
    LineItem('watermellon', 25.00),
]
john = Customer('John Doe', 0)
order = Order(john, cart, FidelityPromo())
assert order.total == 42.00
assert order.due == 42.00

ann = Customer('Ann Smith', 1100)
order = Order(ann, cart, FidelityPromo())
assert order.total == 42.00
assert order.due == 39.90

### Expected Behaviour -- LargeOrderPromo

order = Order(john, cart, LargeOrderPromo())
assert order.total == 42.00
assert order.due == 42.00

long_order = [LineItem(str(item_code), 1.00) 
              for item_code in range(10)]

order = Order(john, long_order, LargeOrderPromo())
assert order.total == 10.00
assert order.due == 9.30

Solution

from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import List

@dataclass
class Customer:
    name: str
    fidelity: int

@dataclass
class LineItem:
    product: str
    price: float

class Promotion:
    def compute_discount(self, order: Order):
        """REturn discount or zero"""
        raise NotImplementedError

class NullPromo(Promotion):
    """ No discount at all."""
    def compute_discount(self, order):
        return 0

class FidelityPromo(Promotion):
    """ 5% discount for customers that have at least 1000 fidelity points."""
    def compute_discount(self, order):
        if order.customer.fidelity >= 1000:
            return 0.05 * order.total
        else:
            return 0

class LargeOrderPromo(Promotion):
    """ 7% discount for orders that have 10 or more different products. """
    def compute_discount(self, order):
        distinct_items = {item.product for item in order.cart}
        if len(distinct_items) >= 10:
            return order.total * 0.07
        else:
            return 0

@dataclass
class Order:
    customer: Customer
    cart: List[LineItem]
    promotion: Promotion = NullPromo()

    @property
    def total(self):
        return sum(item.price for item in self.cart)

    @property
    def due(self):
        discount = self.promotion.compute_discount(self)
        return self.total - discount

    def __repr__(self):
        return f'<Order total: {self.total:.2f} due: {self.due:.2f}>'

### Expected Behaviour -- NullPromo

cart = [LineItem('banana', 2.00)]
john = Customer('John Doe', 0)
order = Order(john, cart, NullPromo())
assert order.total == 2.00
assert order.due == 2.00

### Expected Behaviour -- FidelityPromo

cart = [
    LineItem('banana', 2.00),
    LineItem('apple', 15.00),
    LineItem('watermellon', 25.00),
]
john = Customer('John Doe', 0)
order = Order(john, cart, FidelityPromo())
assert order.total == 42.00
assert order.due == 42.00

ann = Customer('Ann Smith', 1100)
order = Order(ann, cart, FidelityPromo())
assert order.total == 42.00
assert order.due == 39.90

### Expected Behaviour -- LargeOrderPromo

order = Order(john, cart, LargeOrderPromo())
assert order.total == 42.00
assert order.due == 42.00

long_order = [LineItem(str(item_code), 1.00)
              for item_code in range(10)]

order = Order(john, long_order, LargeOrderPromo())
assert order.total == 10.00
assert order.due == 9.30

Exercise: 🏠 BiggestPromo

  1. Implement a new promotion strategy (BiggestPromo) that let you combine multiple simple promotions and applies the biggest discount.
  2. This promotion strategy is different from the other. It accepts a list of other promotions in the constructor.
  3. Make BiggestPromo iterable.
  4. Use Builder and Prototype to simplify BiggestPromo creation.

Tests

### Expected Behaviour -- composite

promo = BiggestPromo([FidelityPromo(), LargeOrderPromo()])
order = Order(ann, long_order, promo)
assert order.total == 10.00
assert order.due == 9.30


### Expected Behaviour -- iterator

promos = [FidelityPromo(), LargeOrderPromo()]
promo = BiggestPromo(promos)
assert list(promo) == promos
for subpromo in promo:
    print(subpromo)
print(promo[1])


### Expected Behaviour -- builder + prototype

promo = (BiggestPromo()
    .added(FidelityPromo())
    .added(LargeOrderPromo()))
order = Order(ann, long_order, promo)
assert order.total == 10.00
assert order.due == 9.30

Command Pattern

The Command Pattern is a behavioral design pattern that turns requests or simple operations into objects. This allows you to parametrize methods with different requests, delay or queue a request’s execution, and support undoable operations. The Command Pattern encapsulates a request as an object, thereby allowing users to issue requests without knowing the details of the operations being requested.

Key Components of the Command Pattern:

  1. Command: An interface or abstract class that defines the execution method.
  2. Concrete Command: Implements the Command interface and performs the actual work by calling methods on the receiver.
  3. Receiver: The object that performs the actual action when the command is executed.
  4. Invoker: The object that initiates the command.
  5. Client: The class that creates and configures the command and the invoker.

When to Use the Command Pattern:

  • When you want to parameterize objects with operations (e.g., as in a queue of commands).
  • When you want to support undoable operations.
  • When you need to decouple the object that sends the request from the object that knows how to perform the operation.
  • When you need to log or queue requests and execute them later.

Example: Home Automation System

Imagine a home automation system where you can control different devices (lights, fans, etc.) with a remote control. The remote control can issue commands such as turning devices on or off, and you can undo the last command if needed.

Components:

  1. Command Interface: Defines the execute method.
  2. Concrete Commands: Implement the command interface to turn devices on or off.
  3. Receiver: The devices (light, fan) that will perform the actual action.
  4. Invoker: The remote control that triggers the commands.
# Command Interface
class Command:
    def execute(self):
        pass

# Concrete Commands
class LightOnCommand(Command):
    def __init__(self, light):
        self.light = light

    def execute(self):
        self.light.turn_on()

class LightOffCommand(Command):
    def __init__(self, light):
        self.light = light

    def execute(self):
        self.light.turn_off()

class FanOnCommand(Command):
    def __init__(self, fan):
        self.fan = fan

    def execute(self):
        self.fan.turn_on()

class FanOffCommand(Command):
    def __init__(self, fan):
        self.fan = fan

    def execute(self):
        self.fan.turn_off()

# Receiver: The devices
class Light:
    def turn_on(self):
        print("The light is on")

    def turn_off(self):
        print("The light is off")

class Fan:
    def turn_on(self):
        print("The fan is on")

    def turn_off(self):
        print("The fan is off")

# Invoker: Remote Control
class RemoteControl:
    def __init__(self):
        self.history = []

    def execute_command(self, command):
        command.execute()
        self.history.append(command)

# Client
light = Light()
fan = Fan()

light_on = LightOnCommand(light)
light_off = LightOffCommand(light)
fan_on = FanOnCommand(fan)
fan_off = FanOffCommand(fan)

remote = RemoteControl()

# Turning the devices on and off using the remote control
remote.execute_command(light_on)  # Output: The light is on
remote.execute_command(fan_on)    # Output: The fan is on
remote.execute_command(light_off) # Output: The light is off
remote.execute_command(fan_off)   # Output: The fan is off

Exercise: Text Editor

  1. You have a TextEditor that let you edit text. It has a clipboard and supports cut and paste operations. All of that is already implemented in TextEditor class.
  2. There is a new requirement: we need an infinite undo. Implement it.

Initial Code

class TextEditor:
    def __init__(self, text=''):
        self._text = text
        self._clipboard = ''

    def cut(self, start=0, end=0):
        self._clipboard = self._text[start:end]
        self._text = self._text[:start] + self._text[end:]

    def paste(self, offset=0):
        self._text = self._text[:offset] + self._clipboard + self._text[offset:]

    def clear_clipboard(self):
        self.clipboard = ''

    def set_text(self, text):
        self._text = text

    def __len__(self):
        return len(self.text)

    def __repr__(self):
        return f'Screen({self._text})'

class ScreenCommand:
    ...

class CutCommand(ScreenCommand):
    ...

class PasteCommand(ScreenCommand):
    ...

Tests

editor = TextEditor('hello world')
editor.cut(start=5, end=11)
assert editor._text == 'hello'

editor.paste(offset=0)
assert editor._text == ' worldhello'

editor.undo()
assert editor._text == 'hello'

editor.undo()
assert editor._text == 'hello world'

Solution

class TextEditor:
    def __init__(self, text=''):
        self._text = text
        self._clipboard = ''
        self._history = []  # złamane SRP

    def cut(self, start=0, end=0):
        cmd = CutCommand(self, start, end)
        self._execute_and_store(cmd)

    def paste(self, offset=0):
        cmd = PasteCommand(self, offset)
        self._execute_and_store(cmd)

    def _execute_and_store(self, cmd):
        cmd.execute()
        self._history.append(cmd)

    def clear_clipboard(self):
        self.clipboard = ''

    def set_text(self, text):
        self._text = text

    def undo(self):
        self._history.pop().undo()

    def __len__(self):
        return len(self.text)

    def __repr__(self):
        return f'Screen({self._text})'


class ScreenCommand:
    def __init__(self, editor):
        self._editor = editor
        self._executed = False

    def execute(self):
        if self._executed:
            raise Exception('Cannot execute multiple times')
        self._executed = True

    def undo(self):
        if not self._executed:
            raise Exception('Cannot undo a command that was not executed')


class CutCommand(ScreenCommand):
    def __init__(self, editor, start, end):
        super().__init__(editor)
        self._start = start
        self._end = end

    def execute(self):
        super().execute()
        self._previous_state = self._editor._text
        self._editor._clipboard = self._editor._text[self._start:self._end]
        self._editor._text = self._editor._text[:self._start] + self._editor._text[self._end:]

    def undo(self):
        super().undo()
#         self._editor.clear_clipboard()
        self._editor._text = self._previous_state


class PasteCommand(ScreenCommand):
    def __init__(self, editor, offset):
        super().__init__(editor)
        self._offset = offset

    def execute(self):
        super().execute()
        self._previous_state = self._editor._text
        self._editor._text = self._editor._text[:self._offset] + self._editor._clipboard + self._editor._text[self._offset:]

    def undo(self):
        super().undo()
        self._editor._text = self._previous_state

Exercise: Add Redo function to the editor

State Pattern

The State Pattern is a behavioral design pattern that allows an object to alter its behavior when its internal state changes. The pattern suggests that the object should change its class to reflect the current state, encapsulating state-specific behavior in separate state classes.

In simpler terms, the State Pattern allows an object to change its behavior at runtime depending on its internal state, without needing large if-else or switch-case statements to manage state transitions.

Key Components of the State Pattern:

  1. Context: The object that maintains a reference to the current state object. It delegates state-specific behavior to this state object.
  2. State Interface: Defines the interface that each state must implement.
  3. Concrete State: Implements the behavior associated with a particular state of the Context.

When to Use the State Pattern?

  • When an object’s behavior needs to change based on its internal state, and you want to avoid complex conditional logic (if-else, switch-case).
  • When an object can be in multiple states, and these states are subject to change at runtime.
  • When you want to make the code more maintainable and scalable by separating state-specific behavior into different classes.

Use Cases:

  • A Turnstile: A turnstile's behavior changes based on whether it is locked or unlocked. Passing through it or inserting a coin causes state transitions.
  • Blog Posts: Blog posts can move between states like draft, review, and published, with different behaviors for each state.
  • Purchase in an Online Shop: An online purchase can move through states such as cart, checkout, paid, shipped, and delivered, with actions changing based on the current state.
  • Phone Buttons: The behavior of phone buttons changes depending on whether the phone is locked or unlocked.
  • TV Remote: A TV remote's on/off button changes the behavior of the TV depending on its current state (on or off).
  • Audio Player: An audio player has different states (locked, playing, paused, stopped) that affect the behavior of buttons like play, next, and previous.
  • Finite-State Machines: Any system that can be modeled as a finite-state machine (e.g., elevators, ATMs, vending machines) benefits from the State Pattern, where actions depend on the current state.

Example: TurnStile

Without state

class Turnstile:
    def __init__(self):
        self._state = 'locked'

    def coin(self):
        if self._state == 'locked':
            self.unlock()
            self._state = 'unlocked'
        else:
            assert self._state == 'unlocked'
            self.display('Thank you')

    def pass_(self):
        if self._state == 'locked':
            self.alarm()
        else:
            assert self._state == 'unlocked'
            self.lock()
            self._state = 'locked'

    def unlock(self):
        print('unlocking turnstile')

    def lock(self):
        print('locking turnstile')

    def display(self, text):
        print(f'>>> {text} <<<')

    def alarm(self):
        print('alarm!')
t = Turnstile()
t.coin()
t.pass_()
t.pass_()
t.coin()
t.pass_()
t.coin()
t.coin()
unlocking turnstile
locking turnstile
alarm!
unlocking turnstile
locking turnstile
unlocking turnstile
>>> Thank you <<<

With State Pattern

class State:
    def __init__(self, turnstile):
        self.turnstile = turnstile

    def coin(self):
        raise NotImplementedError

    def pass_(self):
        raise NotImplementedError


class LockedState(State):
    def coin(self):
        self.turnstile.unlock()
        self.turnstile.set_state(UnlockedState)

    def pass_(self):
        self.turnstile.alarm()


class UnlockedState(State):
    def coin(self):
        self.turnstile.display("Thank you")

    def pass_(self):
        self.turnstile.lock()
        self.turnstile.set_state(LockedState)


class Turnstile:
    def __init__(self):
        self._state = LockedState(self)

    def coin(self):
        self._state.coin()

    def pass_(self):
        self._state.pass_()

    def unlock(self):
        print('unlocking turnstile')

    def lock(self):
        print('locking turnstile')

    def display(self, text):
        print(f'>>> {text} <<<')

    def alarm(self):
        print('alarm!')

    def set_state(self, state_cls):
        self._state = state_cls(self)
t = Turnstile()
t.coin()
t.pass_()
t.pass_()
t.coin()
t.pass_()
t.coin()
t.coin()
t.coin()
unlocking turnstile
locking turnstile
alarm!
unlocking turnstile
locking turnstile
unlocking turnstile
>>> Thank you <<<
>>> Thank you <<<

Example: Traffic Light System

Imagine a traffic light system where the behavior changes based on whether the light is Green, Yellow, or Red. The system transitions between these states cyclically, and each state has its own behavior when transitioning to the next.

Components:

  1. State Interface: Defines the behavior for traffic light transitions.
  2. Concrete States: Green, Yellow, and Red light states.
  3. Context: The traffic light object that holds the current state and delegates behavior to the state classes.
# State Interface
class TrafficLightState:
    def next_state(self, traffic_light):
        pass

# Concrete States
class GreenLightState(TrafficLightState):
    def next_state(self, traffic_light):
        print("Green Light - Turning to Yellow")
        traffic_light.set_state(YellowLightState())

class YellowLightState(TrafficLightState):
    def next_state(self, traffic_light):
        print("Yellow Light - Turning to Red")
        traffic_light.set_state(RedLightState())

class RedLightState(TrafficLightState):
    def next_state(self, traffic_light):
        print("Red Light - Turning to Green")
        traffic_light.set_state(GreenLightState())

# Context: Traffic Light
class TrafficLight:
    def __init__(self):
        self.state = RedLightState()  # Initial state is Red

    def set_state(self, state: TrafficLightState):
        self.state = state

    def change(self):
        self.state.next_state(self)

# Client
traffic_light = TrafficLight()

# Simulate the traffic light changing states
for _ in range(6):  # Loop through the state changes
    traffic_light.change()
Red Light - Turning to Green
Green Light - Turning to Yellow
Yellow Light - Turning to Red
Red Light - Turning to Green
Green Light - Turning to Yellow
Yellow Light - Turning to Red

Memento Pattern

The Memento Pattern is a behavioral design pattern that allows you to capture and store the current state of an object so that it can be restored later, without exposing the details of its implementation. The key idea is to provide a way to roll back to a previous state when needed, making it useful for implementing undo/redo functionality in applications.

Key Components of the Memento Pattern:

  1. Memento: This is an object that stores the state of another object. It doesn't allow direct access to its internal state to prevent unintended modifications.
  2. Originator: The object whose state needs to be saved and restored. It creates a Memento containing its current state and uses the Memento to restore its state.
  3. Caretaker: This is the object responsible for keeping track of the Memento(s). It does not modify or inspect the contents of the Memento, but it knows when to store or retrieve the Memento.

When to Use the Memento Pattern:

  • When you need to save and restore an object’s state (e.g., implementing undo/redo functionality).
  • When you want to store snapshots of an object’s state without exposing its internal structure.
  • When you want to allow rolling back to a previous state without violating encapsulation.

Example: Text Editor with Undo/Redo

In this example, we’ll simulate a simple text editor where the user can write text, and the system can undo or redo the last changes.

# Memento: Stores the state of the text editor
class TextEditorMemento:
    def __init__(self, content):
        self._content = content

    def get_saved_content(self):
        return self._content

# Originator: The text editor that creates and restores mementos
class TextEditor:
    def __init__(self):
        self._content = ""

    def write(self, text):
        self._content += text

    def save(self):
        return TextEditorMemento(self._content)  # Save the current state

    def restore(self, memento):
        self._content = memento.get_saved_content()  # Restore the state

    def show_content(self):
        print(self._content)

# Caretaker: Manages the history of mementos
class History:
    def __init__(self):
        self._mementos = []

    def push(self, memento):
        self._mementos.append(memento)

    def pop(self):
        if self._mementos:
            return self._mementos.pop()
        return None

# Client code: Test undo functionality in the text editor
editor = TextEditor()
history = History()

# Writing to the text editor
editor.write("Hello, ")
history.push(editor.save())  # Save state

editor.write("world!")
history.push(editor.save())  # Save state

editor.show_content()  # Output: Hello, world!

# Undo last change
editor.restore(history.pop())
editor.show_content()  # Output: Hello,

# Undo again
editor.restore(history.pop())
editor.show_content()  # Output: (empty)

Example: Game Checkpoints

In this example, we’ll simulate a game where players can save their progress and load a checkpoint to restore a previous state.

# Memento: Stores the state of the game (player's score and level)
class GameMemento:
    def __init__(self, level, score):
        self._level = level
        self._score = score

    def get_saved_state(self):
        return (self._level, self._score)

# Originator: The game that creates and restores mementos
class Game:
    def __init__(self):
        self._level = 1
        self._score = 0

    def play(self, level, score):
        self._level = level
        self._score = score

    def save(self):
        return GameMemento(self._level, self._score)

    def restore(self, memento):
        self._level, self._score = memento.get_saved_state()

    def show_state(self):
        print(f"Level: {self._level}, Score: {self._score}")

# Caretaker: Manages the saved checkpoints
class CheckpointManager:
    def __init__(self):
        self._checkpoints = []

    def save_checkpoint(self, memento):
        self._checkpoints.append(memento)

    def load_checkpoint(self):
        if self._checkpoints:
            return self._checkpoints.pop()
        return None

# Client code: Test checkpoint functionality in the game
game = Game()
manager = CheckpointManager()

# Playing the game
game.play(2, 500)
manager.save_checkpoint(game.save())  # Save checkpoint

game.play(3, 900)
manager.save_checkpoint(game.save())  # Save checkpoint

game.show_state()  # Output: Level: 3, Score: 900

# Restore to the last checkpoint
game.restore(manager.load_checkpoint())
game.restore(manager.load_checkpoint())
game.show_state()  # Output: Level: 2, Score: 500
Level: 3, Score: 900
Level: 2, Score: 500

Exercise: 🏠 Screen

  1. Implement create_memento() and set_memento(memento) methods in Screen class.
  2. The state of a Screen is a pair of self._text and self._clipboard.
  3. Memento should be a bytes array, so that later on you can store it in a file.
  4. Use pickle.dumps and pickle.loads methods to create/load mementos.
  5. ★ The above implementation depends on the fields of your class. Implement it in a generic way. You can get object state from self.dict. Make sure your code is reusable, that is you can use it for other classes.

Initial Code (without state)

class Screen:
    def __init__(self, text=''):
        self._text = text
        self._clipboard = ''

    def cut(self, start=0, end=0):
        self._clipboard = self._text[start:end]
        self._text = self._text[:start] + self._text[end:]

    def paste(self, offset=0):
        self._text = self._text[:offset] + self._clipboard + self._text[offset:]

    def clear_clipboard(self):
        self.clipboard = ''

    def __len__(self):
        return len(self.text)

    def __repr__(self):
        return 'Screen({})'.format(repr(self._text))

    def create_memento(self):
        pass
        # Your Code Here

    def set_memento(self, memento):
        pass
        # Your Code Here

Test

### Expected Behaviour

screen = Screen('hello world')
assert screen._text == 'hello world'

screen.cut(start=6, end=11)
assert screen._text == 'hello '

memento = screen.create_memento()

screen.paste(offset=0)
assert screen._text == 'worldhello '

screen.set_memento(memento)
assert screen._text == 'hello '

Solution

Exercise: 🏠 Screen with Undo

  1. Implement UndoableMixin class and @undoable decorator.
  2. Any class inheriting from the mixin should store state history and should provide undo() method.
  3. You create a new snapchot in the history only for methods decorated with @undoable. That is, cut and paste create a new snapchot. However, clear_clipboard should not create an entry in the history.
    • Implement redo() functionality.

Initial Code

class TextEditor(UndoableMixin):
    def __init__(self, text=''):
        super().__init__()
        self._text = text
        self._clipboard = ''

    @undoable
    def cut(self, start=0, end=0):
        self._clipboard = self._text[start:end]
        self._text = self._text[:start] + self._text[end:]

    @undoable
    def paste(self, offset=0):
        self._text = self._text[:offset] + self._clipboard + self._text[offset:]

    def clear_clipboard(self):
        self.clipboard = ''

    @undoable
    def set_text(self, text):
        self._text = text

    def __len__(self):
        return len(self.text)

    def __repr__(self):
        return f'Screen({self._text})'
Tests
editor = TextEditor('hello world')
editor.cut(start=5, end=11)
assert editor._text == 'hello', editor._text

editor.paste(offset=0)
assert editor._text == ' worldhello', editor._text

editor.undo()
assert editor._text == 'hello', editor._text

editor.clear_clipboard()

editor.undo()
assert editor._text == 'hello world', editor._text
editor.redo()
assert editor._text == 'hello', editor._text

editor.redo()
assert editor._text == ' worldhello', editor._text
editor.undo()
assert editor._text == 'hello', editor._text

editor.cut(0, 1)
assert editor._text == 'ello', editor._text

import pytest
with pytest.raises(IndexError):
    editor.redo()  # ==> raises IndexError

Solution

Visitor Pattern

The Visitor Pattern is a behavioral design pattern that allows you to separate algorithms from the objects on which they operate. With the Visitor Pattern, you can define new operations without changing the classes of the elements on which it operates. This pattern is particularly useful when you have a structure of objects (such as a tree or a collection) and you need to perform different operations on these objects based on their concrete types.

Key Components of the Visitor Pattern:

  1. Visitor Interface: Declares methods for visiting each type of element in the structure.
  2. Concrete Visitor: Implements the operations defined in the visitor interface for each type of element.
  3. Element Interface: Declares an accept method that takes a visitor object as an argument.
  4. Concrete Element: Implements the accept method by calling the appropriate visitor method.
  5. Object Structure: A collection of elements that may be visited by the visitor.

When to Use the Visitor Pattern?

  • When you need to perform many unrelated operations on objects in a structure, but want to avoid cluttering the classes with these operations.
  • When the operations on a structure of objects change more frequently than the objects themselves.
  • When you want to add new operations to an existing class hierarchy without modifying it.

Example: File System Visitor

In this example, we'll simulate a file system where different operations (like calculating the size or counting files) can be performed on files and directories using the Visitor Pattern.

class FileSystemVisitor:
    def visit_file(self, file):
        pass

    def visit_directory(self, directory):
        pass

# Concrete Visitor: Size Calculator
class SizeCalculatorVisitor(FileSystemVisitor):
    def __init__(self):
        self.total_size = 0

    def visit_file(self, file):
        self.total_size += file.size

    def visit_directory(self, directory):
        # Size of the directory itself can be included if needed
        pass

# Concrete Visitor: File Counter
class FileCounterVisitor(FileSystemVisitor):
    def __init__(self):
        self.file_count = 0

    def visit_file(self, file):
        self.file_count += 1

    def visit_directory(self, directory):
        # No specific counting for directories
        pass

# Element Interface
class FileSystemElement:
    def accept(self, visitor: FileSystemVisitor):
        pass

# Concrete Element: File
class File(FileSystemElement):
    def __init__(self, name, size):
        self.name = name
        self.size = size

    def accept(self, visitor: FileSystemVisitor):
        visitor.visit_file(self)

# Concrete Element: Directory
class Directory(FileSystemElement):
    def __init__(self, name):
        self.name = name
        self.children = []

    def add(self, element: FileSystemElement):
        self.children.append(element)

    def accept(self, visitor: FileSystemVisitor):
        visitor.visit_directory(self)
        for child in self.children:
            child.accept(visitor)

# Client code: Simulate a file system
root = Directory("root")
file1 = File("file1.txt", 100)
file2 = File("file2.txt", 200)
subdir = Directory("subdir")
file3 = File("file3.txt", 300)

# Build the file system hierarchy
root.add(file1)
root.add(file2)
subdir.add(file3)
root.add(subdir)

# Use the SizeCalculatorVisitor to calculate total size
size_visitor = SizeCalculatorVisitor()
root.accept(size_visitor)
print(f"Total size: {size_visitor.total_size}")  # Output: Total size: 600

# Use the FileCounterVisitor to count files
file_counter = FileCounterVisitor()
root.accept(file_counter)
print(f"Total files: {file_counter.file_count}")  # Output: Total files: 3
Total size: 600
Total files: 3