Architecture¶
Execution phases¶
Part objects have this life cycle:
Definition
Construction
Refine done
Traversal (e.g. render to html, respond to ajax, custom report creation)
At definition time we can have just a bunch of dicts. This is really a stacking and merging of namespaces.
At construction time we take the definition namespaces and materialize them into proper Table, Column, Form etc objects.
At bind time we:
register parents
evaluate callables into real values
invoke any user defined
on_bindhandlers
At traversal time we are good to go and can now invoke the final methods of all objects. We can now render html, respond to ajax, etc.
Refine done¶
At some point we know that this object has been completely configured.
This is when refine_done is either called automatically (for example if
you call bind() on an object that hasn’t had refine_done() called yet),
or you can do it explicitly yourself.
The refine done step does a lot of the work that is needed for the final
traversal step, so if possible you want to make sure this is done before.
If this step is done on an object that is then kept around, all this work
doesn’t need to be redone. Examples of work that is done in this step include
all the application of the Style, and pre-sorting out callables and constants.
This is simpler to understand with a concrete example, so let’s take a
table of albums that contain the string passed in a query parameter, or
otherwise the letter x:
albums_with_o = Table(
auto__model=Album,
rows=lambda request, **_: Album.objects.filter(
name__icontains=request.GET.get('q', 'x')
)
)
If you register that to a path with the .as_view() method, then
refine_done will be called once on first use. You can also explicitly call
refine_done() to force this optimization to happen at import time, which
might be preferable to you, especially for views that you know will be called
by all web workers anyway.
If you do this instead:
def albums_with_o(request):
return Table(
auto__model=Album,
rows=Album.objects.filter(
name__icontains=request.GET.get('q', 'x')
)
)
The functionality is the same, but since a fresh Table is created on
each request, all the work done in the refine_done step will have to
be redone each request.
So in general, the FBV style is often easier to reason about when starting out with iommi, but it has some big downsides on performance.
Bind¶
“Bind” is when we take an abstract declaration of what we want and convert it into the “bound” concrete expression of that. It consists of these parts:
Copy of the part. (We set a member
_declaredto point to the original definition if you need to refer to it for debugging purposes.)Set the
parentand set_is_boundtoTrueStyle application
Call the part’s
on_bindmethod
The parts are responsible for calling bind(parent=self) on all their children in on_bind.
The root object of the graph is initialized with bind(request=request). Only one object can be the root.
Namespace dispatching¶
I’ve already hinted at this above in the example where we do
columns__foo__include=False. This is an example of the powerful
namespace dispatch mechanism from iommi.declarative. It’s inspired by the
query syntax of Django where you use __ to jump namespace. (If
you’re not familiar with Django, here’s the gist of it: you can do
Table.objects.filter(foreign_key__column='foo')
to filter.) We really like this style and have expanded on it. It
enables functions to expose the full API of functions it calls while
still keeping the code simple. Here’s a contrived example:
from iommi.declarative.dispatch import dispatch
from iommi.declarative.namespace import EMPTY
@dispatch(
b__x=1, # these are default values. "b" here is implicitly
# defining a namespace with a member "x" set to 1
c__y=2,
)
def a(foo, b, c):
print('foo:', foo)
some_function(**b)
another_function(**c)
@dispatch(
d=EMPTY, # explicit namespace
)
def some_function(x, d):
print('x:', x)
another_function(**d)
def another_function(y=None, z=None):
if y:
print('y:', y)
if z:
print('z:', z)
# now to call a()!
a('q')
# output:
# foo: q
# x: 1
# y: 2
a('q', b__x=5)
# foo: q
# x: 5
# y: 2
a('q', b__d__z=5)
# foo: q
# x: 1
# z: 5
# y: 2
This is really useful for the Table class as it means we can expose the full
feature set of the underlying Query and Form classes by just
dispatching keyword arguments downstream. It also enables us to bundle
commonly used features in what we call “shortcuts”, which are pre-packaged sets of defaults.
Evaluate¶
To customize iommi you can pass functions/lambdas in many places. This makes it super easy and fast to customize things, but how does this all work? Let’s start with a concrete example:
Table(
auto__model=Artist,
columns__name__cell__format=lambda value, **_: f'{value} !!!',
)
This will change the rendering of Dios name from Dio to Dio !!!. The obvious question here is: what other keyword arguments besides value do I get? In this case you get:
request WSGIRequest
table Table
column Column
params Struct
traversable Column
user User
value str
row Artist
cells Cells
bound_cell Cell
root Table
The general idea here that you should get all useful objects up the tree and as they are named it becomes easy to understand what is happening when reading these functions. If you have an iommi object you can call the method iommi_evaluate_parameters() on it to retrieve this dict.
traversable is exactly the same object as column. It’s the general name of the closest object (or the leaf) for that callback. You can think of it as similar to self. This is useful for creating functions that you can use for Field, Column, and Filter; as the keyword argument traversable is the same, but they will get field, column, and filter as the specific keyword arguments. Prefer the specific name if possible since it makes the code more readable.
Note
It is a good idea to always give your callbacks **_ even if you match all keyword arguments. We don’t consider adding keyword arguments a breaking change so additional keyword arguments can be added at any time.
Evaluate - under the hood¶
There are three functions that handle the evaluation of callables into values when needed. All of these pass values straight through, which is why you can write e.g. display_name='Artist' instead of having to write lambdas for simple values.
evaluate: evaluates non-strict, which means it will allow functions that don’t match the given signature to pass throughevaluate_strict: evaluates strictly, which means functions that don’t match the given signature will be an error
Each object in the tree declares what it adds to the evaluate parameters with a method own_evaluate_parameters. For example Table adds just one argument table which is itself. The method iommi_evaluate_parameters gives you all the evaluate parameters up the tree from where you are.
There are two special cases: traversable which is the leaf node, and request which is the http request object.