Pythonic way of extending builder pattern

Viewed 211

Consider the following CDK example in Python (for this question, AWS knowledge is not needed and this should be valid for basically any builder pattern, I'm just using CDK in this example as I faced the issue using this library.):

from aws_cdk import aws_stepfunctions as step_fn
from aws_cdk import core

app = core.App()
state_machine = step_fn.Chain.start(
    step_fn.Pass(app, 'start')
).next(
    step_fn.Pass(app, 'foo1')
).next(
    step_fn.Pass(app, 'foo2')
).next(
    step_fn.Pass(app, 'bar')
)

Now, if I need to re-use construct

.next(
    step_fn.Pass(app, 'foo1')
).next(
    step_fn.Pass(app, 'foo2')
)

multiple times, I could come up with these approaches.

  1. Wrap the code in a method
def foo(chain: step_fn.Chain) -> step_fn.Chain:
    return chain.next(
        step_fn.Pass(app, 'foo1')
    ).next(
        step_fn.Pass(app, 'foo2')
    )


# This works but it destroys the readability of the chain as the calling order is inverted.
state_machine = foo(
    step_fn.Chain.start(
        step_fn.Pass(app, 'start')
    )
).next(
    step_fn.Pass(app, 'bar')
)

# This is against the builder design to wrap mutability in the builder classes.
state_machine = step_fn.Chain.start(
    step_fn.Pass(app, 'start')
)
state_machine = foo(state_machine)
state_machine = state_machine.next(
    step_fn.Pass(app, 'bar')
)
  1. Monkey patching

While the syntax is seemingly nice, this seems error-prone and a maintainability nightmare when applied to a real project with multiple people working with the repository:

step_fn.Chain.foo = foo
state_machine = step_fn.Chain.start(
    step_fn.Pass(app, 'start')
).foo().next(
    step_fn.Pass(app, 'bar')
)

I was trying to look if there is any way to implement type classes for Python objects but could not find anything. I found dry-python but not sure if it can be used for class methods. In Scala, implicit classes could be used to have fluent builder syntax without altering any global state. Is there any Pythonic way to achieve the same?

Edit: Later figured out that CDK chain supports adding other chains which solves this particular issue. In general, if you can affect the design of builders, it is probably best to add a method extend etc that allows adding another builder to the builder which makes it easy to re-use for this kind of scenarios.

1 Answers

You can wrap the whole thing in a class:

class ChainHelper:
    def __init__(self, app: core.App, chain: step_fn.Chain):
        self._app = app
        self._chain = chain

This allows you to provide descriptive names to your operations and allow more reuses:

class ChainHelper:
    def __init__(self, app: core.App, chain: step_fn.Chain):
        self._app = app
        self._chain = chain

    def pass_arg(self, arg: str):
        self._chain = self._chain.next(step_fn.Pass(self._app, arg))
        return self

    def pass_foo(self):
        self._chain = self._chain \
            .next(step_fn.Pass(self._app, 'foo1')) \
            .next(step_fn.Pass(self._app, 'foo2'))
        return self

Usage:

state_machine = ChainHelper(app=core.App(), chain=step_fn.Chain) \
    .pass_arg('start')  \
    .pass_foo()         \
    .pass_arg('bar')
Related