Every coding project has recurring tasks, like test execution or code
generation. Over the years I been exploring a variety of ways to include these
tasks in an executable form as part of the repository. They range from
make
to invoke
, task
, or just
. As of
late, I started to rely more and more on plain self-contained Python scripts for
my own projects. In this post, I would like to describe the technical details
and why I like this setup.
The idea is to collect the recurring tasks of a project in an x.py
file at the
root level. The script itself contains a number of task functions configured via
decorators. For example:
# [...]
@cmd()
def format():
run("python", "-m", "black", ".")
@cmd()
@arg("--full", action="store_true", help="if given, run also slow tests")
def test(full=False):
run("python", "-m", "pytest", ".", *(() if not full else ("-m", "slow")))
# [...]
The individual tasks are available as commands when executing the script. For example to format the source, I run the script as
$ python x.py format
:: python.exe -m black .
All done! ✨ 🍰 ✨
1 file left unchanged.
The two decorators @cmd
and @arg
form a declarative command line parser
based on argparse
. For example the definition of the test
function above corresponds roughly to the following argparse
calls:
subparser = parser.add_subparser("test")
subparser.set_defaults(main_func=test)
subparser.add_argument(
"--full", action="store_true", help="if given, run also slow tests",
)
The implementation is designed for succinctness as I copy and paste it between
projects. An additional requirement is that it passes through black
without
being reformatted. The cmd
and arg
decorators store their arguments as
attributes of the decorated functions. They are defined as
__effect = lambda effect: lambda func: [func, effect(func.__dict__)][0]
cmd = lambda **kw: __effect(lambda d: d.setdefault("@cmd", {}).update(kw))
arg = lambda *a, **kw: __effect(lambda d: d.setdefault("@arg", []).append((a, kw)))
The the actual argument parsing is performed in the "__main__"
guard of the
script. In essence it searches all global variables with @cmd
attributes and
builds the argument parser using the information collected by the @cmd
, @arg
decorators. It reads
if __name__ == "__main__":
_sps = (_p := __import__("argparse").ArgumentParser()).add_subparsers()
for _f in (f for f in list(globals().values()) if hasattr(f, "@cmd")):
(_sp := _sps.add_parser(_f.__name__, **getattr(_f, "@cmd"))).set_defaults(_=_f)
[_sp.add_argument(*a, **kw) for a, kw in reversed(getattr(_f, "@arg", []))]
(_a := vars(_p.parse_args())).pop("_", _p.print_help)(**_a)
And that's it. I copy these roughly 10 lines of Python with slight variations into each project and I have way to execute tasks without external tools apart from a Python interpreter. You can find a full example here.
So how does it stack up? Overall I'm quite happy with this setup. As Python is a full programming language it can for example include loops to avoid duplication or conditions for platform specific behavior. With pure configuration languages, often such logic often cannot be expressed as part of the task file itself.
Further, as these scripts are self-contained there is no need to activate a virtual environment to execute the script. Python's standard library covers most uses cases out of the box. In rase cases, in which I do need extra packages, I import them lazily to allow running basic tasks without activating the virtual environment.
Also because everything is contained in a single file and the implementation is not hidden, it's easy to inspect everything and tune the behavior as required.
So what are the downsides of this approach? Without a Python interpreter at
hand, a task runner written in the main programming language for the project may
work better, such as task
for Go projects or just
for Rust
projects.
Also argparse
can be a bit idiosyncratic at times. In most cases,
I find it easy to configure, but in particular boolean flags require me to
lookup the details quite often.
While I assume this is setup does not work for everybody, I have been quite happy with it over the last couple of months.
In addition to the script described above, I also use a small Powershell
function to simplify its usage. It searches for the nearest x.py
script in the
parents of the current directory and executes it. With this function, I can
simply type xx <task>
in any directory of a project to execute the
corresponding task. The function reads
function XX() {
$dir = $PWD.ProviderPath
$xscript = $null
do {
if (Test-Path -Type Leaf -LiteralPath "$dir/x.py") {
$xscript = "$dir/x.py"
break
}
} while ($dir = Split-Path -LiteralPath $dir)
if ($null -eq $xscript) {
throw "Could not find the x.py script"
}
python $xscript @args
}
Skeleton of a task file:
# ruff: noqa: E401, E731
__effect = lambda effect: lambda func: [func, effect(func.__dict__)][0]
cmd = lambda **kw: __effect(lambda d: d.setdefault("@cmd", {}).update(kw))
arg = lambda *a, **kw: __effect(lambda d: d.setdefault("@arg", []).append((a, kw)))
# ...
if __name__ == "__main__":
_sps = (_p := __import__("argparse").ArgumentParser()).add_subparsers()
for _f in (f for f in list(globals().values()) if hasattr(f, "@cmd")):
(_sp := _sps.add_parser(_f.__name__, **getattr(_f, "@cmd"))).set_defaults(_=_f)
[_sp.add_argument(*a, **kw) for a, kw in reversed(getattr(_f, "@arg", []))]
(_a := vars(_p.parse_args())).pop("_", _p.print_help)(**_a)