8.3. Factory Method

  • EN: Factory Method

  • PL: Metoda wytwórcza

  • Type: class

The Factory Method design pattern is a creational design pattern that provides an interface for creating objects in a superclass, but allows subclasses to alter the type of objects that will be created.

Here's a simple example of the Factory Method pattern in Python:

>>> class Creator:
...     def factory_method(self):
...         pass
...
...     def some_operation(self):
...         product = self.factory_method()
...         result = f"Creator: The same creator's code has just worked with {product.operation()}"
...         return result
...
>>> class ConcreteCreator1(Creator):
...     def factory_method(self):
...         return ConcreteProduct1()
...
>>> class ConcreteCreator2(Creator):
...     def factory_method(self):
...         return ConcreteProduct2()
...
>>> class Product:
...     def operation(self):
...         pass
...
>>> class ConcreteProduct1(Product):
...     def operation(self):
...         return "{Result of the ConcreteProduct1}"
...
>>> class ConcreteProduct2(Product):
...     def operation(self):
...         return "{Result of the ConcreteProduct2}"
...
>>> creator1 = ConcreteCreator1()
>>> creator1.some_operation()
"Creator: The same creator's code has just worked with {Result of the ConcreteProduct1}"
>>> creator2 = ConcreteCreator2()
>>> creator2.some_operation()
"Creator: The same creator's code has just worked with {Result of the ConcreteProduct2}"

In this example, Creator is an abstract class that declares the factory method that returns new product objects. ConcreteCreator1 and ConcreteCreator2 are concrete classes that implement the factory method to produce products of ConcreteProduct1 and ConcreteProduct2 types respectively. Product is an interface for the products, and ConcreteProduct1 and ConcreteProduct2 are concrete products that implement the Product interface.

8.3.1. Pattern

  • Defer the creation of an object to subclasses

  • Relays on inheritance and polymorphism

  • Adds flexibility to the design

../../_images/designpatterns-factorymethod-pattern.png

8.3.2. Problem

  • Tightly coupled with MatchaEngine

  • What if we have better templating engine

../../_images/designpatterns-factorymethod-problem.png

from abc import ABC, abstractmethod
from typing import Any


TEMPLATE = """

<h1>Products</h1>

{% for product in products %}
    <p>{{ product.title }}</p>

"""

class ViewEngine(ABC):
    @abstractmethod
    def render(self, view_name: str, context: dict[str, Any]): ...


class MatchaViewEngine(ViewEngine):
    def render(self, view_name: str, context: dict[str, Any]) -> str:
        return 'View rendered by Matcha'


class Controller:
    def render(self, view_name: str, context: dict[str, Any], engine: ViewEngine) -> None:
        html = engine.render(view_name, context)
        print(html)


class ProductsController(Controller):
    def list_products(self) -> None:
        context: dict[str, Any] = {}
        # get products from a database
        # context[products] = products
        self.render('products.html', context, MatchaViewEngine())

8.3.3. Solution

../../_images/designpatterns-factorymethod-solution.png

from abc import ABC, abstractmethod
from typing import Any


TEMPLATE = """

<h1>Products</h1>

{% for product in products %}
    <p>{{ product.title }}</p>

"""

class ViewEngine(ABC):
    @abstractmethod
    def render(self, view_name: str, context: dict[str, Any]): ...



class MatchaViewEngine(ViewEngine):
    def render(self, view_name: str, context: dict[str, Any]) -> str:
        return 'View rendered by Matcha'

class Controller:
    def _create_view_engine(self) -> ViewEngine:
        return MatchaViewEngine()

    def render(self, view_name: str, context: dict[str, Any]) -> None:
        engine = self._create_view_engine()
        html = engine.render(view_name, context)
        print(html)



class SharpViewEngine(ViewEngine):
    def render(self, view_name: str, context: dict[str, Any]):
        return 'View rendered by Sharp'

class SharpController(Controller):
    def _create_view_engine(self) -> ViewEngine:
        return SharpViewEngine()



class ProductsController(SharpController):
    def list_products(self) -> None:
        context: dict[str, Any] = {}
        # get products from a database
        # context[products] = products
        self.render('products.html', context)


if __name__ == '__main__':
    ProductsController().list_products()

8.3.4. Use Case - 0x01

class Setosa:
    pass

class Versicolor:
    pass

class Virginica:
    pass


def iris_factory(species):
    if species == 'setosa':
        return Setosa
    elif species == 'versicolor':
        return Versicolor
    elif species == 'virginica':
        return Virginica
    else:
        raise NotImplementedError


if __name__ == '__main__':
    iris = iris_factory('setosa')
    print(iris)
    # <class '__main__.Setosa'>

    iris = iris_factory('virginica')
    print(iris)
    # <class '__main__.Virginica'>

    iris = iris_factory('arctica')
    print(iris)
    # Traceback (most recent call last):
    # NotImplementedError

8.3.5. Use Case - 0x02

class Setosa:
    pass

class Versicolor:
    pass

