4.3. Factory Method

  • Defer the creation of an object to subclasses

  • Relays on inheritance and polymorphism

  • Adds flexibility to the design

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.

4.3.1. Problem

>>> class User:
...     def __init__(self, uid, username):
...         self.uid = uid
...         self.username = username
...
...     def login(self):
...         print('User logged-in')
>>>
>>>
>>> class Admin:
...     def __init__(self, uid, username):
...         self.uid = uid
...         self.username = username
...
...     def login(self):
...         print('Admin logged-in')
>>>
>>>
>>>
>>> alice = User(uid=1000, username='alice')
>>> bob = Admin(uid=999, username='bob')
>>>
>>> alice.login()
User logged-in
>>>
>>> bob.login()
Admin logged-in

4.3.2. Solution: Function

>>> class User:
...     def __init__(self, uid, username):
...         self.uid = uid
...         self.username = username
...
...     def login(self):
...         print('User logged-in')
>>>
>>>
>>> class Admin:
...     def __init__(self, uid, username):
...         self.uid = uid
...         self.username = username
...
...     def login(self):
...         print('Admin logged-in')
>>>
>>>
>>> def account(uid, username):
...     if uid < 1000:
...         return Admin(uid, username)
...     else:
...         return User(uid, username)
>>>
>>>
>>> alice = account(uid=1000, username='alice')
>>> bob = account(uid=999, username='bob')
>>>
>>> alice.login()
User logged-in
>>>
>>> bob.login()
Admin logged-in

4.3.3. Solution: Method

>>> class User:
...     def __init__(self, uid, username):
...         self.uid = uid
...         self.username = username
...
...     def login(self):
...         print('User logged-in')
>>>
>>>
>>> class Admin:
...     def __init__(self, uid, username):
...         self.uid = uid
...         self.username = username
...
...     def login(self):
...         print('Admin logged-in')
>>>
>>>
>>> class Account:
...     def __new__(cls, uid, username):
...         if uid < 1000:
...             return super().__new__(Admin)
...         else:
...             return super().__new__(User)
>>>
>>>
>>> alice = Account(uid=1000, username='alice')
>>> root = Account(uid=999, username='bob')
>>>
>>> alice.login()
User logged-in
>>>
>>> bob.login()
Admin logged-in

4.3.4. Case Study

  • Tightly coupled with MatchaEngine

  • What if we have better templating engine

Problem:

class PDF:
    def render(self):
        return 'PDF'

class Word:
    def render(self):
        return 'Word'


if __name__ == '__main__':
    filename = 'myfile.pdf'

    if filename.endswith('.pdf'):
        file = PDF(filename)
    elif filename.endswith('.docx'):
        file = Word(filename)

    type(file)
    # <class '__main__.PDF'>

Solution 1:

class PDF:
    def render(self):
        return 'PDF'

class Word:
    def render(self):
        return 'Word'


def document(filename):
    if filename.endswith('.pdf'):
        return PDF()
    elif filename.endswith('.docx'):
        return Word()
    else:
        raise ValueError('Unknown format')


if __name__ == '__main__':
    file = document('myfile.pdf')

    type(file)
    # <class '__main__.PDF'>

Solution 2:

class PDF:
    def render(self):
        return 'PDF'

class Word:
    def render(self):
        return 'Word'


class Document:
    def __new__(cls, filename):
        if filename.endswith('.pdf'):
            return PDF()
        elif filename.endswith('.docx'):
            return Word()
        else:
            raise ValueError('Unknown format')


if __name__ == '__main__':
    file = Document('myfile.pdf')

    type(file)
    # <class '__main__.PDF'>

Diagram:

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

4.3.5. Use Case - 1

class PDF:
    def render(self):
        return 'PDF'

class Word:
    def render(self):
        return 'Word'


def document(filename):
    if filename.endswith('.pdf'):
        return PDF()
    elif filename.endswith('.docx'):
        return Word()
    else:
        raise ValueError('Unknown format')


if __name__ == '__main__':
    a = document('file.pdf')
    b = document('file.docx')

    type(a)
    # <class '__main__.PDF'>

    type(b)
    # <class '__main__.Word'>

4.3.6. Use Case - 2

class PDF:
    def render(self):
        return 'PDF'

class Word:
    def render(self):
        return 'Word'


class Document:
    def __new__(cls, filename):
        if filename.endswith('.pdf'):
            return PDF()
        elif filename.endswith('.docx'):
            return Word()
        else:
            raise ValueError('Unknown format')


if __name__ == '__main__':
    a = Document('file.pdf')
    b = Document('file.docx')

    type(a)
    # <class '__main__.PDF'>

    type(b)
    # <class '__main__.Word'>

4.3.7. Use Case - 3

