Flexible configuration with Tonic

Tonic is a small Python package that aims to allow modular configuration whose behaviour can be overridden in a way that is easy to comprehend and debug.

You might have seen uses of the @configurable decorator in Moore code. This is part of Tonic. If you want to understand the motivation behind this, and when you might want to use it, read on. The detailed documentation of the Tonic API follows after that.

Designing flexible configuration

We’ve structured the Moore configuration in a way that packages each small step in separate functions. In this way, your HLT2 line calls a function that makes pions, say, and this calls a function which produces tracks, which calls a functions that loads raw data. Your line creates a stack of function calls:

hlt2_line()
    calls make_pions()
        calls make_tracks()
            calls make_hits()
                calls make_raw()

This separation of code helps to organise things.

But now you want to modify some behaviour of some function in the middle, say make_tracks. How can you do this? There are a couple of ways you might come up with.

  1. Modify the source code of the function directly.

  2. Copy the function as some new version and use that.

Option 1 is perfectly valid, and is how many Moore developers work, as explained in Developing Moore.

Option 2 brings a problem. The caller of the function you copied is still calling the original version. So, now you need to modify or copy that function. And now you have to repeat this for the caller of that function! This gets very cumbersome very quickly.

The @configurable decorator

The configurable decorator is designed to help in these situations. It allows you to override the arguments of a function wherever it happens to be called. So even if it’s called deep down the call stack, you still have some ability to override its behaviour.

Take this representative example:

from PyConf import configurable
from PyConf.Algorithms import HitMaker, TrackMaker, SelectPions, ParticleCombiner
from Moore.lines import DecisionLine


def make_raw():
    pass


def make_hits():
    return HitMaker(RawEvent=make_raw())


@configurable
def make_tracks(pT_threshold=200 * MeV):
    return TrackMaker(Hits=make_hits())


@configurable
def make_pions(max_pidk=5):
    return SelectPions(MaxPIDK=max_pidk, tracks=make_tracks())


def dipion_line(name="Hlt2DiPionLine", prescale=1.0):
    pions = make_pions()
    dipions = ParticleCombiner(Decay="B0 -> pi+ pi-", Particles=[make_pions])
    return DecisionLine(name, algs=[dipions], prescale=prescale)

You can see the full chain going from the line to the raw event.

Now we want to study what effect changing the PIDK cut has on the rate of our line. We could just modify the call to make_pions directly. This requires modifying the source of the dipion_line function:

def dipion_line(name="Hlt2DiPionLine", prescale=1.0):
    # Remember to uncomment this back when we're done!
    # pions = make_pions()
    pions = make_pions(max_pidk=0)
    dipions = ParticleCombiner(Decay="B0 -> pi+ pi-", Particles=[make_pions])
    return DecisionLine(name, algs=[dipions], prescale=prescale)

Instead of doing this, we can use the bind method that’s made available on all functions decorated with configurable:

with make_pions.bind(max_pidk=0):
    line = dipion_line()

It’s as easy as that! When you use bind with a context manager like this, any calls to the ‘bound function’ (make_pions in this case) will be intercepted, and the argument value you’ve specified will override the original value. In the example, the value of the max_pidk argument will be 0 inside the with block rather than the default of 5.

Multiple calls to bind can be used in the same with statement. This means we could also modify the tracking threshold along with the PID cut:

with make_pions.bind(max_pidk=0), make_tracks(pT_threshold=500 * MeV):
    line = dipion_line()

So, when should you use configurable to decorate your functions? There are some cases when it never makes sense:

  1. Functions with no arguments, as there’s nothing to override.

  2. Functions that you don’t expect to be buried inside a call stack. Line functions, like our dipion_line, are an example. They are usually called at the top level of script, so if we wanted to override some argument values we would just do so directly:

    line_standard = dipion_line()
    line_prescaled = dipion_line(name="Hlt2DiPionPrescaledLine", prescale=0.5)
    

Outside of these, it depends how you expect the function to be used. It’s generally safe to add configurable, but you can also just omit it. We can always add it later if it turns out it’s needed.

Remember that the standard development flow has the full source code checked out; it’s often easier just to modify it directly rather than jumping through bind calls!

Note

Overriding of deeply nested components was something quite common when we used objects called Configurables. These could be retrieved inside any scope based on a name: if you knew the name of the Configurable, you could retrieve it and modify its properties. This permits ultimate flexibility.

The trouble with this approach is that any one can modify any Configurable at any time. It becomes tricky to keep track of exactly who is modifying what, and what piece of code sets the final value the Configurable ends up with. In an application like the trigger, it’s very important to be able to understand exactly what’s going on!

Using the configurable decorator is an alternative that tries to make overriding more explicit. Everything happens in the callstack, and nothing outside it can mess around inside it. Using bind only modifies things within a very specific scope.

There are a couple of other useful features you can use when using configurable, such as tonic.debug and substitute. This are described in the PyConf.tonic documentation below.

Tonic API

Wrappers for defining functions that can be configured higher up the call stack.

Tonic provides the @configurable decorator, which allows the default values of keyword arguments to be overridden from higher up the callstack with bind.

>>> from PyConf import configurable
>>> @configurable
... def f(a=1):
...     return a
...
>>> with f.bind(a=2):
...     f()
...
2
>>> f()
1

This allows for high-level configuration of behaviour deep within an application; all that’s needed is a reference to the configurable function that one wishes to modify the behaviour of.

The idiomatic way of using tonic is define small, self-contained functions which construct some object of interest. These functions should call other, similarly self-contained functions to retrieve any components which are dependencies. Like this, callers can override the behaviour of any function in the call stack with bind. Each function should expose configurable parameters as keyword arguments.

To help debugging, bindings can be inspected using the debug context manager.

>>> from PyConf import tonic
>>> with tonic.debug():
...     f()
...     f(a=3)
...     with f.bind(a=2):
...         f()
...
1
3
2

Functions marked configurable can also be substituted entirely with substitute.

>>> @configurable
... def echo(arg=123):
...     return arg
...
>>> def echo_constant():
...     return 456
...
>>> with echo.substitute(echo_constant):
...     echo()
...
456
>>> echo()
123

tonic is named for Google’s gin configuration framework 1 which served as inspiration.

1

https://github.com/google/gin-config

class BoundArgument(value, scoped, stacks)[source]
class ForcedArgument(value)
value

Alias for field number 0

add_cache_serializer(f, *args, **kwargs)[source]

Insert a function that can serialize custom objects.

The function f is tried and if it raises TypeError, the previous global serializer function is attempted.

Parameters

f (callable) – f(obj, *args, **kwargs) must convert obj to a serializable representation or raise TypeError.

bound_parameters(configurable)[source]

Return the parameters bound to configurable in the current stack scope.

configurable(wrapped=None, cached=False)[source]

Mark a function as configurable.

The behaviour of a configurable function can be modified using the bind syntax:

>>> @configurable
... def f(a=1):
...     return a
...
>>> with f.bind(a=2):
...     f()
...
2
>>> f()
1
debug()[source]

Context manager that enables debug messaging from tonic.

disable_cache()[source]

Context manager that disables caching of configurable calls.

forced(value)[source]

Force bind an argument, overriding higher-level binds.