If you work in high-performance computing, you probably know the spack package manager. In spack, packages are defined as python classes, using what looks like a domain-specific language, as exemplified bellow.
The developers of spack took inspiration from homebrew, another package manager, which uses Ruby for its domain specific language. While Ruby is very good for this purpose, Python is much harder to use, and I always wondered how spack packages worked. Today I dug into the code, and managed to reproduce what spack does. So let’s dive into it.
Let’s assume we want to implement the version
and variant
directives,
and make them available within a MyPackage
class.
Executing this code will, of course, produce an error:
So we need to define these functions.
Now our code prints this:
Great… but how do we modify the MyPackage
class to keep track
of the versions and variants? Because after all, that’s what we
are after. Well, we need to know when the class is being created
by Python, and for this, we have metaclasses. Let’s write one,
and have MyPackage
use it.
The code above now prints the following:
We can see that the directives inside MyPackage
are called first, then the __new__
method of the metaclass. We can use this to our advantage by having the variant
and version
functions modify some static attributes of the metaclass, then use these static attributes during
the creation of the MyPackage
class.
This code now successfully adds the versions
and variants
static attributes
to the class, and will print the following:
We can make this code a little safer by using _versions_to_add
instead
of versions_to_add
(private attribute), by defining a version
static method
in PackageMeta
, and by adding version = PackageMeta.version
right outside
of the PackagetMeta
class to make it usable at global scope.
The part of spack that implements the mechanism above is in the directives.py file. But if you read it, you will find that it doesn’t look very much like what I have shown above. This is because the spack developers have gone one step further by using decorators and functional programming to allow adding new directives as needed. We can try to do something similar:
This code is, again, a simplification of what Spack does, but let’s dive into it,
focusing on the variant
directive (version
works in exactly the same way).
The first thing Python encounters is the directive
decorator wrapping the variant
function. This directive takes the name of a list ("variants"
): this is the list
in which we will want to add variants. We pass this name to the decorator so that
it can add this name to PackageMeta.list_names
, a list of list names that
PackageMeta
will have to use when creating a class’ attributes.
The second thing the directive
decorator does it return a _wrapper
for the
decorated function. At this point, the name variant
does not refer to the
variant
function anymore, but to a wrapper around it, and PackageMeta.list_names
contains "variants"
. More directives (like version
) are added the same way.
Next, we enter the MyPackage
definition, and call variant('x')
. This calls the
_wrapper
function, which executes the actual variant
function. The variant
function returns the _execute_version
function, which is stored in
PackageMeta.directives_to_execute
.
When, at last, PackageMeta.__new__
is called, PackageMeta.list_names
contains
the list of attributes that we have to create. This is done by adding them
to the attr_dict
variable. At this point, all is left is to call the actual
directives. But we cannot do that before the class is created, since the
directives take a class as argument. Hence, we have to call these directives
in PackageMeta.__new__
, which is called with the created class. This function
goes through PackageMeta.directives_to_execute
, finds _execute_variant
in
it and calls it, which leads to the variant being added to MyPackage.variants
.
And voilà!