How to Simplify Your SConscripts

This is the sixth post in my SCons series. The topic of this post is building reusable infrastructure that can extremely simplify your module-level SConscript files.

Starting with the first non-trivial SCons project, the module-level SConscript files contained too much repetitive code. The goal of this enhancement is to go back to minimalistic SConscript files. The objective is to let the developer define the module-level targets with minimal code, and no hassle.

I continue using the same C++ project that I introduced in the basic example. In this post I present SCons shortcuts that are available in module-level SConscript files. These shortcuts are Python functions that take care of dirty details behind the scenes.

The final result is available on my GitHub scons-series repository.

My SCons extensions are cluttering up the module-level SConscripts. Lets clean them up!

As a reminder, the (seemingly silly) C++ project is a simple address book program. Refer to a previous post if you need more details.

Bottom Line Up Front

The SConscript files I start with look something like this:

Import('*')

module_targets = dict()
module_targets['addressbook'] = env.Library('addressbook', ['addressbook.cc'])

Return('module_targets')
Import('*')

module_targets = dict()
module_targets['writer'] = env.Program('writer', ['writer.cc'] + env.get_targets('addressbook'))

Return('module_targets')

You can see the repetitiveness I’m talking about. Why does a developer need to worry about maintaining and returning a module_targets dictionary? Is it really necessary to mention the name of every target twice? Is it possible to avoid referring to env explicitly all over the place?

The SCons shortcuts I introduce allow for much simpler SConscript files:

Import('*')

Lib('addressbook', 'addressbook.cc')
Import('*')

Prog('writer', 'writer.cc', with_libs='AddressBook::addressbook')

Now, that’s how I like my build recipes!

Implementation Details

The starting point for this enhancement is the last episode (see on GitHub).

The “trick” is to take advantage of the exports argument of the SConscript() function, to make the shortcut functions available to the module-level SConscript file. You can see this in the process_module() method in site_scons/site_init.py file. Here’s an abbreviated version:

def process_module(self, module):
    print 'scons: |- Reading module', module, '...'
    # Prepare shortcuts to export to SConscript
    shortcuts = dict(
        Lib       = self._lib_wrapper(self._env.Library, module),
        StaticLib = self._lib_wrapper(self._env.StaticLibrary, module),
        SharedLib = self._lib_wrapper(self._env.SharedLibrary, module),
        Prog      = self._prog_wrapper(module),
    )
    # Execute the SConscript file, with variant_dir set to the
    #  module dir under the project flavored build dir.
    self._env.SConscript(
        sconscript_path,
        variant_dir=os.path.join('$BUILDROOT', module),
        exports=shortcuts)

The exports argument takes a dictionary whose keys are names of symbols to export, and values are the exported values. The shortcuts are the shorthand names I want to make available in SConscript files (Lib, Prog, etc.). The values assigned to those shortcuts are customized builders created by the wrapper methods, _lib_wrapper and _prog_wrapper. Each wrapper method returns a function that wraps an underlying SCons builder (e.g. Library, Program), and also performs the extra steps I want to hide from the SConscript. Specifically, the lib-wrapper needs to populate the known libraries dictionary, and the prog-wrapper needs to take care of adding library nodes specified via the with_libs argument.

The Library wrappers

The custom library builder is returned by the _lib_wrapper method in site_scons/site_init.py. It’s pretty straight forward:

def _lib_wrapper(self, bldr_func, module):
    """Return a wrapped customized flavored library builder for module.

    @param  builder_func        Underlying SCons builder function
    @param  module              Module name
    """
    def build_lib(lib_name, sources, *args, **kwargs):
        """Customized library builder.

        @param  lib_name    Library name
        @param  sources     Source file (or list of source files)
        """
        # Create unique library key from module and library name
        lib_key = self.lib_key(module, lib_name)
        assert lib_key not in self._libs
        # Store resulting library node in shared dictionary
        self._libs[lib_key] = bldr_func(lib_name, sources, *args, **kwargs)
    return build_lib

The _lib_wrapper() method returns the nested function build_lib. If you’re not familiar with returning nested functions to statically capture the scope, you might be interested in my post on Python closures. Practically, it allows someone else (e.g. a SConscript file) to invoke the build_lib function, and reference the bldr_func and module variables that were captured in the closure, without the caller ever knowing it existed.

The build_lib function simply uses the underlying SCons builder bldr_func to compile a library. The resulting library node is saved in the shared libraries dictionary, using a unique key generated from the module and library name (modname::libname).

The Prog wrapper

The custom program builder is returned by the _prog_wrapper method in site_scons/site_init.py.

