A pydoit translation of the project’s vars.mk Makefile.
Quick Start
# Install pydoit
pip install doit
# Run from the repo root:
doit -f content/blog/comparing-build-systems/doit/dodo.py build_prod_obverse
# Or copy/symlink to repo root and run without -f:
ln -s content/blog/comparing-build-systems/doit/dodo.py dodo.py
doit build_prod_obverse
Available Tasks
Run doit -f dodo.py list to see all tasks.
| Task | Description |
|---|---|
build_dev_obverse |
Build dev site |
build_prod_obverse |
Build production site |
build_prod_reverse |
Build production mirror |
build_test_obverse |
Build test site |
build_test_reverse |
Build test mirror |
build_onion_obverse |
Build onion site |
build_onion_reverse |
Build onion mirror |
build_all |
Build all four production sites |
deploy_prod_obverse |
Deploy to Netlify |
deploy_prod_reverse |
Deploy mirror to Netlify |
deploy_test_obverse |
Deploy to AWS (CFN base + Hugo + CFN distrib) |
deploy_test_reverse |
Deploy mirror to AWS |
deploy_onion_obverse |
Deploy via rsync |
deploy_onion_reverse |
Deploy mirror via rsync |
deploy_all |
Deploy all four production sites |
deploy_s3 |
Deploy both test sites |
check_all |
Run all pre-build checks |
check_symlinks |
Check for symlinks in content/ |
check_exifstripped |
Verify/strip EXIF from images |
spellcheck_prod_obverse |
Spellcheck HTML output |
go_mod |
Update go modules |
vars_clean |
Remove generated build state (build/) |
deploy_clean |
Clean all generated files |
git_info |
Capture git commit info (internal) |
calc_hugosources |
Compute Hugo source file list (internal) |
cfn_base:test-obverse |
Deploy test-obverse CFN base stack (internal) |
cfn_base:test-reverse |
Deploy test-reverse CFN base stack (internal) |
cfn_distrib:test-obverse |
Deploy test-obverse CFN distrib stack (internal) |
cfn_distrib:test-reverse |
Deploy test-reverse CFN distrib stack (internal) |
How This Compares to Make
What Works Well
1. No parse-time overhead — by default
The Makefile’s core problem: $(shell ...) runs on every make invocation, even
make help. pydoit avoids this entirely because dodo.py is a Python module —
functions are defined but not called until doit decides a task needs to run.
# Python: _hugo_sources() is a function; it runs only when a task executes
def _hugo_sources():
return glob.glob("archetypes/**/*", recursive=True) + ...
# Make: $(shell find ...) runs at PARSE TIME on every invocation
HUGOSOURCES = $(shell find archetypes config content ... -type f)
This is the vars.mk pattern’s whole motivation, already solved by the language.
2. calc_dep cleanly replaces -include + $(HUGOSOURCES)
The vars.mk Makefile uses a two-phase trick to get an incremental source list:
build/hugo-sources.vars.mkis a target that writesHUGOSOURCES := ...-include build/hugo-sources.vars.mkcauses make to re-read the Makefile- Hugo targets then depend on
$(HUGOSOURCES)
pydoit has calc_dep for exactly this. A task’s action can return a dict of
additional dependencies, and any task that declares calc_dep: ["that_task"]
gets those deps merged in — no re-parsing needed:
# This task's action returns dynamic file_dep
def task_calc_hugosources():
def calc():
return {"file_dep": _hugo_sources()} # returns a dict!
return {
"actions": [calc],
"file_dep": HUGO_SOURCE_DIRS, # recompute when dirs change
}
# Hugo build tasks use calc_dep to get the dynamic source list
def _hugobuild(env):
return {
"calc_dep": ["calc_hugosources"], # merges file_dep from above
"file_dep": ["build/git-info.json"],
"targets": [f"public/{env}/.sentinel"],
...
}
Make’s -include dance is essentially reimplementing what calc_dep provides natively.
3. Python functions replace define/endef macros
Make’s macro syntax is awkward. Python closures are natural and handle argument passing cleanly:
# pydoit: regular Python function, env captured by closure
def _hugobuild(env):
def build(targets):
...hugo with env...
return {"actions": [build], "targets": [f"public/{env}/.sentinel"]}
def task_build_prod_obverse():
return _hugobuild("prod-obverse")
# Make: define/endef macro, called with $(call ...)
define hugobuild
@echo "Building for $(1)"
hugo --destination public/$(1) --environment $(1)
endef
public/prod-obverse/.sentinel: ...
$(call hugobuild,prod-obverse)
4. JSON replaces .vars.mk fragments
CFN outputs become a JSON file instead of a Makefile fragment. Reading them in Python is
straightforward; no $$(cat ...) workarounds needed:
# pydoit: read JSON, pass values directly
base = _read_json("build/cfn-test-obverse-base.json")
subprocess.run([..., f"ContentBucketName={base['ContentBucketName']}", ...])
# Make: read from vars.mk via -include, then use make variable
# (or the original pattern: $$(cat public/cfn/ContentBucketName))
--parameter-overrides ContentBucketName=$(TEST_OBVERSE_ContentBucketName)
5. Generator functions for paired tasks
task_cfn_base and task_cfn_distrib are generator functions that yield named
subtasks. This cleanly handles the two nearly-identical stack pairs without
copy-paste:
def task_cfn_base():
yield _cfn_base_task("test-obverse", "test-obverse-base", ...)
yield _cfn_base_task("test-reverse", "test-reverse-base", ...)
These become cfn_base:test-obverse and cfn_base:test-reverse — the colon
grouping is automatic.
6. Explicit task ordering, no left-to-right dependency
Make’s deploy-test-obverse relied on left-to-right ordering of its prerequisites
for CFN base → Hugo upload → CFN distribution. In pydoit, task_dep runs tasks in
dependency order, and the ordering is made explicit through the dep chain:
cfn_distrib:test-obverse has file_dep on the base JSON, so it won’t run before
the base task.
What Doesn’t Work Well / Trade-offs
1. No pattern rules (same gap as Task)
Make’s %.exifstripped: | % handles per-image tracking elegantly. pydoit has no
pattern rule equivalent — you loop in Python. This loses per-file incremental
tracking, processing all images whenever any source directory changes.
2. Task names use underscores, not dashes
Python function names can’t contain hyphens. All task names become underscored
(build_prod_obverse, not build-prod-obverse). pydoit does allow dash names
via the basename attribute, but it adds noise.
3. .doit.db state file
pydoit stores its dependency graph in a .doit.db SQLite file at the working
directory. This is invisible in normal use but needs to be excluded from version
control and cleaned with doit forget or by deleting the file. Make uses only
filesystem mtimes — no extra state file.
4. calc_dep ordering subtlety
calc_dep tasks run before the dependent task’s up-to-date check. If calc_hugosources
itself hasn’t run yet (clean build), pydoit runs it first, then re-evaluates the
hugobuild task with the merged deps. This is correct but less obvious than Make’s
explicit dependency chain.
5. Must be installed
Like Task, pydoit requires installation (pip install doit). Make is pre-installed
on virtually every Unix system.
6. No built-in parallel execution
pydoit runs tasks sequentially by default. Parallel execution requires doit -n 4
(number of workers). Task’s deps: are parallel by default; Make requires -j.
Neither default is obviously correct for deploy workflows where ordering matters.
Targets Not Translated
dev: Interactive hugo server; not interesting for build system comparison.macos-dev-*: macOS-specific; not relevant.list-pagerefs,list-shortcodes: One-linerrgcommands.
Structural Differences Summary
| Aspect | Make (vars.mk) | pydoit |
|---|---|---|
| Syntax | Makefile DSL | Python |
| Parse-time overhead | Yes: $(shell ...) runs on every invocation |
No: Python functions don’t run until called |
| Dynamic file deps | -include + two-phase re-read |
calc_dep — returns dict of additional deps |
| Stage outputs | .vars.mk fragments included into Makefile |
JSON files read by Python |
| Macros | define ... endef + $(call ...) |
Python helper functions returning task dicts |
| Parallel tasks | make -j flag |
doit -n N flag |
| File tracking | Timestamps | Timestamps (default) or MD5 checksums |
| State storage | Filesystem mtimes only | .doit.db SQLite file + filesystem mtimes |
| Pattern rules | %.exifstripped: | % |
Not supported |
| Subtasks | Separate targets with shared prefix by convention | Generator functions yielding named dicts |
| Installation | Universal (pre-installed) | pip install doit |
| Task name convention | Dashes (deploy-prod-obverse) |
Underscores (deploy_prod_obverse) |
Verdict
pydoit is a natural fit for the vars.mk pattern — it solves the same problem
(deferred computation of expensive values) without the two-phase -include dance.
calc_dep is a first-class version of what vars.mk implements with Makefile hacks.
The key wins:
- No parse-time overhead by default — the whole motivation for vars.mk is solved by the language
calc_depreplaces-include+$(HUGOSOURCES)cleanly and explicitly- Python helper functions are more readable than
define/endefmacros - JSON for stage outputs is cleaner than
.vars.mkfragments
The main losses compared to the vars.mk Makefile:
- No pattern rules (same problem as all other alternatives)
.doit.dbstate file needs management- Task names use underscores instead of dashes
- Must be installed
Compared to the other build systems in this series: pydoit is the closest conceptual match to the vars.mk pattern, because both are fundamentally about staged computation with cached intermediate results. Task is more approachable day-to-day. Shake has the most expressive dependency model. pydoit’s advantage is that Python is already ubiquitous in the toolchain.