This tutorial converts a typical Python project Makefile into a tasks.py and shows you how to use ntask's caching, parallelism, and watch mode.
.PHONY: install test lint typecheck build check
install:
pip install -e ".[dev]"
test:
pytest
lint:
ruff check src tests
typecheck:
mypy src
build:
python -m build
check: lint typecheck testDrawbacks:
make testruns the full suite every time, even when no source changed.- Adding a new file to
src/doesn't automatically invalidate dependent targets. - There's no concurrency.
make -jexists but doesn't compose well with Python tooling.
from ntask import task, cached, depends, shell
@task
def install():
"""Install dev dependencies."""
shell('pip install -e ".[dev]"')
@task
@cached(inputs=["src/**/*.py", "tests/**/*.py", "pyproject.toml"])
def test():
"""Run the test suite."""
shell("pytest -q")
@task
@cached(inputs=["src/**/*.py", "tests/**/*.py", "pyproject.toml"])
def lint():
"""Lint with ruff."""
shell("ruff check src tests")
@task
@cached(inputs=["src/**/*.py", "pyproject.toml"])
def typecheck():
"""Static-type-check with mypy."""
shell("mypy src")
@task
@cached(
inputs=["src/**/*.py", "pyproject.toml", "README.md"],
outputs=["dist/"],
)
def build():
"""Build the wheel."""
shell("python -m build")
@task
def check():
"""All quality checks."""
depends(lint, typecheck, test)Save it as tasks.py at your project root. No other configuration needed.
ntask --list # show every task with its docstring
ntask check # runs lint, typecheck, test in order
ntask check # second time: all three are cache hits
ntask check -j # run them in parallel across all cores
ntask test # run just the test task
ntask --why test # explain the last cache decision for test
ntask --graph check # draw the DAG as ASCIIThe first run executes everything. The second ntask check looks at the inputs glob, hashes every matching file with xxh3_128, compares to the prior cache key, and short-circuits each task that hasn't changed.
Edit one file under src/, run ntask check again, and watch only the tasks whose inputs (or transitive upstream inputs) changed actually rerun.
ntask watch testntask reads the inputs=[...] globs of test, registers a filesystem watcher, and reruns the task whenever any matching file changes on disk. Save a file, see test output, repeat.
Watch mode requires the target to be @cached because the inputs glob is what defines "changed."
Add a parameter to a task and ntask wires it up automatically:
@task
@cached(inputs=["src/**/*.py", "tests/**/*.py"])
def test(pattern: str = "", verbose: bool = False):
"""Run a subset of the test suite."""
flags = "-v" if verbose else "-q"
k = f"-k {pattern}" if pattern else ""
shell(f"pytest {flags} {k}")ntask test --pattern=auth --verboseBooleans become --flag/--no-flag. Strings become --name VALUE. Literal[...] and Enum become --name {choice1,choice2}. Path arguments coerce automatically.
- Same five-line invocation surface as Make.
- Skip work the cache says is unchanged: instant
lint/typecheck/testwhen nothing relevant changed. - Real parallelism with
-j. - A live rerun loop with
watch. - A diagnostic flag (
--why) that names the specific file or env var that invalidated the cache.
Read caching.md next for the full cache contract, or jump to guide.md for the comprehensive reference.