class Virginica:
    pass


def iris_factory(species):
    cls = {
        'setosa': Setosa,
        'versicolor': Versicolor,
        'virginica': Virginica,
    }.get(species, None)

    if not cls:
        raise NotImplementedError
    else:
        return cls


if __name__ == '__main__':
    iris = iris_factory('setosa')
    print(iris)
    # <class '__main__.Setosa'>

    iris = iris_factory('virginica')
    print(iris)
    # <class '__main__.Virginica'>

    iris = iris_factory('arctica')
    print(iris)
    # Traceback (most recent call last):
    # NotImplementedError

8.3.6. Use Case - 0x03

class Setosa:
    pass

class Virginica:
    pass

class Versicolor:
    pass


def iris_factory(species):
    try:
        classname = species.capitalize()
        return globals()[classname]
    except KeyError:
        raise NotImplementedError


if __name__ == '__main__':
    iris = iris_factory('setosa')
    print(iris)
    # <class '__main__.Setosa'>

    iris = iris_factory('virginica')
    print(iris)
    # <class '__main__.Virginica'>

    iris = iris_factory('arctica')
    print(iris)
    # Traceback (most recent call last):
    # NotImplementedError

8.3.7. Use Case - 0x04

class PDF:
    pass

class TXT:
    pass


class File:
    def __new__(cls, *args, **kwargs):
        filename, extension = args[0].split('.')
        if extension == 'pdf':
            return PDF()
        elif extension == 'txt':
            return TXT()


if __name__ == '__main__':
    file = File('myfile.pdf')
    print(file)
    # <__main__.PDF object at 0x...>

    file = File('myfile.txt')
    print(file)
    # <__main__.TXT object at 0x...>

8.3.8. Use Case - 0x05

from abc import ABC, abstractproperty


class Document(ABC):
    @abstractproperty
    @property
    def extension(self):
        return

    def __new__(cls, filename, *args, **kwargs):
        name, extension = filename.split('.')
        for cls in Document.__subclasses__():
            if cls.extension == extension:
                return super().__new__(cls)
        else:
            raise NotImplementedError('File format unknown')


class PDF(Document):
    extension = 'pdf'

class Txt(Document):
    extension = 'txt'

class Word(Document):
    extension = 'docx'


if __name__ == '__main__':
    file = Document('myfile.txt')
    print(type(file))
    # <class '__main__.Txt'>

    file = Document('myfile.pdf')
    print(type(file))
    # <class '__main__.PDF'>

8.3.9. Use Case - 0x06

from abc import ABC, abstractproperty, abstractmethod
from dataclasses import dataclass


@dataclass
class ConfigParser(ABC):
    filename: str

    @abstractproperty
    @property
    def extension(self):
        pass

    def show(self):
        content = self.read()
        return self.parse(content)

    @abstractmethod
    def parse(self, content: str) -> dict:
        return NotImplementedError

    def read(self):
        with open(self.filename) as file:
            return file.read()

    def __new__(cls, filename, *args, **kwargs):
        _, extension = filename.split('.')
        for parser in cls.__subclasses__():
            if parser.extension == extension:
                instance = super().__new__(parser)
                instance.__init__(filename)
                return instance
        else:
            raise NotImplementedError('Parser for given file type not found')


class ConfigParserINI(ConfigParser):
    extension = 'ini'

    def parse(self, content: str) -> dict:
        print('Parsing INI file')


class ConfigParserCSV(ConfigParser):
    extension = 'csv'

    def parse(self, content: str) -> dict:
        print('Parsing CSV file')


class ConfigParserYAML(ConfigParser):
    extension = 'yaml'

    def parse(self, content: str) -> dict:
        print('Parsing YAML file')


class ConfigFileJSON(ConfigParser):
    extension = 'json'

    def parse(self, content: str) -> dict:
        print('Parsing JSON file')


class ConfigFileXML(ConfigParser):
    extension = 'xml'

    def parse(self, content: str) -> dict:
        print('Parsing XML file')


if __name__ == '__main__':
    # iris.csv or *.csv, *.json *.yaml...
    # filename = input('Type filename: ')
    config = ConfigParser('/tmp/myfile.json')
    config.show()

8.3.10. Use Case - 0x07

import os


class HttpClientInterface:
    def GET(self):
        raise NotImplementedError

    def POST(self):
        raise NotImplementedError


class GatewayLive(HttpClientInterface):
    def GET(self):
        print('Execute GET request over network')
        return ...

    def POST(self):
        print('Execute POST request over network')
        return ...


class GatewayStub(HttpClientInterface):
    def GET(self):
        print('Returning stub GET')
        return {'firstname': 'Mark', 'lastname': 'Watney'}

    def POST(self):
        print('Returning stub POST')
        return {'status': 200, 'reason': 'OK'}


class HttpGatewayFactory:
    def __new__(cls, *args, **kwargs):
        if os.getenv('ENVIRONMENT') == 'production':
            return GatewayLive()
        else:
            return GatewayStub()


