Just the other day I saw someone mention on Twitter that Pynt was their new favorite pure Python replacement for make. I have some minor experience using Invoke, a very similar tool. This prompted me to experiment with both utilities and report on my findings.

Background

Both tools take great advantage of decorators in Python and both generate command-line interfaces for the user based on the tasks defined. Pynt and Invoke allow you to write your tasks in pure Python.

Pynt is a fork of microbuild which reportedly works on all versions of Python greater than 2.7. Your file should be named build.py.

Invoke is part of the roadmap to fabric 2.0 (a system administration tool). The plan is to have invoke take over fabric’s task running features and slim down fabric. Your invoke file should be named tasks.py.

The @task at hand

I’m in the process of developing a new library that has both integration and unit tests. The integration tests are a bit slow since they hit an API so on occasion I will only run the unit tests. I already had a Makefile that I had not added to the repository which allowed me to easily run them separately:

default: all

all:
    py.test

unit:
    py.test tests/unit

integration:
    py.test tests/integration

docs:
    rm -rf docs/_build/
    make -C docs/ html

clean:
    git clean -Xdf
    rm -rf docs/_build

I wanted something exactly equivalent in both languages so I set out to write them.

So I pulled up invoke’s documentation and Pynt’s (which seems to just be the README in the repository) and started working.

Using Pynt

from pynt import task
import pytest


@task()
def all():
    pytest.main()


@task()
def unit():
    pytest.main('tests/unit')


@task()
def integration():
    pytest.main('tests/integration')

This worked well, but I really wanted to test out the rake style syntax that is mentioned quite prominently in the README. So I added a new task:

@task()
def run_tests(quiet=True):
    pytest.main('-q' if quiet else '')

Running

~$ pynt run_tests

Worked but as soon as I tried this:

~$ pynt run_tests[quiet=False]

It continued to run the tests the same way. (I should have seen the name of each file and the status of running the tests on that file.) The reason why this was not changing the behaviour is that the function receives a string instead of boolean value. The code would have to be

@task()
def run_tests(quiet=True):
    if str(quiet).lower() == 'true':
        pytest.main('-q')
    elif str(quiet).lower() == 'false':
        pytest.main()

Which to me seems a bit inconvenient and over the top for a simple switch. On the other hand, the rake syntax has a really nice benefit:

@task()
def var_ex(*args):
    print(args)

This task can be run as

~$ pynt var_ex[foo,bar,bogus,tasks]
[ build.pyc - Starting task "var_ex" ]
('foo', 'bar', 'bogus', 'tasks')
[ build.pyc - Completed task "var_ex" ]

Which seems as though it would give you a great deal of freedom when developing your tasks. Overall it seems as though Pynt is a very well designed tool with a unique interface. I did not try out its dependency management but that looks promising.

Using Invoke

The first thing to note is that most of the task definitions will be the same
between Pynt and Invoke. For example:

from invoke import task, run
import pytest


@task
def all():
    pytest.main()


@task
def unit():
    pytest.main('tests/unit')


@task
def integration():
    pytest.main('tests/integration')


@task
def clean():
    run('git clean -Xdf')
    run('rm -rf docs/_build')


@task
def docs():
    run('make -C docs/ html')

But then I read on in the docs and realized that (much like rake) Invoke
supports using namespaces for tasks, which allows me to do this:

from invoke import task, Collection
import pytest

@task
def all():
    pytest.main()


@task
def unit():
    pytest.main('tests/unit')


@task
def integration():
    pytest.main('tests/integration')

tests = Collection(all, unit, integration)


@task
def clean():
    run('git clean -Xdf')
    run('rm -rf docs/_build')


@task
def docs():
    run('make -C docs/ html')

ns = Collection(clean, docs, tests=tests)

Then when you list the tasks you see:

~$ invoke -l
Available tasks:

    clean
    docs
    tests.all
    tests.integration
    tests.unit

Further more you can even declare one of the tasks in that namespace as
default, so that when you just use the namespace it knows which task to
execute. If we just modify the last example a little we can have invoke
tests
run all of the tests if we change the following:

@task(default=true)
def all():
    pytest.main()

Beyond keeping all of your tasks in a single file, you can also break out
related tasks into modules and load them as namespaces.

Finally, remember that task I wrote in Pynt that took a boolean value? There
are no such problems with invoke. If I have a tasks file that looks like this:

from invoke import task


@task
def test_args(quiet=True):
    if quiet:
        print("LA! LA! LA! I CAN'T HEAR YOU!")
    else:
        print("Ah freedom...")

I can toggle that argument like so

~$ invoke test_args --quiet
LA! LA! LA! I CAN'T HEAR YOU!
~$ invoke test_args --no-quiet
Ah freedom...

Decision

I will likely use Invoke when I need it. It feels to me far more mature and reliable. There is more documentation around for Invoke and even a project to collect the more common tasks. Pynt seems as though it has a great deal of potential and I’m going to continue to watch it. If anything changes in the future, I will be certain to post an update so be sure to subscribe to The Changelog Weekly.


Have comments? Send a tweet to @TheChangelog on Twitter.

Subscribe to The Changelog Weekly – our weekly email covering everything that hits our open source radar.