Python
Python, pip and uv under boks — and the one thing about Python under boks that surprises everybody.
Read the three levels first if you haven't.
The thing that surprises everybody
Python's ordinary answer to "I need a dependency" is pip install. Under boks
that command appears to work:
$ boks pip install requests
Installing collected packages: urllib3, idna, charset_normalizer, certifi, requests
WARNING: The script idna is installed in '/boks/home/.local/bin' which is not on PATH.
Successfully installed … requests-2.34.2 …
and then evaporates:
$ boks python -c "import requests"
ModuleNotFoundError: No module named 'requests'
Nothing went wrong. /boks/home is a tmpfs that exists for the life of one
container, so an install into it is gone before your next command starts.
There is no working-tree equivalent of Node's node_modules here, and no
virtualenv to activate. A requirements file is the mechanism. boks reads
it, builds a layered environment image with those packages installed, caches
it by content hash, and runs your code against that image.
Once you internalise that, the rest of Python under boks is straightforward.
Tools and their defaults
| Tool | Capabilities | Why |
|---|---|---|
python | rw | A runtime. Writes the working tree; reaches nothing. |
pip | rw, net | A package manager. Installing means talking to an index. |
uv | rw, net | Same, much faster. |
Known wart:
boks --info pipdisplaysCAPS rwand omits thenetit really runs with — that view reads the image label rather than the per-name policy override. The grant is real.
Packages that grant capabilities
Some packages auto-grant capabilities when they end up in a built
environment, because a package whose entire purpose is network access should
not also require you to type --cap net:
| Package | Grants |
|---|---|
requests, httpx, aiohttp | net |
flask, django, fastapi | net |
huggingface_hub | a ~/.cache/huggingface dotfile mount |
So this needs no capability flag at all, even though bare boks python has no
network whatsoever:
$ boks -e @pip:requirements.txt python script.py fetch https://example.com
https://example.com -> 200, 559 bytes
The grant lands on the built environment image, not on python generally.
It also shows up in the trust prompt at level 2, tagged with the file that
caused it. A project can declare its own rules for internal packages the
public catalog will never know about:
package_rules:
- provider: pip
package: my-internal-client
capabilities: [net]Level 1 — on the command line
# no dependencies
boks python script.py
boks python -c "import sys; print(sys.version)"
# inline, for a one-liner
boks python:requests -c "import requests; print(requests.__version__)"
# from a requirements file
boks -e @pip:requirements.txt python script.py
boks -e @pip python script.py # same: requirements.txt is the default
# uv instead of pip — same result, much faster build
boks -e @uv:requirements.txt python script.py
# a specific interpreter
boks python@3.12 script.py
Pin your requirements. The environment image is cached by the content hash of the file, so unpinned dependencies mean an image whose contents you cannot reason about.
Serving
boks -p 8000 python -m http.server 8000 --bind 0.0.0.0
Two separate things again: -p publishes the port (and implies network), and
the server itself must bind 0.0.0.0 — a server on 127.0.0.1 inside a
container is bound to the container's own loopback and unreachable from your
machine (curl exits 52). Every Python web framework has its own spelling:
| Framework | Dev server | Bind |
|---|---|---|
| Flask | python app.py / flask run | app.run(host="0.0.0.0") or --host 0.0.0.0 |
| Django | python manage.py runserver | runserver 0.0.0.0:8000 |
| FastAPI | uvicorn main:app | --host 0.0.0.0 |
| gunicorn | python -m gunicorn app:app | --bind 0.0.0.0:8000, before the app argument |
That last row is a real trap: gunicorn's parser treats anything after a
positional as free text, so gunicorn app:app --bind 0.0.0.0:8000 silently
keeps the default 127.0.0.1:8000 and your published port answers nothing.
Level 2 — a project .boksrc
tools:
python:
env_file: requirements.txt
pass_env:
- EXAMPLE_TOKEN
set_env:
LOG_LEVEL: debugboks python script.py # -e never typed again in this project
An explicit -e on the command line still wins, so env_file is a default
rather than a lock.
Three ways a value reaches the container
set_env— authored in.boksrc, always present, wins over the others for the same key.pass_env— forwards a variable if your shell has it. boks never invents a value; the declaration only opens the door.dotenv— read host-side from a file.python's index entry already declares.env, so a project's.envis parsed on the host and injected as ordinary environment variables — while the raw file stays unreadable from inside the container.
Anything undeclared does not travel:
$ EXAMPLE_TOKEN=… AWS_SECRET_ACCESS_KEY=… boks python -c "…"
EXAMPLE_TOKEN: set
AWS_SECRET_ACCESS_KEY: (unset)
Your shell environment is not the container's environment, and the difference is a reviewed, committed file. That is most of what boks changes about what a compromised dependency can do.
Django and other manage.py shapes
python manage.py runserver's real subcommand is at args[1] — args[0] is
always the literal string "manage.py". That is what subcommand_index is
for:
tools:
django-python:
alias: python
env_file: requirements.txt
subcommand_index: 1
subcommands:
runserver:
ports: ["8000"]
args: ["0.0.0.0:8000"]
boks django-python manage.py runserver gets the port and the bind address.
… manage.py test and … manage.py migrate get neither — they never
asked to be a server.
dev/prod variants
python-dev:
alias: python
env_file: requirements-dev.txt
set_env: { LOG_LEVEL: debug }
python-prod:
alias: python
env_file: requirements.txt
set_env: { LOG_LEVEL: warning }boks python-dev -m pytest # test deps
boks python-prod app.py # app deps only
boks python app.py # untouched
Note for requirements-dev.txt: repeat the app's own pins in it rather than
using -r requirements.txt. The pip provider copies only the one named file
into the build context, so a -r reference to a file that was never copied
fails the build.
Level 3 — a shim
boks -i python$ python script.py
python 3.14.7
requests 2.34.2
$ python -c "import sys; print(sys.executable)"
/bin/python3Think twice before shimming python
This is the tool to be most careful with:
- It overwrites
~/.local/bin/pythonif a pyenv shim, a pipx symlink or a Homebrew link is already there. - It shadows the interpreter for everything resolving
pythonthroughPATH— editor plugins, language servers, other tools' subprocesses. They will all get a containerised Python with no network and no access outside the current directory. - Outside a project with a
.boksrc, you get bare defaults — no dependencies, no network, and aModuleNotFoundErrorrather than a message about levels.
The safer pattern is to shim the project-scoped alias instead — boks -i python-dev — which resolves only inside a project whose .boksrc mints it
and fails loudly with Unknown tool elsewhere.
For one-offs, boks flags move to the environment, since python -e is
Python's own flag:
BOKS_ARGS="-e @pip:requirements-dev.txt" python -m pytest
BOKS_ARGS="--cap net" python fetch.pyStandalone scripts: the shebang
For a single script, put the whole invocation in the file:
#!/usr/bin/env -S boks -e @pip:requirements.txt python
import requestschmod +x script.py
./script.py
Anyone with boks installed can run it with no setup at all — no level 2, no shim, no virtualenv. It is the best way to hand someone a Python script that has dependencies.
Testing and CI
boks -e @pip:requirements-dev.txt python -m pytest -v
In CI, level 1 explicitly:
- run: boks --non-interactive -e @pip:requirements-dev.txt python -m pytest
--non-interactive never applies a project overlay on a project that isn't
already trusted, so the pipeline's real capability grants stay visible in the
pipeline rather than depending on someone's laptop.
Worked examples
pip-python-file— all three levels,env_file/pass_env/set_env, and the vanishingpip install.pip-python-inline—python:requestswith no file at all.pip-python-lifecycle— adding a package when you don't know its version, freezing, upgrading.uv-python— the uv provider.flask-quickstart,django-quickstart,fastapi-quickstart— full frameworks, run/develop/test/deploy.pip-python-critical-cve— what happens when the environment image's scan finds something.