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

ToolCapabilitiesWhy
pythonrwA runtime. Writes the working tree; reaches nothing.
piprw, netA package manager. Installing means talking to an index.
uvrw, netSame, much faster.

Known wart: boks --info pip displays CAPS rw and omits the net it 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:

PackageGrants
requests, httpx, aiohttpnet
flask, django, fastapinet
huggingface_huba ~/.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:

FrameworkDev serverBind
Flaskpython app.py / flask runapp.run(host="0.0.0.0") or --host 0.0.0.0
Djangopython manage.py runserverrunserver 0.0.0.0:8000
FastAPIuvicorn main:app--host 0.0.0.0
gunicornpython -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: debug
boks 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 .env is 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/python3

Think twice before shimming python

This is the tool to be most careful with:

  • It overwrites ~/.local/bin/python if a pyenv shim, a pipx symlink or a Homebrew link is already there.
  • It shadows the interpreter for everything resolving python through PATH — 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 a ModuleNotFoundError rather 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.py

Standalone 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 requests
chmod +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