11.5. Builder

  • EN: Builder

  • PL: Budowniczy

  • Type: object

  • Why: To separate the construction of an object from its representation

  • Why: The same construction algorithm can be applied to different representations

  • Usecase: Export data to different formats

11.5.1. Pattern

../../_images/designpatterns-builder-pattern.png

11.5.2. Problem

  • Violates Open/Close Principle

  • Tight coupling between Presentation class with formats

  • PDF has pages, Movies has frames, this knowledge belongs to somewhere else

  • Duplicated code

  • Magic number

../../_images/designpatterns-builder-problem.png

from enum import Enum


class Slide:
    text: str

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

    def get_text(self) -> str:
        return self.text


#%% Formats
class PresentationFormat(Enum):
    PDF = 1
    IMAGE = 2
    POWERPOINT = 3
    MOVIE = 4

class PDFDocument:
    def add_page(self, text: str) -> None:
        print('Adding a page to PDF')

class Movie:
    def add_frame(self, text: str, duration: int) -> None:
        print('Adding a frame to a movie')


#%% Main
class Presentation:
    slides: list[Slide]

    def __init__(self) -> None:
        self.slides = []

    def add_slide(self, slide: Slide) -> None:
        self.slides.append(slide)

    def export(self, format: PresentationFormat) -> None:
        if format == PresentationFormat.PDF:
            pdf = PDFDocument()
            pdf.add_page('Copyright')
            for slide in self.slides:
                pdf.add_page(slide.get_text())
        elif format == PresentationFormat.MOVIE:
            movie = Movie()
            movie.add_frame('Copyright', duration=3)
            for slide in self.slides:
                movie.add_frame(slide.get_text(), duration=3)

11.5.3. Solution

  • Use the builder pattern to separate the exporting logic from the presentation format

  • The same exporting logic belongs to the different formats

../../_images/designpatterns-builder-solution.png

from enum import Enum


class Slide:
    text: str

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

    def get_text(self) -> str:
        return self.text


class PresentationBuilder:
    def add_slide(self, slide: Slide) -> None:
        raise NotImplementedError


#%% Formats
class PresentationFormat(Enum):
    PDF = 1
    IMAGE = 2
    POWERPOINT = 3
    MOVIE = 4

class PDFDocument:
    def add_page(self, text: str) -> None:
        print('Adding a page to PDF')

class Movie:
    def add_frame(self, text: str, duration: int) -> None:
        print('Adding a frame to a movie')

class PDFDocumentBuilder(PresentationBuilder):
    document: PDFDocument

    def __init__(self):
        self.document = PDFDocument()

    def add_slide(self, slide: Slide) -> None:
        self.document.add_page(slide.get_text())

    def get_pdf_document(self) -> PDFDocument:
        return self.document


class MovieBuilder(PresentationBuilder):
    movie: Movie

    def __init__(self):
        self.movie = Movie()

    def add_slide(self, slide: Slide) -> None:
        self.movie.add_frame(slide.get_text(), duration=3)

    def get_movie(self) -> Movie:
        return self.movie


#%% Main
class Presentation:
    slides: list[Slide]

    def __init__(self) -> None:
        self.slides = []

    def add_slide(self, slide: Slide) -> None:
        self.slides.append(slide)

    def export(self, builder: PresentationBuilder) -> None:
        builder.add_slide(Slide('Copyright'))
        for slide in self.slides:
            builder.add_slide(slide)


if __name__ == '__main__':
    presentation = Presentation()
    presentation.add_slide(Slide('Slide 1'))
    presentation.add_slide(Slide('Slide 2'))

    builder = PDFDocumentBuilder()
    presentation.export(builder)
    movie = builder.get_pdf_document()

    builder = MovieBuilder()
    presentation.export(builder)
    movie = builder.get_movie()

11.5.4. Use Case - 0x01

class ReadCSV:
    filename: str
    delimiter: str
    encoding: str
    chunksize: int

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

    def withChunksize(self, value):
        self.chunksize = value
        return self

    def withDelimiter(self, value):
        self.delimiter = value
        return self

    def withEncoding(self, value):
        self.encoding = value
        return self


if __name__ == '__main__':
    data = (
        ReadCSV('myfile.csv')
        .withChunksize(10_1000)
        .withDelimiter(',')
        .withEncoding('UTF-8')
    )

11.5.5. Use Case - 0x02

>>> def read_csv(filepath_or_buffer, sep=', ', delimiter=None, header='infer',
...              names=None, index_col=None, usecols=None, squeeze=False,
...              prefix=None, mangle_dupe_cols=True, dtype=None, engine=None,
...              converters=None, true_values=None, false_values=None,
...              skipinitialspace=False, skiprows=None, nrows=None,
...              na_values=None, keep_default_na=True, na_filter=True,
...              verbose=False, skip_blank_lines=True, parse_dates=False,
...              infer_datetime_format=False, keep_date_col=False,
...              date_parser=None, dayfirst=False, iterator=False,
...              chunksize=None, compression='infer', thousands=None,
...              decimal=b'.', lineterminator=None, quotechar='"',
...              quoting=0, escapechar=None, comment=None, encoding=None,
...              dialect=None, tupleize_cols=None, error_bad_lines=True,
...              warn_bad_lines=True, skipfooter=0, doublequote=True,
...              delim_whitespace=False, low_memory=True, memory_map=False,
...              float_precision=None): ...
>>> data = read_csv('myfile.csv', ', ', None, 'infer', None, None, None,
...                 False, None, True, None, None, None, None, None, False,
...                 None, None, None, True, True, False, True, False, False,
...                 False, None, False, False, None, 'infer', None, b'.',
...                 None, '"', 0, None, None, None, None, None, True, True,
...                 0, True, False, True, False, None)
>>> data = read_csv('myfile.csv',
...     chunksize=10_000,
...     delimiter=',',
...     encoding='utf-8')