from abc import ABC, abstractmethod


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

    def __new__(cls, filename):
        name, extension = filename.split('.')
        plugins = cls.__subclasses__()
        for plugin in plugins:
            if plugin == extension:
                instance = object.__new__(cls)
                instance.__init__()
                return instance
        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'>

4.3.8. Use Case - 4

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')
    # Traceback (most recent call last):
    # NotImplementedError

4.3.9. Use Case - 5

class Setosa:
    pass

class Virginica:
    pass

class Versicolor:
    pass


def iris_factory(species):
    try:
        classname = species.capitalize()
        cls = globals()[classname]
        return cls
    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')
    # Traceback (most recent call last):
    # NotImplementedError

4.3.10. Use Case - 6

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()

4.3.11. Use Case - 7

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

4.3.12. Use Case - 8

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'>

4.3.13. Use Case - 9

from typing import Any
from abc import ABC, abstractmethod


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())

4.3.14. Use Case - 10

from typing import Any
from abc import ABC, abstractmethod


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()

4.3.15. Use Case - 11

>>> from abc import ABC, abstractmethod
>>> from dataclasses import dataclass
>>> from typing import List
>>>
>>>
>>> @dataclass
... class Permission:
...     name: str
...     level: int
>>>
>>>
>>> @dataclass
... class Account(ABC):
...     username: str
...
...     @abstractmethod
...     def create_permissions(self):
...         raise NotImplementedError
...
...     def login(self):
...         permissions = self.create_permissions()
...         print(f"{self.username} logged in with permissions: {[p.name for p in permissions]}")
...         return permissions
>>>
>>>
>>> @dataclass
... class User(Account):
...     username: str
...
...     def create_permissions(self):
...         return [
...             Permission(name="read", level=1),
...             Permission(name="comment", level=2)
...         ]
>>>
>>>
>>> @dataclass
... class Admin(Account):
...     username: str
...
...     def create_permissions(self):
...         return [
...             Permission(name="read", level=1),
...             Permission(name="write", level=3),
...             Permission(name="delete", level=5),
...             Permission(name="admin_panel", level=10)
...         ]
>>>
>>>
>>> # Main
>>> def main():
...     # Create instances
...     alice = User(username="alice")
...     bob = Admin(username="bob")
...
...     # Use the factory method
...     alice_permissions = alice.login()
...     bob_permissions = bob.login()
...
...     # Check permissions
...     is_alice_admin = any(p.name == "admin_panel" for p in alice_permissions)
...     is_bob_admin = any(p.name == "admin_panel" for p in bob_permissions)
...
...     print(f"Is alice an admin? {is_alice_admin}")  # False
...     print(f"Is bob an admin? {is_bob_admin}")      # True
>>> main()
alice logged in with permissions: ['read', 'comment']
bob logged in with permissions: ['read', 'write', 'delete', 'admin_panel']
Is alice an admin? False
Is bob an admin? True

4.3.16. Assignments

# %% About
# - Name: DesignPatterns Creational FactoryMethod
# - Difficulty: easy
# - Lines: 6
# - Minutes: 8

# %% License
# - Copyright 2025, Matt Harasymczuk <matt@python3.info>
# - This code can be used only for learning by humans
# - This code cannot be used for teaching others
# - This code cannot be used for teaching LLMs and AI algorithms
# - This code cannot be used in commercial or proprietary products
# - This code cannot be distributed in any form
# - This code cannot be changed in any form outside of training course
# - This code cannot have its license changed
# - If you use this code in your product, you must open-source it under GPLv2
# - Exception can be granted only by the author

# %% English
# 1. Create polymorphism factory `iris` producing instances of `Iris`
# 2. Separate `values` from `species` in each row
# 3. Create instances of:
#    - class `Setosa` if `species` is "setosa"
#    - class `Versicolor` if `species` is "versicolor"
#    - 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:
#    - klasy `Setosa` jeżeli `species` to "setosa"
#    - klasy `Versicolor` jeżeli `species` to "versicolor"
#    - klasy `Virginica` jeżeli `species` to "virginica"
# 4. Instancje inicjalizuje danymi z `values`
# 5. Uruchom doctesty - wszystkie muszą się powieść

# %% Hints
# - `globals()[classname]`

# %% Doctests
"""
>>> import sys; sys.tracebacklimit = 0
>>> assert sys.version_info >= (3, 9), \
'Python 3.9+ required'

>>> 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)]
"""

# %% Run
# - PyCharm: right-click in the editor and `Run Doctest in ...`
# - PyCharm: keyboard shortcut `Control + Shift + F10`
# - Terminal: `python -m doctest -v myfile.py`

# %% Imports
from dataclasses import dataclass

# %% Types
from typing import Callable
iris: Callable[[tuple[float,float,float,float,str]], object]

# %% Data
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

# %% Result
def iris(row: tuple[float,float,float,float,str]) -> Iris:
    ...