make
A command runner whose recipes are Python.
# Makefile.py
from make import recipe, sh
@recipe(group="app", requires=["cargo"])
def test(*, fast: bool = False) -> None:
"""Run the test suite."""
sh("cargo", "test", *(["--lib"] if fast else []))
$ make app.test --fast
$ cargo test --lib
The command line is derived from the function signature, so there is no second
schema to keep in sync. Recipes are ordinary functions — importable,
unit-testable, and distributable as versioned packages rather than a
directory someone git cloned.
$ uv tool install mkrun # installs the `make` and `mk` commands
Three names, deliberately different. The PyPI distribution is mkrun,
because both make and mk are taken by unrelated projects — and a recipe file
declaring dependencies = ["make"] would silently install a jinja2 templating
tool. The import name is make and the commands are make and mk, which are
independent of the distribution name (the same way pip install pillow gives
you import PIL).
make shadows GNU make on PATH. That is deliberate; mk is the identical
alias for repos that also use a real Makefile.
Why
just is a good dispatcher wrapped around a language that recipes outgrow. Once
a recipe body has a loop, an if, or three variables that must agree, you are
writing shell inside string interpolation with no types, no tests, and no way to
share it except copying a file.
just |
make |
|
|---|---|---|
| Recipe body | bash, with {{ }} spliced in as text |
Python; values are values |
| Arguments | positional strings | typed, from the signature — int, Path, Literal, list[str] |
| Required input | omit the default so it becomes a parse error | declared, with an error naming the field and where to set it |
| Namespacing | one flat namespace, web-/box- prefixes by convention |
modules: web.start, box.ls |
| Overriding a shared recipe | impossible — duplicates are fatal | @recipe(override="web.start"), and abstract=True upstream |
| Sharing | git clone --depth 1 into a gitignored directory |
a PyPI (or git) dependency, resolved and locked by uv |
| Pinning | none — every checkout is on some HEAD | make --sync → a lockfile |
| Testing | just --fmt --check (it parses) |
pytest, with a command recorder |
| Dry run | text expansion | every command actually suppressed |
Nothing here is theoretical. Every row is something that cost real bugs in a
fleet of five repositories sharing 1100 lines of just — a teardown that reaped
the wrong thing, a worktree that silently started with no environment at all, an
export that was a no-op for two repos out of five. All of them are properties of
small functions, and all of them are tests now.
docs/why.md names them, one by one, with the commits. Migrating, including the
full translation table: docs/from-just.md.
Recipes
Arguments come from the signature
Parameters before * are positional; parameters after * are options.
from pathlib import Path
from typing import Literal
from make import recipe, sh
@recipe
def publish(bundle: Path, *, track: Literal["alpha", "prod"] = "alpha",
locale: list[str] = [], dry: bool = False) -> None:
"""Upload a bundle to the store."""
sh("fastlane", "supply", "--aab", bundle, "--track", track,
*(["--validate_only"] if dry else []))
$ make publish ./app.aab --track prod --locale es-ES --locale en-US
$ make publish --help
| Signature | Command line |
|---|---|
path: Path |
required positional |
dest: str = "." |
optional positional |
*, port: int = 8001 |
--port 8001 |
*, force: bool = False |
--force |
*, color: bool = True |
--no-color |
*, track: Literal["a","b"] |
--track {a,b} |
*, locale: list[str] = [] |
--locale (repeatable) |
*args: str |
trailing arguments |
Short flags, help text and environment fallbacks attach without leaving the signature:
from typing import Annotated
from make import arg
def serve(*, port: Annotated[int, arg("-p", help="dev port", env="DEV_PORT")] = 8001): ...
Options on @recipe
@recipe(
group="play", # namespace -> play.publish
needs=[build, sign], # run first, once per invocation
requires=["fastlane"], # must be on PATH; checked before anything runs
dangerous=True, # demand --yes or an interactive confirmation
inputs=["src/**/*.rs"], # skip when outputs are newer than inputs
outputs=["dist/app"],
aliases=["ship"],
abstract=False, # declared but unimplemented; a consumer must override
override=False, # True, or the full name of the recipe being replaced
keep_cwd=False, # run where the user stood, not at the recipe-file root
)
Running commands
sh("git", "commit", "-m", message) # argv list -- no quoting hazard, ever
sh.out("git", "rev-parse", "HEAD") # captured stdout, stripped
sh.lines("git", "ls-files")
sh.ok("command", "-v", "fastlane") # bool, never raises
sh.pipe("du -sk target | cut -f1") # explicit shell, because it is the hazard
sh.bash(script) # multi-line bash, set -euo pipefail
sh.background("tailwindcss", "--watch", log="tmp/css.log")
sh.replace_process("dx", "serve") # exec, replacing this process
There is no interpolation step, so a value containing a space, a quote or a $
is data and cannot become syntax. Every one of these honours --dry-run; give
sh.out(..., dry="...") or sh.ok(..., dry=False) a stand-in when the value
steers later logic.
Changing files
A dry run that suppresses every command but still writes files looks safe and
is not. Use fs wherever a recipe changes something:
from make import fs
fs.write(path, text) fs.copy(source, destination)
fs.mkdir(path) fs.replace(source, destination) # atomic move
fs.remove(path) fs.rmtree(path)
Reading is untouched — read_text, glob and stat stay as they are, because
suppressing reads would make the dry run diverge from the real one.
Configuration for shared packages
A package declares what it needs from the consumer, typed:
from dataclasses import dataclass, field
from make import config
@config.section("web")
@dataclass
class Web:
bin: str # required
port: int = 8001
watch: list[str] = field(default_factory=list)
Consumers set it from the recipe file, a config file, or the environment — last wins:
Web.configure(bin="acme-web", port=8005, watch=["server", "web"])
# make.toml (or [tool.make.web] in pyproject.toml)
[web]
port = 8005
$ MAKE_WEB_PORT=8105 make web.start
A missing required value fails with the field, its type, and all three places it could be set.
Layered secrets
from make import env
env.layered() # ~/.make/secrets.env -> ~/.make/<repo>.env -> ./.env
Later layers win, but a variable exported by the caller still beats all of them.
<repo> resolves through git rev-parse --git-common-dir, so it is the main
checkout's name even from inside a linked worktree — the failure mode where a
worktree silently starts with no application environment at all. ~/.just/ is
read too, so an existing setup keeps working.
Sharing recipes
This is the point. Declare dependencies inline (PEP 723):
# Makefile.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["mkrun>=0.1", "acme-recipes>=0.4"]
# ///
from make import recipe, sh
from acme_recipes import deploy, docker # importing registers deploy.* and docker.*
deploy.Deploy.configure(host="app.example.com", unit="acme-web")
$ make --sync # pin -> Makefile.py.lock, committed
$ make --sync --upgrade # move the pins, deliberately, as a reviewable diff
$ make web.start
Without --upgrade, an existing lock is respected: a repo stays on the version
it was pinned to even after the shared package moves. That is the whole
difference from a git pull --ff-only || true that drags every checkout to
whatever HEAD happens to be.
If the current interpreter already satisfies the dependencies, nothing happens.
Otherwise make re-executes itself under uv run, into a cached environment.
In a project that already has a pyproject.toml and a virtualenv, put the
dependencies there instead and make --sync runs uv sync.
Publishing a recipe package is publishing a wheel. Nothing about it is special:
# make_recipes_yourthing/__init__.py
from make import group, sh
docker = group("docker")
@docker
def build(*, tag: str = "latest") -> None:
"""Build the image."""
sh("docker", "build", "-t", f"app:{tag}", ".")
Testing recipes
from make.testing import record
from acme_recipes import web
def test_start_reaps_a_stale_lock_holder():
with record(responses={"lsof -t": "4711"}) as rec:
web.start(port=8105)
assert rec.saw("kill", "-9", "4711")
assert rec.matched(r"dx serve .*--port 8105")
record() captures every command instead of running it, answers sh.out() with
canned text, and reports declared tools as present. Recipes called from Python
are plain functions — needs=, the confirmation gate and staleness belong to the
runner, not the function.
Command line
make [options] <recipe> [arguments] [<recipe> [arguments] ...]
-l, --list list recipes (the default with no recipe)
-h, --help [RECIPE] help, or full help for one recipe
-n, --dry-run print commands instead of running them
-y, --yes pre-answer confirmations for dangerous recipes
-f, --force ignore inputs=/outputs= staleness
-j, --jobs N run independent prerequisites in parallel
-q, --quiet only errors
-v, --verbose more detail (repeatable)
-C, --cwd DIR change directory before finding the recipe file
-F, --file PATH use this recipe file
-e, --env KEY=VALUE set a variable for every command
--json machine-readable --list
--doctor check every declared tool
--sync resolve and pin dependencies
--completions SH bash | zsh | fish
Recipe files, searched from the current directory upward: Makefile.py,
makefile.py, mk.py, .make/main.py. Not make.py — that name can shadow
import make.
Status
Alpha. The recipe-authoring API — @recipe, sh, fs, config, env — is
what a private fleet of seven recipe groups is already built on, and is not
expected to change shape. The internals may.
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mkrun-0.1.0.tar.gz.
File metadata
- Download URL: mkrun-0.1.0.tar.gz
- Upload date:
- Size: 54.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
32179f136aeebaebe628d8e2bd280729d4a618411aff0c66b236f58de6d21c0c
|
|
| MD5 |
40001359f11fcf3cdcbe73c25b928da5
|
|
| BLAKE2b-256 |
d41f0a8dab19d759e46c149a307d28e8c39d8af41eb4f782b69e67d088c42368
|
Provenance
The following attestation bundles were made for mkrun-0.1.0.tar.gz:
Publisher:
release.yml on optersoft/make
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mkrun-0.1.0.tar.gz -
Subject digest:
32179f136aeebaebe628d8e2bd280729d4a618411aff0c66b236f58de6d21c0c - Sigstore transparency entry: 2406016549
- Sigstore integration time:
-
Permalink:
optersoft/make@ce358c0b16a7690be0435b4d2fc93156ea58ecc4 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/optersoft
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ce358c0b16a7690be0435b4d2fc93156ea58ecc4 -
Trigger Event:
push
-
Statement type:
File details
Details for the file mkrun-0.1.0-py3-none-any.whl.
File metadata
- Download URL: mkrun-0.1.0-py3-none-any.whl
- Upload date:
- Size: 50.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eea8d837f18c1fe52f9f0c5856a8e6165e2408676bce41586d79929931507fbe
|
|
| MD5 |
2599894642a5dd230f9516791025a8b6
|
|
| BLAKE2b-256 |
9f0673711c5ab5b50cc38db7ae52fa3dd86d05bee67949f3c9c7c38324b122c9
|
Provenance
The following attestation bundles were made for mkrun-0.1.0-py3-none-any.whl:
Publisher:
release.yml on optersoft/make
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mkrun-0.1.0-py3-none-any.whl -
Subject digest:
eea8d837f18c1fe52f9f0c5856a8e6165e2408676bce41586d79929931507fbe - Sigstore transparency entry: 2406016567
- Sigstore integration time:
-
Permalink:
optersoft/make@ce358c0b16a7690be0435b4d2fc93156ea58ecc4 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/optersoft
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ce358c0b16a7690be0435b4d2fc93156ea58ecc4 -
Trigger Event:
push
-
Statement type: