Monday, December 14, 2015

Waf build system

Waf build system

Waf build system

I've blogged about scons in January of this year. This autumn I gave waf another try, because I wanted to get rid of the python2 dependency, and I found out that waf supported almost everything I needed. I missed support for WiX Toolset and I wanted waf to handle localization satellite assemblies. To integrate these tools myself was a good exercise to get aqcuainted with the waf sources, which is also the purpose of this blog.

After skimming through the wafbook I dived into the sources to get an idea how it works. When trying it, I had troubles with the file system. As with scons it is not easy to move files around arbitrarily. It was, though, easier to solve with waf than with scons. Altogether the wscript's turned out cleaner than the SConscript's.

My Test Project

The test project is similar to that of scons:

  • C code is generated from a SimpleTemplate file (stpl) using bottle.
  • A python wrapper is created using cffi
  • A C# wrapper is created
  • which is used in a C# based GUI

I've added it to the waf playground folder and created a pull request, which was accepted. It's the stpl_c_py_cs_satellite_wix folder.

With waf you can create one waf script file containing all of waf. You can then check this waf script into your source code control system. So the build system becomes part of your sources, i.e. you don't depend so much on the further development of the waf project or whether your pull requests are accepted or not.

python waf-light --tools=resx,satellite_assembly,wix

The test project builds with the generated waf script on a normal cmd prompt. Developer Command Prompt for VS2015 initializes the console for x86, while waf defaults to x86_64, leading to a clash of x86 libcmt with x86_64 code. So let waf do the initialization.

MSys2 shell on Windows works, too. With scons there were problems, because scons uses the the shell to run the compilers and csc cannot handle file names with forward slashes.

On Linux the project compiles as well, with Mono covering the .NET part. To run the test there you need to help ld find libfuni.so via environment variables:

LD_LIBRARY_PATH=$PWD/../build/api/: PATH=$PATH:$LD_LIBRARY_PATH waf configure build test --stubs

Files

Let's start with files:

├── build
│   ├── api
│   └── gui
└── src
    ├── api
    │   └── wscript_build
    ├── build.py
    ├── gui
    │   └── wscript_build
    └── wscript

You will run waf from the src folder.

waf configure build

In the wscript, files can be given as strings or nodes (Nodes.py).

Node (Node.py):

abspath relpath path_from bld_dir bldpath srcpath change_ext chmod delete
find_dir find_node search_node find_or_declare find_resource get_bld
get_bld_sig get_src suffix height is_bld is_child_of is_src listdir make_node
mkdir mkfile read write ant_glob ant_iter

parent children ctx

String paths can be relative to wscript(_build) in the source tree. They are converted to nodes using TaskGen.to_nodes(). It uses find_resource(), and this first uses find_node() to look in the source tree and then search_node() for generated files in the build tree. find_node() finds the file only, if it exists on the file system. search_node() finds declared-only nodes, too.

Note

If you have a file as part of a compiler option, then this file must be relative to build, because that's where the compilers will run.

Generated files don't exist on disk until a later phase, when an actual compiler was run. If you wanted to have a generated file in the source tree, how do you do? ctx.path.get_src().find_or_declare() will not declare a node in the source tree, make_node() will, but find_node() will not find it, so all dependencies will fail. The work around is to actually create a temporary empty file on disk, so find_node() can find it. You can use make_node().write('') to create an empty file during the wscript execution.

from waflib.Node import Node
root =Node('',None)
toppath = os.path.expanduser('~/tmp/waftest')
top = root.make_node(toppath)
top.abspath()
nod = top.make_node('notondisk')
assert not os.path.exists(nod.abspath())
#but find_node will not find it either
assert None == top.find_node('notondisk')
#search_node will find it
assert top.search_node('notondisk')
nod.write('')
assert os.path.exists(nod.abspath())
assert top.find_node('notondisk')

Contexts

In wscript you have a function per waf command line command, like configure(), build(), ... or yourownone(). You specify their sequence of execution in the command line: waf configure build yourownone. If no command line command is given, the default is build.

yourownone will get a basic context

  • Context (Context.py):

    cmd_and_log end_msg exec_command execute fatal finalize load
    load_special_tools msg post_recurse pre_recurse recurse start_msg to_log
    
    cmd variant
    

But the major commands (build, install, ..) will have their special context (created via the module function create_context()).

These contexts have special functions that help you define the task_gen object, which then creates the task object in its post() method.

load() is for loading (the command from) tools, recurse() for doing the command in the wscript of subfolders.

The options, configure, build, clean, install commands get these contexts as parameters:

  • OptionsContext (Options.py):

    add_option add_option_group execute get_option_group jobs parse_args
    
  • ConfigurationContext (Configure.py):

    err_handler eval_rules execute get_env init_dirs load post_recurse prepare_env set_env setenv store
    
  • BuildContext (Build.py):

    add_group add_manual_dependency add_post_fun add_pre_fun add_to_group compile
    declare_chain execute execute_build get_all_task_gen get_build_iterator
    get_env get_group get_group_idx get_group_name get_targets get_tasks_group
    get_tgen_by_name get_variant_dir hash_env_vars init_dirs install_as
    install_files launch_node load_envs post_build post_group pre_build
    progress_line restore rule set_env set_group setup store symlink_as total
    
    env variant_dir (depending on) variant
    
  • CleanContext (Build.py):

    clean execute
    
  • InstallContext (Build.py):

    copy_fun do_install do_link install_as install_files run_task_now symlink_as
    

