ruk·si

🐍 Python
Strategy Pattern

Updated at 2018-11-21 02:20

Strategy is selecting an approach (e.g. a function) to use at runtime.

We were marking strategies with decorators but there are multiple ways to define them.

  • the naive way is just to list all the promotions but this is error-prone; promotions = [bonus_promotion, bulk_item_promotion, large_cart_promotion]
  • loading strategies by name in this namespace; promotions = [globals()[name] for name in globals() if name.endswith('_promotion') and name != 'best_promotion']
  • loading strategies from a module; promotions = [func for name, func in inspect.getmembers(promotions, inspect.isfunction)]
  • loading strategies from subclasses; Promotion.__subclasses__(), note that you need to execute the module containing the subclasses for this to work
from dataclasses import dataclass
from typing import Callable, Collection


@dataclass
class Customer():
    name: str
    bonus_points: int


@dataclass
class LineItem():
    product: str
    quantity: int
    price: float

    def total(self):
        return self.price * self.quantity


class Order:

    def __init__(
        self,
        customer: Customer,
        cart: Collection[LineItem] = None,
        promotion: Callable[['Order'], float] = None,
    ):
        self.customer = customer
        self.cart = list(cart)
        self.promotion = promotion
        self.total = sum(item.total() for item in self.cart)
        self.due = self._due()

    def _due(self):
        if self.promotion is None:
            discount = 0
        else:
            discount = self.promotion(self)
        return self.total - discount

    def __repr__(self):
        name = self.__class__.__name__
        return f'<{name} total:{self.total!r} due:{self.due!r}>'


promotions = []


def promotion(promotion_func):
    promotions.append(promotion_func)
    return promotion_func


@promotion
def bonus_promotion(order: Order) -> float:
    """ Customer gets 5% discount if they have 1000 bonus points. """
    return order.total * .05 if order.customer.bonus_points >= 1000 else 0


@promotion
def bulk_item_promotion(order: Order) -> float:
    """ 10% discount when buying 20 or more of a specific item. """
    discount = 0
    for item in order.cart:
        if item.quantity >= 20:
            discount += item.total() * .1
    return discount


@promotion
def large_cart_promotion(order: Order) -> float:
    """  7% discount for orders with 10 or more distinct items. """
    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total * .07
    return 0


bob = Customer('Bob Doe', 0)
alice = Customer('Alice Cooper', 1100)

# Usage #1

basic_cart = [
    LineItem('orange', 4, .5),
    LineItem('apple', 10, 1.5),
    LineItem('watermelon', 5, 5.0),
]
assert str(Order(bob, basic_cart, bonus_promotion)) == '<Order total:42.0 due:42.0>'
assert str(Order(alice, basic_cart, bonus_promotion)) == '<Order total:42.0 due:39.9>'

# Usage #2

banana_cart = [
    LineItem('banana', 30, .5),
    LineItem('apple', 5, 1.5),
]
assert str(Order(bob, basic_cart, bulk_item_promotion)) == '<Order total:42.0 due:42.0>'
assert str(Order(alice, banana_cart, bulk_item_promotion)) == '<Order total:22.5 due:21.0>'

# Usage #3

long_order = [LineItem(str(item_name), 1, 1.0) for item_name in range(10)]
assert str(Order(bob, long_order, large_cart_promotion)) == '<Order total:10.0 due:9.3>'


# Usage #4

def best_promotion(order: Order) -> float:
    return max(p(order) for p in promotions)


assert str(Order(alice, basic_cart, best_promotion)) == '<Order total:42.0 due:39.9>'

Source

  • Python Tricks The Book, Dan Bader
  • Fluent Python, Luciano Ramalho