Monday, 6 October 2025

Setting up a Python learning environment: Docker, pytest, and ruff

G'day:

I'm learning Python. Not because I particularly want to, but because my 14-year-old son Zachary has IT homework and I should probably be able to help him with it. I've been a web developer for decades, but Python's never been part of my stack. Time to fix that gap.

This article covers getting a Python learning environment set up from scratch: Docker container with modern tooling, pytest for testing, and ruff for code quality. The goal is to have a proper development environment where I can write code, run tests, and not have things break in stupid ways. Nothing revolutionary here, but documenting it for when I inevitably forget how Python dependency management works six months from now.

The repo's at github.com/adamcameron/learning-python (tag 3.0.2), and I'm tracking this as Jira tickets because that's how my brain works. LP-1 was the Docker setup, LP-2 was the testing and linting toolchain.

Getting Docker sorted

First job was getting a Python container running. I'm not installing Python directly on my Windows machine - everything goes in Docker. This keeps the host clean and makes it easy to blow away and rebuild when something inevitably goes wrong.

I went with uv for dependency management. It's the modern Python tooling that consolidates what used to be pip, virtualenv, and a bunch of other stuff into one fast binary. It's written in Rust, so it's actually quick, and it handles the virtual environment isolation properly.

The docker-compose.yml is straightforward:

services:
    python:
        build:
            context: ..
            dockerfile: docker/python/Dockerfile

        volumes:
            - ..:/usr/src/app
            - venv:/usr/src/app/.venv

        stdin_open: true
        tty: true

volumes:
    venv:

The key bit here is that separate volume for .venv. Without it, you get the same problem as with Node.js - the host's virtual environment conflicts with the container's. Using a named volume keeps the container's dependencies isolated while still letting me edit source files on the host.

The Dockerfile handles the initial setup:

FROM astral/uv:python3.11-bookworm

RUN echo "alias ll='ls -alF'" >> ~/.bashrc
RUN echo "alias cls='clear; printf \"\033[3J\"'" >> ~/.bashrc

RUN ["apt-get", "update"]
RUN ["apt-get", "install", "-y", "vim"]

WORKDIR  /usr/src/app

ENV UV_LINK_MODE=copy

RUN \
    --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync \
    --no-install-project

ENTRYPOINT ["bash"]

Nothing fancy. The astral/uv base image already has Python and uv installed. I'm using Python 3.11 because it's stable and well-supported. The uv sync at build time installs dependencies from pyproject.toml, and that cache mount makes rebuilds faster.

The ENTRYPOINT ["bash"] keeps the container running so I can exec into it and run commands. I'm used to having PHP-FPM containers that stay up with their own service loop, and this achieves the same thing.

One thing I'm doing here differently from usual, is that I am using mount to temporarily expose files to the Docker build process. In the past I would have copied pyproject.toml into the image file system. Why the change? Cos I did't realise I could do this until I saw it in this article I googled up: "Using uv in Docker › Intermediate layers"! I'm gonna use this stragey from now on, I think…

Project configuration and initial code

Python projects use pyproject.toml for configuration - it's the equivalent of package.json in Node.js or composer.json in PHP. Here's the initial setup:

[project]
name = "learning-python"
version = "0.1"
description = "And now I need to learn Python..."
readme = "README.md"
requires-python = ">=3.11"
dependencies = []

[project.scripts]
howdy = "learningpython.lp2.main:greet"

[build-system]
requires = ["uv_build>=0.8.15,<0.9.0"]
build-backend = "uv_build"

[tool.uv.build-backend]
namespace = true

The project.scripts section defines a howdy command that calls the greet function from learningpython.main. The syntax is module.path:function. This makes the function callable via uv run howdy from the command line.

The namespace = true bit tells uv to use namespace packages, which means I don't need __init__.py files everywhere. Modern Python packaging is less fussy than it used to be.

The actual code in src/learningpython/lp2/main.py is about as simple as it gets:

def greet():
    print("Hello from learning-python!")

if __name__ == "__main__":
    greet()

Nothing to it. The if __name__ == "__main__" bit means the function runs when you execute the file directly, but not when you import it as a module. Standard Python pattern.

With all this in place, I could build and run the container:

$ docker compose -f docker/docker-compose.yml up --detach
[+] Running 2/2
 ✔ Volume "learning-python_venv"  Created
 ✔ Container learning-python-python-1  Started

$ docker exec learning-python-python-1 uv run howdy
Hello from learning-python!

Right. Basic container works, simple function prints output. Time to sort out testing.

Installing pytest and development dependencies

Python separates runtime dependencies from development dependencies. Runtime deps go in the dependencies array, dev deps go in [dependency-groups]. Things like test frameworks and linters are dev dependencies - you need them for development but not for running the actual application.

To add pytest, I used uv add --dev pytest. This is the Python equivalent of composer require --dev in PHP or npm install --save-dev in Node. The --dev flag tells uv to put it in the dev dependency group rather than treating it as a runtime requirement.

I wanted to pin pytest to major version 8, so I checked PyPI (pypi.org/project/pytest/) to see what was current. As of writing it's 8.4.2. Python uses different version constraint syntax than Composer - instead of ^8.0 you write >=8.0,<9.0. More verbose but explicit.

I also wanted a file watcher like vitest has. There's pytest-watch but it hasn't been maintained since 2020 and doesn't work with modern pyproject.toml files. There's a newer alternative called pytest-watcher that handles the modern Python tooling properly.

After running uv add --dev pytest pytest-watcher, the pyproject.toml updated to include:

[dependency-groups]
dev = [
    "pytest>=8.4.2,<9",
    "pytest-watcher>=0.4.3,<0.5",
]

The uv.lock file pins the exact versions that were installed, giving reproducible builds. It's the Python equivalent of composer.lock or package-lock.json.

Writing the first test

pytest discovers test files automatically. It looks for files named test_*.py or *_test.py and runs functions in them that start with test_. No configuration needed for basic usage.

I created tests/lp2/test_main.py to test the greet() function. The test needed to verify that calling greet() outputs the expected message to stdout. pytest has a built-in fixture called capsys that captures output streams:

from learningpython.lp2.main import greet

def test_greet(capsys):
    greet()
    captured = capsys.readouterr()
    assert captured.out == "Hello from learning-python!\n"

The capsys parameter is a pytest fixture - you just add it as a function parameter and pytest provides it automatically. Calling readouterr() gives you back stdout and stderr as a named tuple. The \n at the end is because Python's print() adds a newline by default.

Running the test:

$ docker exec learning-python-python-1 uv run pytest
======================================= test session starts ========================================
platform linux -- Python 3.11.13, pytest-8.4.2, pluggy-1.6.0
rootdir: /usr/src/app
configfile: pyproject.toml
collected 1 item

tests/lp2/test_main.py .                                                                     [100%]

======================================== 1 passed in 0.01s =========================================

Green. The test found the pyproject.toml config automatically and discovered the test file without needing to tell it where to look.

For continuous testing, pytest-watcher monitors files and re-runs tests on changes:

$ docker exec learning-python-python-1 uv run ptw
[ptw] Watching directories: ['src', 'tests']
[ptw] Running: pytest
======================================= test session starts ========================================
platform linux -- Python 3.11.13, pytest-8.4.2, pluggy-1.6.0
rootdir: /usr/src/app
configfile: pyproject.toml
collected 1 item

tests/lp2/test_main.py .                                                                     [100%]

======================================== 1 passed in 0.01s =========================================

Any time I change a file in src or tests, it automatically re-runs the relevant tests. Much faster feedback loop than running tests manually each time.

Code formatting and linting with ruff

Python has a bunch of tools for code quality - black for formatting, flake8 for linting, isort for import sorting. Or you can just use ruff, which consolidates all of that into one fast tool written in Rust.

Installation was the same pattern: uv add --dev ruff. This added "ruff>=0.8.4,<0.9" to the dev dependencies.

ruff has two main commands:

  • ruff check - linting (finds unused variables, style issues, code problems)
  • ruff format - formatting (fixes indentation, spacing, line length)

Testing it out with some deliberately broken code:

$ docker exec learning-python-python-1 uvx ruff check src/learningpython/lp2/main.py
F841 Local variable `a` is assigned to but never used
 --> src/learningpython/lp2/main.py:2:8
  |
1 | def greet():
2 |        a = "wootywoo"
  |        ^
3 |        print("Hello from learning-python!")
  |
help: Remove assignment to unused variable `a`

It caught the unused variable. It also didn't complain about the 7-space indentation, because ruff check is about code issues, not formatting. That's what ruff format is for:

$ docker exec learning-python-python-1 uvx ruff format src/learningpython/lp2/main.py
1 file reformatted

This fixed the indentation to Python's standard 4 spaces. The check command can also auto-fix some issues with --fix, similar to eslint.

I configured IntelliJ to run ruff format on save. Had to disable a conflicting AMD Adrenaline hotkey first - video driver software stealing IDE shortcuts is always fun to debug. It took about an hour to work out WTF was going on there. I really don't understand why AMD thinks its driver software needs hotkeys. Dorks.

A Python gotcha: hyphens in paths

I reorganised the code by ticket number, so I moved the erstwhile main.py to src/learningpython/lp-2/main.py. Updated the pyproject.toml entry point to match:

[project.scripts]
howdy = "learningpython.lp-2.main:greet"

This did not go well:

$ docker exec learning-python-python-1 uv run howdy
      Built learning-python @ file:///usr/src/app
Uninstalled 1 package in 0.37ms
Installed 1 package in 1ms
  File "/usr/src/app/.venv/bin/howdy", line 4
    from learningpython.lp-2.main import greet
                            ^
SyntaxError: invalid decimal literal

Python's import system doesn't support hyphens in module names. When it sees lp-2, it tries to parse it as "lp minus 2" and chokes. Module names need to be valid Python identifiers, which means letters, numbers, and underscores only.

Renaming to lp2 fixed it. No hyphens in directory names if those directories are part of the import path. You can use hyphens in filenames that you access directly (like python path/to/some-script.py), but not in anything you're importing as a module.

This caught me out because hyphens are fine in most other ecosystems. Coming from PHP and JavaScript where some-module-name is perfectly normal, Python's stricter rules take some adjustment.

Wrapping up

So that's the development environment sorted. Docker container running Python 3.11 with uv for dependency management. pytest for testing with pytest-watcher for continuous test runs. ruff handling both linting and formatting. All the basics for writing Python code without things being annoying.

The final project structure looks like this:

learning-python/
├── docker/
│   ├── docker-compose.yml
│   └── python/
│       └── Dockerfile
├── src/
│   └── learningpython/
│       └── lp2/
│           └── main.py
├── tests/
│   └── lp2/
│       └── test_main.py
├── pyproject.toml
└── uv.lock

Everything's on GitHub at github.com/adamcameron/learning-python (tag 3.0.2).

Now I can actually start learning Python instead of fighting with tooling. Which is the point.

Righto.

--
Adam