BuildContext is of course the important one.

It has a variant-specific environment (env) of type ConfigSet (ConfigSet.py):

append_unique append_value prepend_value derive detach get_flat get_merged_dict keys revert stash load store update

The build context's important functions, like program, shlib, stlib, objects will be provided by the tools loaded, and in this fashion

@conf
def program(bld, *k, **kw):
    set_features(kw, 'program')
    return bld(*k, **kw)

The last line is BuildContext::__call__(), which therefore is the important starting point. BuildContext::__call__() takes either a rule parameter or a features parameter.

Rules

rule can either be

  • a command like touch tst.txt, but normally with macros that are expanded in compile_fun() (Task.py), and executed via python subprocess, by default and luckily without using shell.
  • or a python function

Features

The __call__() parameters will be saved in an intermediate class task_gen (TaskGen.py). This can be equipped with additional functions by tools, when they are loaded.

Tools can change the task_gen class by adding methods before or after existing methods, especially process_rule and process_source. But with more tools this can get chaotic. Therefore these functions are associated with a string (= feature). You activate these function by providing the features parameter in the ctx.__call__() method, instead of the rule parameter.

Execution

Here a very shortened sequence of running waf for the important build command, i.e. BuildContext.execute():

We start in Scripting.py:

waf_entry_point
    run_commands
        parse_options
        run_command
            ctx = create_context(cmd_name)
            ctx.execute()
                recurse
                    exec/call
                execute_build
                    compile
                        get_build_iterator
                            post_group
                                tskgen.post
                                    process_rule
                                        create_task
                                    process_source
                                        create_task
                        Parallel.start
  • Via ctx.execute() either configure's execute or build's execute, and so on, is run.
  • ctx.recurse will run wscript.build()/wscript_build, which will create and put the task_gen objects into the current group. bld.add_group() in wscript makes a new group. Groups run sequentially.
  • execute_build{compile{get_build_iterator{post_group()}}} will call the task_gen object's post() method, which creates the tasks, which are returned in the iterator and run by a Parallel (Runner.py) object. post_group() takes into account the --targets= command line parameter.
  • The task_gen object's post() will sort process_rule process_source and the other methods added to task_gen via tools, using before_method, after_method and features. Use waf --zones=task_gen to see.
  • process_rule will create a task (create_task(name)), if a rule was specified.
  • else process_source will find the rule via tast_gen.mappings, which is filled via the extensions() (TaskGen.py) decorator. The decorated function must then call create_task('taskname'). In this case you must somewhere have class taskname(Task):..., or you use task_factory() (TaskGen.py), as is done in in the ctx.__call__() scenario and via declare_chain() (TaskGen.py). task_factory() uses Python's type() function to dynamically create the class derived from Task.

Variants

You use ctx.setenv('somevariant') in wscript's configure to define the environment for more variants. To do the variant via command line you need to subclass the according context and give it a name to use in the command line.

From the wafbook:

from waflib.Build import BuildContext
class debug(BuildContext):
        cmd = 'debug'
        variant = 'debug'

But how is that associated with the wscript's build() function? ctx.recurse() uses ctx.fun, not ctx.cmd, and that is still build.

Write a Tool

When you write a tool, you can either

  • inject methods into task_gen:

    @feature('a_tool')
    @after_method('process_source')
    def somefun(tskgen):...
    
  • use the extension decorator:

    @extension('.rb')
    def process(self, node):
        return self.create_task('run_ruby', node)
    
    class run_ruby(Task.Task):
        run_str = '${RUBY} ${RBFLAGS} -I ${SRC[0].parent.abspath()} ${SRC}'
    

    A manually derived task has normally a run_str or run() overridden. You can also provide scan for implicit dependency scanning and vars to influence tsk.signature() (calculated per task and depending on all that determines whether this task must be executed again).

  • use declare_chain():

    TaskGen.declare_chain(name = 'erlc',
        rule      = '${ERLC} ${ERLC_FLAGS} ${SRC[0].abspath()} -o ${TGT[0].name}',
        ext_in    = '.erl',
        ext_out   = '.beam')
    

The tool is loaded as a module, which means its body is executed. The build setup is normally done in the body itself. If you have a command function in the tool module, normally options and/or configure, it will be executed, when load() is called. In this cases call ctx.load() for each of these command functions.

Summary

Here the selection of the waf source code vocabulary:

Node
    find_node()
    find_resource()
    find_or_declare()
    make_node()
    mkdir()
    write()

    ctx

Context
    setenv()
    load()
    add_group()
    __call__()
    program()
    shlib()
    stlib()

    cmd_and_log()
    exec_command()

    cmd env variant

ConfigSet
    append_value()
    append_unique()
    prepend_value()

task_gen
    post()
        process_rule()
        process_source()
extension
declare_chain
before_method
after_method

Task
    run()
    run_str
    vars
    scan()