Concepts
This page explains chorelib’s core concepts and design decisions.
Rule vs Task
chorelib has two kinds of build targets:
- Rule (
@rule) A file-based target with mtime checking. The builder runs only when the target is missing or older than its dependencies. Rules associate a target name (or regex pattern) with a builder function.
@rule("output.txt", depends="input.txt") def build(target, depends, needs): shell("cp", depends[0], target)
The builder function receives three arguments:
target— the target file name (str)depends— list of dependency file names (list[str])needs— list of order-only prerequisite names (list[str])
- Task (
@task) An always-execute target, analogous to Make’s
.PHONY. Tasks run their builder every time they are requested, regardless of mtime. They are identified by name (derived from the function name or explicitly given).@task def clean(): shell("rm -f output.txt")
Task builders take no arguments. Tasks cannot have
depends— they only supportneedsfor ordering.
depends vs needs
Both depends and needs express that one target requires another to be
built first. The critical difference is whether changes trigger a rebuild:
- depends
A dependency that triggers rebuilds. If any dependency is newer than the target (based on mtime comparison), the target is rebuilt.
@rule("app.js", depends=["main.js", "util.js"]) def bundle(target, depends, needs): ...
- needs
An order-only prerequisite. The needed target is built before the current target, but its mtime is not compared. Changes to a
needstarget do not trigger a rebuild.@rule("build/output.html", depends="input.md", needs="build") def convert(target, depends, needs): ...
Common use case: ensuring a directory exists before writing files into it.
Property |
|
|
|---|---|---|
Build order |
Built first |
Built first |
Triggers rebuild |
Yes (mtime comparison) |
No |
Typical use |
Source files, headers |
Directories, setup tasks |
Automatic Flattening of Parameters
Parameters that accept sequences — targets, depends, needs, and
the *cmd arguments of shell() / command() — automatically flatten
arbitrarily nested lists. This means you never need to manually concatenate or
unpack lists when composing dependencies.
For example, you can mix single values and nested lists freely:
HEADERS = ["hello.h", "config.h"]
LIBS = ["libfoo.a", "libbar.a"]
@rule("app", depends=[HEADERS, LIBS, "main.c"])
def build(target, depends, needs):
...
# depends is flattened to:
# ["hello.h", "config.h", "libfoo.a", "libbar.a", "main.c"]
The same applies to shell() and command():
CFLAGS = ["-O2", "-Wall"]
command("gcc", CFLAGS, "-o", target, depends)
# Executed as: gcc -O2 -Wall -o app hello.h config.h ...
Strings are treated as atomic values (not iterated character by character),
and None values are silently ignored.
This design eliminates boilerplate list operations like [*A, *B, c] or
A + B + [c], keeping build scripts concise and readable. The flattening
is performed by chorelib.utils.flatten().
Regex Target Patterns
Target names can be regex patterns, allowing a single rule to match many targets. There are two ways to specify a regex target:
Compiled pattern: Pass a
re.compile()object.@rule(re.compile(r"(.+)\.o"), depends=r"\1.c") def compile(target, depends, needs): ...
String starting with
^: Strings that start with^are automatically compiled as regex patterns.@rule(r"^(.+)\.o", depends=r"\1.c") def compile(target, depends, needs): ...
Backreferences (\1, \2, etc.) in depends and needs are expanded
using the regex match. Named groups ((?P<name>...)) are also supported.
Pattern matching uses re.fullmatch() — the pattern must match the entire
target name.
mtime-Based Rebuild Detection
chorelib decides whether to rebuild a target by comparing modification times:
If the target does not exist, it is built.
If any
dependsdependency is newer than the target, it is rebuilt.Otherwise, the target is up to date and skipped.
This is the same logic Make uses, applied through Python’s os.path.getmtime.
Use -r (--rebuild) to force all targets to rebuild regardless of mtime.
Custom mtime Functions
The @mtime decorator overrides the default os.path.getmtime for
targets matching a pattern. This enables chorelib to manage non-filesystem
resources.
@mtime(re.compile(r"^s3://.*"))
def s3_mtime(target):
# Return a numeric timestamp, datetime, or None
...
The mtime function should:
Return a numeric timestamp (
float) ordatetimeobject if the target exists.Return
Noneif the target does not exist (this triggers a build).
When a datetime is returned, naive datetimes (without timezone info) are
treated as UTC. Timezone-aware datetimes are converted to timestamps
automatically.
Multiple @mtime decorators can coexist. The first one whose pattern matches
a given target is used.
Dependency Graph and Cycle Detection
Before any build execution, chorelib constructs a dependency graph (DAG) from all requested targets and their transitive dependencies. It then performs a depth-first search to detect circular dependencies.
If a cycle is detected, a RuleError is raised immediately — before any
builder runs.
A depends on B
B depends on C
C depends on A ← cycle detected → RuleError
Parallel Execution Model
chorelib uses Python’s asyncio event loop with a ThreadPoolExecutor to
run builder functions in parallel:
The dependency graph determines the execution order.
Targets with no dependency relationship run concurrently.
The
-w Nflag controls the maximum number of parallel workers.-w 1(default) runs builders sequentially.
Builder functions run in worker threads via the executor, while dependency resolution and scheduling happen on the asyncio event loop thread.
schedule()
schedule() allows builder functions to dynamically add new targets to the
running build:
from chorelib import schedule
@task
def discover():
targets = find_targets()
schedule(*targets)
schedule() is thread-safe — it works correctly from both the event loop
thread and worker threads. Internally, when called from a worker thread, it
uses asyncio.run_coroutine_threadsafe() to safely interact with the event
loop.
RuleSet
A RuleSet is a collection of rules, tasks, and mtime definitions. chorelib
provides a default RuleSet instance that powers the module-level
@rule, @task, and @mtime decorators:
import chorelib
# These use the default RuleSet
@chorelib.rule("output.txt", depends="input.txt")
def build(target, depends, needs):
...
For advanced use cases (e.g., composing multiple independent build configurations),
you can create your own RuleSet:
from chorelib import RuleSet, Main
rules = RuleSet()
@rules.rule("output.txt", depends="input.txt")
def build(target, depends, needs):
...
if __name__ == "__main__":
Main(rules).run()