An OOP task runner in Python. It works like a Makefile, but tasks are Python class methods, so you can inherit and reuse them across projects.
- Reusable - Makefile and Justfile work well, but reusing tasks across projects is hard. bakefile makes tasks Python class methods, so you inherit and share them like any other code.
- Python - Write Python instead of a DSL. Real language features, type checking with ruff and ty, and the rest of Python's tooling.
ctx.run()still handles normal CLI commands through subprocess. - Language-agnostic - Tasks are Python, but the commands they run can target any language (Go, Rust, JS, etc.).
bakefile vs the runners you probably already know:
| bakefile | Make | Just | Task | mise | Invoke | |
|---|---|---|---|---|---|---|
| Tasks are | Python class methods (shell via ctx.run()) |
shell recipes | shell recipes | shell recipes | shell recipes | Python functions (shell via c.run()) |
| Type-safe task args | ✅ Typer | ❌ positional $@ |
❌ recipe params (str) | ❌ CLI vars (str) | ❌ env vars (str) | ❌ named, unvalidated |
| Auto help + completion* | ✅ Typer | ❌ | ||||
| Task reusability | ✅ inherit + override + compose | |||||
| Prebuilt task libraries | ✅ bakelib Spaces | ❌ | ❌ | ❌ | ❌ | ✅ invocations |
| Language | Python | Make DSL | Just DSL | YAML | TOML | Python |
| Type-check / lint / format | ✅ ruff + ty (native), any Python tool | ❌ | just --fmt |
❌ | ❌ | ✅ any Python tool |
| Logging & console | ✅ Rich + loguru | ❌ | ❌ | ❌ | ||
| Typed & validated config** | ✅ Pydantic BaseSettings | ❌ shell vars | ❌ shell vars | ❌ templated vars | ❌ env vars | ❌ Python dict |
| Secrets management | ✅ SecretUtils | ❌ | ❌ | ❌ | ❌ | ❌ |
| Multi-environment management | ✅ EnvBakebook (typed) | ❌ | ❌ | ❌ | ✅ MISE_ENV files | ❌ |
| Single binary, no runtime*** | ❌ needs Python | ✅ | ✅ | ✅ | ✅ | ❌ needs Python |
| Inline deps (PEP 723)**** | ✅ | N/A | N/A | N/A | N/A | ❌ |
| Manages tool versions | ❌ | ❌ | ❌ | ✅ | ❌ |
Legend: ✅ yes ·
* Auto help + completion: --help (typed options) and completes flags too (bake --install-completion).
** Typed & validated config: typed Pydantic settings (Pydantic Settings) that export to shell, dotenv, JSON, or YAML, or inject into a subprocess's environment (env, export).
*** Single binary, no runtime: bakefile needs a Python runtime, eased by PEP 723 and uv.
**** Inline deps (PEP 723): bakefile's bakefile.py can declare its own dependencies inline (# /// script), so a single file carries its own dependencies, no project setup needed. PEP 723 is a Python-only standard, so non-Python runners are N/A. bakefile also works with pyproject.toml for normal Python projects. PEP 723 is optional. Invoke has no inline-deps mechanism.
Most runners are DSLs over shell recipes. Invoke is Python too, but its tasks are flat module functions with no inheritance or composition model. bakefile is the only one where tasks are class methods you inherit, override, and compose, so reusable task libraries (bakelib Spaces) just work.
bakefile is new, built on modern typed Python (Typer + Pydantic). Production-proven: I run it daily at my company.
Install via pip:
pip install bakefileOr via uv:
uv add bakefile # as a project dependency
uv tool install bakefile # as a global toolCreate a file named bakefile.py:
from bake import Bakebook, command, console
class MyBakebook(Bakebook):
@command()
def build(self) -> None:
console.echo("Building...")
# Use self.ctx to run commands
self.ctx.run("cargo build")
bakebook = MyBakebook()
@bakebook.command()
def hello(name: str = "world"):
console.echo(f"Hello {name}!")Or generate one automatically:
bakefile init # Basic bakefile
bakefile init --inline # With PEP 723 standalone dependenciesRun your tasks:
bake hello # Hello world!
bake hello --name Alice # Hello Alice!
bake build # Building...bakefile provides two command-line tools:
bake- Runs tasks from yourbakefile.pybakefile- Manages yourbakefile.py(init, add-inline, lint, sync, lock, add, pip, venv, find-python, which, run, env, export)
Detailed CLI documentation in Usage.
A class in bakefile.py that holds your tasks:
- Subclass it to share tasks across projects through normal inheritance.
- It extends Pydantic's
BaseSettings, so configuration is typed class attributes with validation, env-var and.envloading, defaults, and type coercion. - Tasks use the
@command()decorator, same syntax as Typer. ctx.run()executes CLI commands through Python's subprocess.
from bake import Bakebook, command, Context, console
from pydantic import Field
from typing import Annotated
import typer
class MyBakebook(Bakebook):
# Pydantic configuration
api_url: str = Field(default="https://api.example.com", env="API_URL")
@command()
def fetch(self) -> None:
# Run CLI commands via self.ctx
self.ctx.run(f"curl {self.api_url}")
bakebook = MyBakebook()
# Standalone functions also work
@bakebook.command()
def test(
verbose: Annotated[bool, typer.Option(False, "--verbose", "-v")] = False,
):
if verbose:
console.echo("Running tests...")
bakebook.ctx.run("pytest")bakefile supports PEP 723 inline script metadata, so your bakefile.py can declare its own dependencies. Add it to an existing bakefile with bakefile add-inline:
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "bakefile>=0.0.0",
# ]
# ///
from bake import Bakebook, command, console
bakebook = Bakebook()
@bakebook.command()
def hello():
console.echo("Hello from standalone bakefile!")This suits non-Python projects without a pyproject.toml. For Python projects, add bakefile to your project's dependencies instead.
You can also generate one with bakefile init or bakefile add-inline.
Create a bakebook by inheriting from Bakebook or instantiating it:
from bake import Bakebook
bakebook = Bakebook()- Pattern 1: Before instantiating - Use
@command()on class methods - Pattern 2: After instantiating - Use
@bakebook.command()on standalone functions - Accepts all Typer options -
name,help,deprecated, etc.
from bake import Bakebook, command, console
from typing import Annotated
import typer
# Pattern 1: On class (use self.ctx for context access)
class MyBakebook(Bakebook):
@command()
def task1(self) -> None:
console.echo("Task 1")
self.ctx.run("echo 'Task 1 complete'")
bakebook = MyBakebook()
# Pattern 2: On instance (use bakebook.ctx for context access)
@bakebook.command(name="deploy", help="Deploy application")
def deploy(
env: Annotated[str, typer.Option("dev", help="Environment to deploy")],
):
console.echo(f"Deploying to {env}...")
bakebook.ctx.run(f"kubectl apply -f {env}.yaml")The Bakebook class provides a .ctx property for accessing CLI context:
class MyBakebook(Bakebook):
@command()
def my_command(self) -> None:
# Run a command
self.ctx.run("echo hello")
# Run with options
self.ctx.run(
"pytest",
capture_output=False, # Stream to terminal
check=True, # Raise on error
cwd="/tmp", # Working directory
env={"KEY": "value"}, # Environment variables
)
# Run a multi-line script
self.ctx.run_script(
title="Setup",
script="""
echo "Step 1"
echo "Step 2"
""",
)bakefile has two output channels, and they don't affect each other:
consoleis your task's user-facing output (results, status). It always prints, regardless of verbosity.- Logs are bakefile's own diagnostics, plus any
loggingcalls you make in tasks. These are gated by verbosity.
console (imported from bake) is a thin wrapper over Rich. Print task output through it rather than print:
from bake import Bakebook, command, console
class MyBakebook(Bakebook):
@command()
def status(self) -> None:
console.echo("Building...") # plain output, stdout
console.success("Build done") # ✅ SUCCESS (stderr)
console.warning("Low disk") # ⚠️ WARNING (stderr)
console.error("Build failed") # ❌ ERROR (stderr)Helpers:
console.echo(msg)prints to stdout (your task's normal output).console.success,info,warning,error(msg)print a labeled line to stderr. In GitHub Actions,warninganderrorbecome::warning::/::error::annotations.console.cmd(cmd_str)prints a command as❯ <cmd>, which is whatctx.runuses to show the command it runs.console.script_block(title, script)pretty-prints a multi-line script, used byrun_script.
For anything else, use the raw Rich consoles: console.out / console.err (stdout / stderr, color) and console.plain_out / console.plain_err (no color).
console output, plain print(), and command output are all separate from logs. They always print, regardless of verbosity.
bakefile logs through loguru, and all logs go to stderr. Standard-library logging is bridged into it, so any logging.getLogger(__name__).info(...) in your tasks honors the same settings:
import logging
logger = logging.getLogger(__name__)
@bakebook.command()
def task(self):
logger.info("starting") # visible at -vv and aboveThree settings control logs. The first two decide what shows, and the third picks the format.
Verbosity sets the global floor. Anything below it is dropped. The default is 0 (silent).
| Flag | Env | Level |
|---|---|---|
| (none) | BAKE_LOG_VERBOSITY=0 |
silent (no logs) |
-v |
BAKE_LOG_VERBOSITY=1 |
warning |
-vv |
BAKE_LOG_VERBOSITY=2 |
info |
-vvv |
BAKE_LOG_VERBOSITY=3 |
debug |
bake build # silent
bake -v build # warning + error
bake -vv build # adds info
bake -vvv build # adds debug (everything)Per-module levels (--bake-log, env BAKE_LOG) is a comma-separated list of level or module=level entries. It raises or lowers specific modules independent of verbosity. The default is warning,bake=debug,bakelib=debug,bakefile=debug (the tool's internals and your bakefile.py at debug, everything else at warning):
# Set one level for everything (the root level, always required)
bake --bake-log debug build # all modules at debug
bake --bake-log warning build # all modules at warning
# Raise one module above the root
bake --bake-log warning,bake=debug build # bake internals at debug, rest warning
# Target your own bakefile.py (it loads as module "bakefile")
bake --bake-log warning,bakefile=debug build # your bakefile.py at debug
# Env var form: quiet bakefile.py, keep bake internals (restate them)
BAKE_LOG="warning,bake=debug,bakelib=debug,bakefile=warning" bake build--bake-log replaces the default instead of merging with it, so restate any module you want to keep. In the last line, bake and bakelib stay at debug while bakefile.py is silenced to warning.
A log line shows only if it clears both the verbosity floor and its module's level. So with the default BAKE_LOG, -v surfaces bakefile's warnings and errors, and -vvv surfaces its debug logs too.
Format (--log-pretty / --no-log-pretty, env BAKE_LOG_PRETTY, default pretty) chooses between pretty colored text and JSON (one object per line, for CI and log shipping):
bake --no-log-pretty build # JSON logsFor advanced needs, override setup_logging() (e.g. a custom JSON sink like GCPJsonSink for GCP Cloud Logging) or get_bake_log_thread_local_context() (inject trace IDs into each log line) on your Bakebook.
Bakebooks extend Pydantic's BaseSettings for configuration:
from bake import Bakebook
from pydantic import Field
class MyBakebook(Bakebook):
# Defaults
database_url: str = "sqlite:///db.sqlite3"
# With environment variable mapping
api_key: str = Field(default="default-key", env="API_KEY")
# With validation
port: int = Field(default=8000, ge=1, le=65535)Settings are loaded from environment variables, .env files, or defaults.
The bake command runs tasks from your bakefile.py. Run bake --help to see all available commands and options.
bake <command> [args]bake hello
bake build
bake test --verbosePreview what would happen without executing:
bake -n build
bake --dry-run deploy-v / -vv / -vvv (env BAKE_LOG_VERBOSITY 0-3) control bakefile's internal logs, plus per-module levels with --bake-log (env BAKE_LOG) and format with --log-pretty (env BAKE_LOG_PRETTY). See Logging and Console Output for the full reference. This only affects logs, not console.echo, print, or command output.
Run multiple commands sequentially:
bake -c lint test buildIf any command fails, the chain stops.
Override defaults when running bake:
bake --version # Show version
bake -f tasks.py build # Custom filename
bake -b my_bakebook build # Custom bakebook object name
bake -C /path/to/project build # Run from different directoryThe bakefile command (short: bf) manages your bakefile.py.
Create a new bakefile.py:
bakefile init # Basic bakefile
bakefile init -i # With PEP 723 inline metadata (--inline)
bakefile init --force # Force overwrite existing bakefileAdd PEP 723 inline metadata to an existing bakefile:
bakefile add-inlineLint bakefile.py (or the entire project) with ruff format, ruff check, and ty. Disable any with --no-ruff-format, --no-ruff-check, or --no-ty:
bakefile lint # Lint bakefile.py and all Python files
bakefile lint -b # Lint only bakefile.py (--only-bakefile)
bakefile lint --no-ty # Skip type checking
bakefile lint --line-length 88 # Override ruff line length (default 100)Convenience wrappers around uv commands with --script bakefile.py added. For PEP 723 bakefile.py files only. For normal Python projects, use your preferred dependency manager (pip, poetry, uv, etc.).
bakefile sync # = uv sync --script bakefile.py
bakefile sync --upgrade # -U; upgrade package dependencies
bakefile sync --reinstall # Reinstall all packages
bakefile lock # = uv lock --script bakefile.py
bakefile lock --upgrade # -U; upgrade locked dependencies
bakefile add requests # = uv add --script bakefile.py requests
bakefile pip install # = uv pip install --python <bakefile-python-path>Extra args pass through to uv (e.g. bakefile sync --frozen, bakefile lock --no-build).
Ensure a .venv exists in the repo root. For PEP 723 standalone bakefiles, symlinks .venv to the uv-managed environment. For standard Python projects (pyproject.toml), it just runs uv sync, so prefer uv directly there:
bakefile venv # Create or update .venv (standalone bakefiles)
bakefile venv --force # Replace an existing .venv symlink (standalone only)Print the Python path used by bake, bakefile env, bakefile export, bakefile run, and bakefile lint (the bakefile's Python):
bakefile find-pythonDiagnose which Python each command uses. bake, bakefile env, and bakefile export reinvoke under the bakefile's Python (they re-run themselves and load the bakebook). bakefile run and bakefile lint spawn that Python directly to run your code (no self-reinvoke, no bakebook). All other subcommands use the invoked Python:
bakefile whichRun a script or module under the bakefile's Python (like uv run for the bakefile's environment). If the first argument is an existing file, it runs as python script.py. Otherwise it runs as python -m module:
bakefile run test.py # Run a script (python test.py)
bakefile run pytest tests/ # Run as a module (python -m pytest tests/)
bakefile run ruff check src/ # Module with argumentsPrint or inject bakebook variables. Given this bakefile.py:
from bake import Bakebook
from pydantic import SecretStr
class MyBakebook(Bakebook):
database_url: str = "postgres://localhost/myapp"
api_key: SecretStr = SecretStr("hunter2")
bakebook = MyBakebook()Print a value (output is shell-quoted):
bakefile env DATABASE_URL # postgres://localhost/myapp
bakefile env API_KEY # '**********' (SecretStr masked)
bakefile env API_KEY -s # hunter2 (--secret reveals it)Inject variables into a command's environment with -- (printenv reads the injected value):
# Inject all variables (none named before --)
bakefile env -- printenv DATABASE_URL # postgres://localhost/myapp
# Inject only API_KEY, with -s to reveal the secret
bakefile env -s API_KEY -- printenv API_KEY # hunter2Export bakebook variables to shell, dotenv, JSON, or YAML. By default it exports every field, including bake's internal settings, so use -i to focus on your own. Using the same bakefile.py as env above:
# Default: all fields
bakefile export
# export BAKE_LOG=warning,bake=debug,bakelib=debug,bakefile=debug
# export BAKE_LOG_VERBOSITY=0
# export BAKE_LOG_PRETTY=true
# export DATABASE_URL=postgres://localhost/myapp
# export API_KEY='**********'
# Filter to specific fields with -i
bakefile export -i database_url -i api_key
# export DATABASE_URL=postgres://localhost/myapp
# export API_KEY='**********'
bakefile export -f json -i database_url -i api_key
# {
# "database_url": "postgres://localhost/myapp",
# "api_key": "**********"
# }Formats: sh (default), dotenv, json, yaml. Secrets stay masked unless you pass -s. Write to a file with -o:
bakefile export -f dotenv -o .env # .env file
bakefile export -f json -o config.json # JSON file
bakefile export -f yaml -o config.yaml # YAML filebakelib is an optional collection of opinionated helpers built on top of Bakebook. Includes Spaces (pre-configured tasks) and Environ (multi-environment support).
Install with:
pip install bakefile[lib]bakelib is optional; bakefile works without it. You can also write your own Bakebook classes if you prefer different conventions.
A Space is a Bakebook preconfigured for a project type. They share a common base and compose through inheritance: BaseSpace holds the shared tasks (lint, clean, setup-dev, tools, update, version), then PythonSpace and RustSpace add language-specific lint, test, and tool setup, and PythonLibSpace and RustLibSpace add publishing on top. GitHubActionsTools, BaseServiceSpace, and SubmodulesUtils extend BaseSpace directly.
The prebuilt spaces:
PythonSpace- Python lint, test, and dev setup (ruff, ty, deptry, pytest)RustSpace- Rust lint, tool setup, and update (clippy, fmt, rustup, cargo)PythonLibSpace-PythonSpacepluspublish(PyPI / TestPyPI)RustLibSpace-RustSpacepluspublish(crates.io)GitHubActionsTools- GitHub Actions linting (actionlint) and updatesBaseServiceSpace- build / deploy / destroy hooks to overrideSubmodulesUtils- sync git submodules
PythonSpace below is the worked example.
PythonSpace provides common tasks for Python projects:
from bakelib import PythonSpace
bakebook = PythonSpace()Available commands:
bake lint- Run prettier, toml-sort, ruff format, ruff check, ty, deptrybake test- Run pytest with coverage ontests/unit/bake test-integration- Run integration tests fromtests/integration/bake test-all- Run all testsbake clean- Clean gitignored files (with exclusions)bake clean-all- Clean all gitignored filesbake setup-dev- Setup Python development environmentbake tools- List development toolsbake update- Upgrade dependencies (includes uv lock --upgrade)- ...and more (run
bake --help)
Create custom spaces by inheriting from BaseSpace:
from bakelib import BaseSpace
class MySpace(BaseSpace):
def test(self) -> None:
self.ctx.run("npm test")
bakebook = MySpace()BaseSpace provides these tasks (override as needed):
lint()- Run prettierclean()/clean_all()- Clean gitignored filessetup_dev()- Setup development environmenttools()- List development toolsupdate()- Upgrade dependencies- ...and more
For projects with multiple environments (dev, staging, prod), use environment mixins:
from bakelib.environ import (
BaseEnv,
DevEnvMixin,
EnvBakebook,
ProdEnvMixin,
StagingEnvMixin,
get_bakebook,
)
# Compose env mixins with EnvBakebook
class DevBakebook(DevEnvMixin, EnvBakebook[BaseEnv]): ...
class StagingBakebook(StagingEnvMixin, EnvBakebook[BaseEnv]): ...
class ProdBakebook(ProdEnvMixin, EnvBakebook[BaseEnv]): ...
bakebook_dev = DevBakebook()
bakebook_staging = StagingBakebook()
bakebook_prod = ProdBakebook()
# Select bakebook based on ENV environment variable
bakebook = get_bakebook([bakebook_dev, bakebook_staging, bakebook_prod])ENV=prod bake deploy # Uses prod bakebook
ENV=dev bake deploy # Uses dev bakebook
bake deploy # Defaults to dev (lowest priority)Create custom environments by inheriting from BaseEnv:
from bakelib.environ import BaseEnv, EnvBakebook
class MyEnv(BaseEnv):
ENV_PRIORITY_ORDER = ("dev", "sit", "qa", "uat", "prod")
class MyEnvBakebook(EnvBakebook[MyEnv]):
env: MyEnv = MyEnv("dev")RefreshableCacheRegistry is a standalone refreshable cache for secrets or any fetched values, usable in any Python project with no Bakebook required. Subclass FetchFn to declare how a value is fetched, register it under a key, and the first get fetches and caches it.
from dataclasses import dataclass
from bakelib.refreshable_cache import FetchFn, KeyringCache, MemoryCache, RefreshableCacheRegistry
@dataclass(frozen=True)
class GcpSecretFetchFn(FetchFn[str]):
project_id: str
secret_id: str
def __call__(self) -> str:
# Real implementation calls the GCP Secret Manager API here
return "dummy-secret-value"
registry = RefreshableCacheRegistry[str](namespace="myapp", backends=[MemoryCache, KeyringCache])
registry.insert_cache(
"api_key",
fetch_fn=GcpSecretFetchFn(key="api_key", project_id="my-project", secret_id="api-key"),
)
registry.get("api_key") # fetches and caches on first call
registry.refresh("api_key") # force a re-fetch
registry.has_value("api_key") # True once cachedFor secrets rotated while your process runs, wrap the call in @cache.catch_refresh and raise RefreshNeededError when the service rejects the cached value:
cache = registry.get_cache("api_key")
@cache.catch_refresh
def call_api() -> str:
token = cache.get()
response = api_request(token) # your code
if response.status == 401: # token rejected (rotated server-side)
raise cache.RefreshNeededError
return response.bodyThe cache then clears and call_api retries, re-fetching a fresh token via cache.get(). Retries are tenacity-backed (stop/wait, defaults 2 attempts, no delay), so secrets refresh at runtime with no restart. acatch_refresh is the async variant.
Backends: MemoryCache (default, ephemeral), KeyringCache (system keyring, persistent), ChainedCache (several, read-first/write-all), NullCache (disabled). A single backend is used directly, multiple are wrapped in ChainedCache. Pass ttl= for expiry.
SecretUtils is a Bakebook mixin from bakelib.utils that wires a RefreshableCacheRegistry into your bakebook and adds the bake secret commands. It reuses the same FetchFn you defined above. Override get_secret_fetch_fns to declare which keys are tracked:
from bakelib.utils import SecretUtils
class MyBakebook(SecretUtils[str]):
def get_secret_fetch_fns(self):
return (GcpSecretFetchFn(key="api_key", project_id="my-project", secret_id="api-key"),)Only tracked keys can be set or read. The bake secret group:
bake secret list- tracked keys with cached/not-cached status (shows the namespace)bake secret get KEY- print a cached valuebake secret set KEY VALUE- store a value (plain, not prompted)bake secret del [KEY]- delete one key, or all if none givenbake secret refresh [KEY]- re-run the fetch functions for one key, or all
The default backend chain is MemoryCache + KeyringCache under the namespace "bakebook". Override get_secret_namespace() to isolate secrets per project.
For more details, see the bakelib source.
Clone and install the project:
git clone https://github.com/wislertt/bakefile.git
cd bakefile
# Install bakefile as a global tool
uv tool install bakefile
# Setup development environment (macOS only)
# Installs brew, bun, uv, and pre-commit hooks
bake setup-dev
# Verify development environment is setup correctly
# Checks tool locations and runs lint + test
bake assert-setup-devNote: bake setup-dev only supports macOS. For other platforms, run bake --dry-run setup-dev to see the commands and follow platform-specific alternatives.
The project uses uv for dependency management.
Run tests using the bake commands:
bake test # Unit tests (fast)
bake test-integration # Integration tests (slow, real subprocess)
bake test-all # All tests with coverageRun linters and formatters before committing:
bake lint # Run prettier, toml-sort, ruff format, ruff check, ty, deptryVerification workflow:
- Make changes
- Run
bake lintto check code quality - Run
bake testto verify unit tests pass - Commit when both pass
Contributions are welcome. See CLAUDE.md for development guidelines, including:
- Project structure and testing conventions
- Code quality standards
- Development workflow
Licensed under the Apache License 2.0. See LICENSE for the full text.