def _prog_wrapper(self, module, default_install=True):
    """Return a wrapped customized flavored program builder for module.

    @param  module              Module name
    @param  default_install     Whether built program nodes should be
                                installed in bin-dir by default
    """
    def build_prog(prog_name, sources, with_libs=None, *args, **kwargs):
        """Customized program builder.

        @param  prog_name   Program name
        @param  sources     Source file (or list of source files)
        @param  with_libs   Library name (or list of library names) to
                            link with.
        @param  install     Binary flag to override default value from
                            closure (`default_install`).
        """
        # Make sure sources is a list
        sources = listify(sources)
        install_flag = kwargs.pop('install', default_install)
        # Process library dependencies - add libs specified in `with_libs`
        for lib_name in listify(with_libs):
            lib_keys = listify(self._get_matching_lib_keys(lib_name))
            if len(lib_keys) == 1:
                # Matched internal library
                lib_key = lib_keys[0]
                # Extend prog sources with library nodes
                sources.extend(self._libs[lib_key])
            elif len(lib_keys) > 1:
                # Matched multiple internal libraries - probably bad!
                raise StopError('Library identifier "%s" matched %d '
                                'libraries (%s). Please use a fully '
                                'qualified identifier instead!' %
                                (lib_name, len(lib_keys),
                                 ', '.join(lib_keys)))
            else:  # empty lib_keys
                raise StopError('Library identifier "%s" didn\'t match '
                                'any library. Is it a typo?' % (lib_name))
        # Build the program and add to prog nodes dict if installable
        prog_nodes = self._env.Program(prog_name, sources, *args, **kwargs)
        if install_flag:
            # storing each installable node in a dictionary instead of
            #  defining InstallAs target on the spot, because there's
            #  an "active" variant dir directive messing with paths.
            self._progs[module].extend(prog_nodes)
    return build_prog

When the Writer/SConscript calls Prog('writer', 'writer.cc', with_libs='AddressBook::addressbook'), it actually executes build_prog(target_name='writer', sources='writer.cc', with_libs='AddressBook::addressbook', args=[], kwargs={}) with captured variable module=Writer from the enclosing scope.

The custom builder takes care of the stuff that were previously done in the SConscript itself. It uses a simplified version of the _get_targets() method that was introduced in an earlier episode to add dependencies to the sources list. In previous episodes, the SConscript used get_targets() explicitly to extend the sources list. To enable the simpler, more readable, with_libs=[...] approach shown here, I allow the custom builder to take an additional arguments with_libs (that defaults to None). The custom builder uses with_libs to dynamically extend the sources list with the _get_matching_lib_keys() method.

Passing through *args, **kwargs to the underlying SCons builder allows you to use SCons features (like environment override) without the custom builder interfering with it.

Simplified library matching

The _get_targets function introduced in a previous episode did many things. It supported taking variable list of library queries, because it was meant to be used in SConscript files. If you linked with libraries A and B, you could use _get_targets('A', 'B') instead of two separate calls. It also supported wildcard-queries, and contained the logic to deal with multiple results, no results, and printing warnings. This made the function less than simple, allowing to do more without adding overhead to the SConscript file.

This shortcuts refactor changes things completely. The SConscript maintainer uses with_libs instead of calling a function. The wrapped build_prog is responsible for processing with_libs, so it makes sense to perform a more sensible role separation. This is the reason I replaced _get_targets with the much simpler _get_matching_lib_keys method:

def _get_matching_lib_keys(self, lib_query):
    """Return list of library keys for given library name query.

    A "library query" is either a fully-qualified "Module::LibName" string
     or just a "LibName".
    If just "LibName" form, return all matches from all modules.
    """
    if self.is_lib_key(lib_query):
        # It's a fully-qualified "Module::LibName" query
        if lib_query in self._libs:
            # Got it. We're done.
            return [lib_query]
    else:
        # It's a target-name-only query. Search for matching lib keys.
        lib_key_suffix = '%s%s' % (self._key_sep, lib_query)
        return [lib_key for lib_key in self._libs
                if lib_key.endswith(lib_key_suffix)]

This function doesn’t return library nodes, just matching library keys. It doesn’t take multiple queries – just one. It doesn’t deal with raising errors or printing warnings – it just returns what it finds. It doesn’t support wildcard-queries.

Summary

That’s it. Some refactoring of previous episodes, along with clever use of exports dictionary, allow simple and readable SConscript files. The actual functionality did not change from previous episodes.

The final result is available on my GitHub scons-series repository. Feel free to use / fork / modify. If you do, I’d appreciate it if you share back improvements.

My SCons shortcuts significantly simplify SConscript files

See the scons tag for more in my SCons series. You may want to stay tuned for the next episode, that will show how to remove the last bit of overhead in SConscript files – the Import('*') line. Another future episode that may interest you will deal with removing the requirement for forward-only module dependencies.

No Comments Yet.

Leave a Reply