if __name__ == '__main__':
    os.environ['ENVIRONMENT'] = 'testing'

    client = HttpGatewayFactory()
    result = client.GET()
    # Returning stub GET
    result = client.POST()
    # Returning stub POST

    os.environ['ENVIRONMENT'] = 'production'

    client = HttpGatewayFactory()
    result = client.GET()
    # Execute GET request over network
    result = client.POST()
    # Execute POST request over network

8.3.11. Use Case - 0x08

from abc import ABC, abstractmethod


class Path(ABC):
    def __new__(cls, path, *args, **kwargs):
        if path.startswith(r'C:\Users'):
            instance = object.__new__(WindowsPath)
        if path.startswith('/home'):
            return object.__new__(LinuxPath)
        if path.startswith('/Users'):
            return object.__new__(macOSPath)
        instance.__init__(path)
        return instance

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

    @abstractmethod
    def dir_create(self): pass

    @abstractmethod
    def dir_list(self): pass

    @abstractmethod
    def dir_remove(self): pass


class WindowsPath(Path):
    def dir_create(self):
        print('create directory on ')

    def dir_list(self):
        print('list directory on ')

    def dir_remove(self):
        print('remove directory on ')


class LinuxPath(Path):
    def dir_create(self):
        print('create directory on ')

    def dir_list(self):
        print('list directory on ')

    def dir_remove(self):
        print('remove directory on ')


class macOSPath(Path):
    def dir_create(self):
        print('create directory on ')

    def dir_list(self):
        print('list directory on ')

    def dir_remove(self):
        print('remove directory on ')


if __name__ == '__main__':
    file = Path(r'C:\Users\MWatney\myfile.txt')
    print(type(file))
    # <class '__main__.WindowsPath'>

    file = Path(r'/home/mwatney/myfile.txt')
    print(type(file))
    # <class '__main__.LinuxPath'>

    file = Path(r'/Users/mwatney/myfile.txt')
    print(type(file))
    # <class '__main__.macOSPath'>

8.3.12. Assignments

Code 8.55. Solution
"""
* Assignment: DesignPatterns Creational FactoryMethod
* Complexity: easy
* Lines of code: 6 lines
* Time: 8 min

English:
    1. Create polymorphism factory `iris` producing instances of `Iris`
    2. Separate `values` from `species` in each row
    3. Create instances of:
        a. class `Setosa` if `species` is "setosa"
        b. class `Versicolor` if `species` is "versicolor"
        c. class `Virginica` if `species` is "virginica"
    4. Initialize instances with `values`
    5. Run doctests - all must succeed

Polish:
    1. Stwórz fabrykę abstrakcyjną `iris` tworzącą instancje klasy `Iris`
    2. Odseparuj `values` od `species` w każdym wierszu
    3. Stwórz instancje:
        a. klasy `Setosa` jeżeli `species` to "setosa"
        b. klasy `Versicolor` jeżeli `species` to "versicolor"
        c. klasy `Virginica` jeżeli `species` to "virginica"
    4. Instancje inicjalizuj danymi z `values`
    5. Uruchom doctesty - wszystkie muszą się powieść

Hints:
    * `globals()[classname]`

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from pprint import pprint

    >>> result = map(iris, DATA[1:])
    >>> pprint(list(result), width=120)
    [Virginica(sepal_length=5.8, sepal_width=2.7, petal_length=5.1, petal_width=1.9),
     Setosa(sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2),
     Versicolor(sepal_length=5.7, sepal_width=2.8, petal_length=4.1, petal_width=1.3),
     Virginica(sepal_length=6.3, sepal_width=2.9, petal_length=5.6, petal_width=1.8),
     Versicolor(sepal_length=6.4, sepal_width=3.2, petal_length=4.5, petal_width=1.5),
     Setosa(sepal_length=4.7, sepal_width=3.2, petal_length=1.3, petal_width=0.2)]
"""

from dataclasses import dataclass


DATA = [
    ('sepal_length', 'sepal_width', 'petal_length', 'petal_width', 'species'),
    (5.8, 2.7, 5.1, 1.9, 'virginica'),
    (5.1, 3.5, 1.4, 0.2, 'setosa'),
    (5.7, 2.8, 4.1, 1.3, 'versicolor'),
    (6.3, 2.9, 5.6, 1.8, 'virginica'),
    (6.4, 3.2, 4.5, 1.5, 'versicolor'),
    (4.7, 3.2, 1.3, 0.2, 'setosa'),
]


@dataclass
class Iris:
    sepal_length: float
    sepal_width: float
    petal_length: float
    petal_width: float

class Setosa(Iris):
    pass

class Versicolor(Iris):
    pass

class Virginica(Iris):
    pass


# Skip header and separate `values` from `species` in each row
# Create instances of: `Setosa`, `Versicolor`, `Virginica` based on `species`
# Initialize instances with `values`
# type: Callable[[tuple[float,float,float,float,str], Iris]]
def iris(row: tuple[float,float,float,float,str]) -> Iris:
    ...