Introducing

dry-python

Artem Malyshev

BIO

Code is...

hard

and frustrating

Let's consider we're developing subscription button for a web service

Startup









Micro framework

  1. Long handlers
  2. Lots of "if" statements

Long handlers

 85 @app.route('/subscriptions/')
 86 def buy_subscription(page):
...
121     if props[-1].endswith('$'):
122 ->      props[-1] = props[-1][:-1]
123
Traceback (most recent call last):
  File "views.py", line 1027, in buy_subscription
ZeroDivisionError: division by zero

Enterprise









Big framework

  1. You need method flowchart
  2. Zig-zag in the traceback
  3. Framework internals leak

Implicit API

class SubscriptionViewSet(viewsets.ModelViewSet):
    queryset = Subscription.objects.all()
    serializer_class = SubscriptionSerializer
    permission_classes = (CanSubscribe,)
    filter_class = SubscriptionFilter
  1. What exactly does this class do?
  2. How to use it?

Framework internals leak

class SubscriptionSerializer(Serializer):
    category_id = IntegerField()
    price_id = IntegerField()
def recreate_nested_writable_fields(self, instance):
    for field, values in self.writable_fields_to_recreate():
        related_manager = getattr(instance, field)
        related_manager.all().delete()
        for data in values:
            obj = related_manager.model.objects.create(
                to=instance, **data)
            related_manager.add(obj)

As a result code is...

  1. Fragile
  2. Hard to reason about
  3. Time-consuming

If your code is crap, stickies on the wall won't help.

@HenrikKniberg

Service layer

Defines an application's boundary with a layer of services that establishes a set of available operations and coordinates the application's response in each operation.

by Randy Stafford

Business objects

def buy_subscription(category_id, price_id, user):

    category = find_category(category_id)
    price = find_price(price_id)
    profile = find_profile(user)
    if profile.balance < price.cost:
        raise ValueError
    decrease_balance(profile, price.cost)
    save_profile(profile)
    expires = calculate_period(price.period)
    subscription = create_subscription(
        profile, category, expires)
    notification = send_notification(
        'subscription', profile, category.name)

Business object problems

  1. Mixed state, implementation and specification
  2. Growth problem
  3. Top-down architecture

A set of libraries for pluggable business logic components.

Answers how we decompose and organize business logic.

Define a user story in the business transaction DSL.

Separate state, implementation and specification.

DSL

from stories import story, arguments

class Subscription:
    @story
    @arguments('category_id', 'price_id')
    def buy(I):
        I.find_category
        I.find_price
        I.find_profile
        I.check_balance
        I.persist_payment
        I.persist_subscription
        I.send_subscription_notification

Context

(Pdb) ctx
Subscription.buy:
  find_category
  check_price
  check_purchase (PromoCode.validate)
    find_code (skipped)
  check_balance
    find_profile

Context:
  category_id = 1318  # Story argument
  user = <User: 3292> # Story argument
  category = <Category: 1318>
    # Set by Subscription.find_category

DEBUG TOOLBAR











py.test

Sentry

Usage

  1. Story decorator build an execution plan
    class Subscription:
        @story
        def buy(I):
            I.find_category
  2. Execute object methods according to plan
    def find_category(self, ctx):
        category = Category.objects.get(
            pk=ctx.category_id)
        return Success(category=category)
  3. We call the story method
    subs = Subscription()
    subs.buy(category_id=1, price_id=1)

Failures

  1. Define a number of reasons
    @Subscription.buy.failures
    class Errors(Enum):
        low_balance = auto()
  2. Failure will stop the execution of the whole story
    def check_balance(self, ctx):
        if ctx.profile.balance < ctx.price.cost:
            return Failure(Errors.low_balance)
        else:
            return Success()
  3. We check failure reason
    result = Subscription().buy.run(category_id=2)
    assert result.failed_because(Errors.low_balance)

Contract

  1. Define a number of variable validators
    from pydantic import BaseModel
    
    @Subscription.buy.contract
    class Context(BaseModel):
        user: User
        category_id: int
        category: Optional[Category]
  2. Return variables from step
    def find_category(self, ctx: "Context"):
        category = get_category(
            ctx.category_id)
        return Success(category=category)

Substories

  1. Steps can be stories as well
    class Subscription:
        @story
        def buy(I):
            I.calculate_discount
            I.check_balance
        @story
        def calculate_discount(I):
            I.find_promo_code
            I.check_code_expiration
  2. Each step can stop the execution of current substory
    def check_code_expiration(self, ctx):
        if ctx.promo_code.is_expired():
            return Skip()
        else:
            return Success()

Provide composition instead of inheritance.

Solves top-down approach problem.

Delegate responsibility

class Subscription:

    def find_category(self, ctx):
        category = self.load_category(ctx.category_id)
        return Success(category=category)

    def find_price(self, ctx):
        price = self.load_price(ctx.price_id)
        return Success(price=price)

    def __init__(self, load_category, load_price):
        self.load_category = load_category
        self.load_price = load_price

Injection

from dependencies import Injector, Package

app = Package('app')

class BuySubscription(Injector):

    buy_subscription = app.services.Subscription.buy
    load_category = app.repositories.load_category
    load_price = app.repositories.load_price
    load_profile = app.repositories.load_profile

BuySubscription.buy_subscription(category_id=1, price_id=1)

Django views

from dependencies import operation
from dependencies.contrib.django import view
from django.http import HttpResponse, HttpResponseRedirect

@view
class BuySubscriptionView(BuySubscription):

    @operation
    def post(buy_subscription, category_id, price_id):
        result = buy_subscription.run(category_id, price_id)
        if result.is_success:
            return HttpResponseRedirect(to=result.value)
        elif result.failed_on('check_balance'):
            return HttpResponse('<h1>Not enough money</h1>')

Flask views

from dependencies import operation
from dependencies.contrib.flask import method_view
from flask import redirect

@method_view
class BuySubscriptionView(BuySubscription):

    @operation
    def post(buy_subscription, category_id, price_id):
        result = buy_subscription.run(category_id, price_id)
        if result.is_success:
            return redirect(result.value)
        elif result.failed_on('check_balance'):
            return '<h1>Not enough money</h1>'

Celery Tasks

from dependencies import operation
from dependencies.contrib.celery import task

@task
class PutMoneyTask(PutMoney):

    @operation
    def run(put_money, user, amount, task):
        result = put_money.run(user, amount)
        if result.is_failure:
            task.on_failure(result.ctx.transaction_id)

Plans

  1. Delegates
  2. Rollbacks
  3. asyncio support
  4. pyramid support
  5. typing advantages
  6. linters integration
  7. language server

Try it!

$ pip install stories
$ pip install dependencies

Get in touch