11.5.6. Use Case - 0x02

>>> class Person:
...     def __init__(self, firstname, lastname, email, age, height, weight):
...         self.firstname = firstname
...         self.lastname = lastname
...         self.email = email
...         self.age = age
...         self.height = height
...         self.weight = weight
>>> mark = Person( 'Mark', 'Watney', 'mwatney@nasa.gov', 40, 185, 75)
>>> mark = Person(
...     firstname='Mark',
...     lastname='Watney',
...     email='mwatney@nasa.gov',
...     age=40,
...     height=185,
...     weight=75,
... )

11.5.7. Use Case - 0x02

>>> class Person:
...     def __init__(self, firstname, lastname, is_astronaut, is_retired,
...                  is_alive, friends, assignments, missions, assigned):
...         ...
>>> mark = Person('Mark', 'Watney', True, False, True, None, 1, 17, False)
>>> mark = Person(
...     firstname = 'Mark',
...     lastname = 'Watney',
...     is_astronaut = True,
...     is_retired = False,
...     is_alive = True,
...     friends = None,
...     assignments = 1,
...     missions = 17,
...     assigned = False,
... )
>>> class Person:
...     def __init__(self):
...         ...
...
...     def withFirstname(self, firstname):
...         self.firstname = firstname
...         return self
...
...     def withLastname(self, lastname):
...         self.lastname = lastname
...         return self
...
...     def withIsAstronaut(self, is_astronaut):
...         self.is_astronaut = is_astronaut
...         return self
...
...     def withIsRetired(self, is_retired):
...         self.is_retired = is_retired
...         return self
...
...     def withIsAlive(self, is_alive):
...         self.is_alive = is_alive
...         return self
...
...     def withFriends(self, friends):
...         self.friends = friends
...         return self
...
...     def withAssignments(self, assignments):
...         self.assignments = assignments
...         return self
...
...     def withMissions(self, missions):
...         self.missions = missions
...         return self
...
...     def withAssigned(self, assigned):
...         self.assigned = assigned
...         return self
>>>
>>>
>>> mark = (
...     Person()
...     .withFirstname('Mark')
...     .withLastname('Watney')
...     .withIsAstronaut(True)
...     .withIsRetired(False)
...     .withIsAlive(True)
...     .withFriends(None)
...     .withAssignments(1)
...     .withMissions(17)
...     .withAssigned(False)
... )

11.5.8. Assignments

Code 11.47. Solution
"""
* Assignment: DesignPatterns Creational BuilderTexture
* Complexity: easy
* Lines of code: 18 lines
* Time: 8 min

English:
    1. Create class `Texture`
    2. Use builder pattern to set:
        a. `file: Path` convert from `str`
        b. `width: int` value greater than 0
        c. `height: int` value greater than 0
        d. `quality: int` from 1 to 100 percent
    3. Run doctests - all must succeed

Polish:
    TODO: Polish translation

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

    >>> result = (
    ...     Texture()
    ...     .with_file('img/dragon/alive.png')
    ...     .with_height(100)
    ...     .with_width(50)
    ...     .with_quality(75))
    >>>
    >>> vars(result)
    {'file': PosixPath('img/dragon/alive.png'), 'height': 100, 'width': 50, 'quality': 75}
"""
from pathlib import Path

class Texture:
    file: Path
    width: int
    height: int
    quality: int


Code 11.48. Solution
"""
* Assignment: DesignPatterns Creational BuilderEmail
* Complexity: easy
* Lines of code: 15 lines
* Time: 13 min

English:
    1. Create class `Email`
    2. Use builder pattern to set:
        a. `recipient: str` verify email address using regex
        b. `sender: str` verify email address using regex
        c. `subject: str` encode to bytes
        d. `body: str` encode to bytes
        e. `attachment: bytes` base64 encoded
    3. Run doctests - all must succeed

Polish:
    TODO: Polish translation

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

    >>> result = (
    ...     Email()
    ...     .with_recipient('mwatney@nasa.gov')
    ...     .with_sender('mlewis@nasa.gov')
    ...     .with_subject('Hello from Mars')
    ...     .with_body('Greetings from Red Planet')
    ...     .with_attachment('myfile.txt'.encode())
    ... )

    >>> pprint(vars(result), width=72, sort_dicts=False)
    {'recipient': 'mwatney@nasa.gov',
     'sender': 'mlewis@nasa.gov',
     'subject': 'Hello from Mars',
     'body': 'Greetings from Red Planet',
     'attachment': b'bXlmaWxlLnR4dA=='}
"""
from base64 import b64encode


class Email:
    recipient: str
    sender: str
    subject: str
    body: str
    attachment: bytes


Code 11.49. Solution
""""
You’re building a word processor similar to Word. The user can
add text or image elements to a document and then export it to
a variety of different formats such as HTML, text, and so on.
Look at the implementation of the Document class in the builder
package. Note that if the selected format is HTML, all text and
image elements are written to an HTML document. If the selected
format is text, however, only text elements are written to a text
file. You can run the code in the Demo class to see this in action.
What are the problems with the current design? Refactor this design
using the builder pattern.
"""