Patterns

A few shapes that come up again and again once a switch block is available. Each example runs as shown.

Wrap the block in a function

Most switch blocks exist to produce a value, and a function that returns s.result is the cleanest way to use one. It gives the switch a name, keeps s out of the enclosing scope, and makes the result available where the caller wants it:

from switchlang import switch, closed_range


def shipping_cost(weight_kg):
    with switch(weight_kg) as s:
        s.case(closed_range(0, 1), lambda: 5.00)
        s.case(closed_range(2, 5), lambda: 9.50)
        s.case(closed_range(6, 20), lambda: 19.00)
        s.default(lambda: 'quote required')
    return s.result


print(shipping_cost(1), shipping_cost(4), shipping_cost(12), shipping_cost(80))
5.0 9.5 19.0 quote required

A command loop

The example that motivated the library: a loop that reads a command and dispatches on it. The input is scripted here so the page can run, but the shape is the same with input():

inputs = iter(['a', '3', 'v', 'q'])


def get_action():
    return next(inputs)


state = {'level': None, 'running': True}


def set_level(n):
    state['level'] = n
    return f'level set to {n}'


def quit_app():
    state['running'] = False
    return 'bye'


while state['running']:
    action = get_action()

    with switch(action) as s:
        s.case(['a', 'c'], lambda: 'create account')
        s.case(['v', 'b'], lambda: 'view bookings')
        s.case([str(n) for n in range(1, 6)], lambda: set_level(int(action)))
        s.case('q', quit_app)
        s.case('', lambda: None)
        s.default(lambda: f'unknown command {action!r}')

    print(f'{action!r} -> {s.result}')
'a' -> create account
'3' -> level set to 3
'v' -> view bookings
'q' -> bye

Two things to notice. The lambdas close over action, so the digit case can read the value that matched. And the digit keys are built with a list comprehension: a case key is an ordinary expression, so it can come from anywhere.

Cases built from data

Because case() is a method call, you can register cases in a loop. That makes a switch block a good front end for a table of handlers that lives in configuration or is assembled at startup:

parsers = {
    'json': lambda: 'parse as JSON',
    'yaml': lambda: 'parse as YAML',
    'toml': lambda: 'parse as TOML',
}

extension = 'yaml'

with switch(extension) as s:
    for key, handler in parsers.items():
        s.case(key, handler)
    s.default(lambda: f'no parser for .{extension}')

print(s.result)
parse as YAML

You still get duplicate detection and the “nothing matched” check, which a bare dict lookup doesn’t give you. If the table is the whole story and never changes, a dict is fine on its own; the switch earns its keep when some cases are data and others are code.

A state machine on tuple keys

Tuples are hashable, so a (state, event) pair is a valid key. That turns a transition table into a switch block, with default() as “stay where you are”:

from enum import Enum, auto


class Job(Enum):
    QUEUED = auto()
    RUNNING = auto()
    DONE = auto()
    FAILED = auto()


def next_state(state, event):
    with switch((state, event)) as s:
        s.case((Job.QUEUED, 'start'), lambda: Job.RUNNING)
        s.case((Job.RUNNING, 'finish'), lambda: Job.DONE)
        s.case((Job.RUNNING, 'error'), lambda: Job.FAILED)
        s.case((Job.FAILED, 'retry'), lambda: Job.QUEUED)
        s.default(lambda: state)
    return s.result


job = Job.QUEUED
for event in ['start', 'error', 'retry', 'start', 'finish', 'start']:
    job = next_state(job, event)
    print(f'{event:7} -> {job.name}')
start   -> RUNNING
error   -> FAILED
retry   -> QUEUED
start   -> RUNNING
finish  -> DONE
start   -> DONE

The last 'start' arrives in DONE, where it means nothing, and the default keeps the job where it is.

An explicit “do nothing” case

Sometimes a value is valid and should do nothing, and you want that written down rather than left to the default. lambda: None is the idiom:

s.case('', lambda: None)   # empty input: ignore it

It reads as a decision, which is the point. The default stays free to mean “this value was not expected.”

Grouped actions with fall-through

Fall-through is rare in practice, and when you want it, it is usually to share a tail of work between neighboring cases. Here the “verbose” level does everything “normal” does, plus more:

def configure_logging(level):
    steps = []
    with switch(level) as s:
        s.case('verbose', lambda: steps.append('log every request'), fallthrough=True)
        s.case('normal', lambda: steps.append('log errors and warnings'), fallthrough=True)
        s.case('quiet', lambda: steps.append('log errors only'))
        s.default(lambda: steps.append('unknown level, logging errors only'))
    return steps


print(configure_logging('verbose'))
print(configure_logging('quiet'))
['log every request', 'log errors and warnings', 'log errors only']
['log errors only']

The order of the cases is the order of the chain, so arrange them from “most” to “least”. Fall-through explains the rules.