Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

About

mkrun -- a command runner whose recipes are Python, shareable as versioned packages

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages