Python
Create a virtualenv, lint and test a Python project in containers, cached by its requirements and sources.
This tutorial runs a small Python package's checks, ruff and pytest, in a
python:3.13-slim container, with the virtualenv built once and reused until the
requirements change. You need hammerkit and Docker; Python
doesn't have to be installed on your machine.
The finished project is
examples/tutorial-python.
The project
tutorial-python/
├── requirements.txt
├── pyproject.toml
├── greet/__init__.py
└── tests/test_greet.pyEvery package is pinned, including the ones pytest pulls in, so the file determines exactly what gets installed:
iniconfig==2.3.0
packaging==26.3
pluggy==1.6.0
Pygments==2.21.0
pytest==9.1.1
ruff==0.16.10
def hello(name: str) -> str:
return f"Hello, {name}!"
from greet import hello
def test_greets_by_name():
assert hello("hammerkit") == "Hello, hammerkit!"
1. Create the virtualenv
A task names the image it runs in, the files it reads (src) and the files it
produces (generates). The install task builds .venv from the requirements:
tasks:
install:
description: create the virtualenv from the pinned requirements
image: python:3.13-slim
src: [requirements.txt]
generates: [.venv]
cmds:
- python -m venv .venv
- .venv/bin/python -m pip install --no-cache-dir -r requirements.txthammerkit installRun it again and hammerkit skips it: requirements.txt is unchanged. Only a
dependency change rebuilds the virtualenv.
Call tools as .venv/bin/python -m <tool> rather than .venv/bin/<tool>. The
scripts in .venv/bin hard-code the absolute path the venv was created at, so they
break when a cached .venv is restored into a checkout at another path, like a
CI runner's. python -m works from anywhere.
2. Lint and test
Both tasks depend on install, which mounts its .venv into their containers, and
read the package, the tests and the tool configuration:
envs:
# keep Python from writing __pycache__ into the mounted sources
PYTHONDONTWRITEBYTECODE: '1'
tasks:
lint:
description: lint with ruff
image: python:3.13-slim
deps: [install]
src: [pyproject.toml, greet, tests]
cmds:
- .venv/bin/python -m ruff check .
test:
description: run the tests
image: python:3.13-slim
deps: [install]
src: [pyproject.toml, greet, tests]
cmds:
- .venv/bin/python -m pytestSources are mounted read-write. Without PYTHONDONTWRITEBYTECODE, Python writes
__pycache__ folders into greet and tests, which changes the tasks' own
inputs, and the next run misses the cache.
lint and test don't depend on each other, so they run in parallel.
3. One task for CI
A task without cmds groups other tasks. ci names everything a change has to
pass. The complete build file:
envs:
# keep Python from writing __pycache__ into the mounted sources
PYTHONDONTWRITEBYTECODE: '1'
tasks:
install:
description: create the virtualenv from the pinned requirements
image: python:3.13-slim
src: [requirements.txt]
generates: [.venv]
cmds:
- python -m venv .venv
- .venv/bin/python -m pip install --no-cache-dir -r requirements.txt
lint:
description: lint with ruff
image: python:3.13-slim
deps: [install]
src: [pyproject.toml, greet, tests]
cmds:
- .venv/bin/python -m ruff check .
test:
description: run the tests
image: python:3.13-slim
deps: [install]
src: [pyproject.toml, greet, tests]
cmds:
- .venv/bin/python -m pytest
ci:
description: everything CI checks
deps: [lint, test]
hammerkit run ciSummary:
ci executed 6.9s
install executed 6.1s
lint executed 6.6s
test executed 6.9s
4 executed, 0 cached (0% cache hit), 7.0s totalRun it again without changing anything:
Summary:
ci cached 19ms
install skipped 0ms
lint cached 0ms
test cached 0ms
0 executed, 3 cached, 1 skipped (100% cache hit), 45ms totalinstall is skipped: every task that needs it was a cache hit, so the virtualenv
isn't needed either. Edit a file in greet and only lint and test run again.
Ignore what hammerkit and the tools write into the project:
.hammerkit
.venv
Run it in CI
CI runs the same command: install hammerkit and run
hammerkit run ci. A fresh runner starts with an empty cache; to reuse what your
laptop or the previous run already built, share the cache as described in
caching in CI.
Next steps
- Tests that need a database: start it as a service and let the
task
needit, see services & networking. - A task reruns and you don't know why:
hammerkit explain.