Building a Python project with uv and Docker

  1. How to reduce the number of local development tools.

  2. How to optimally build a Docker image.

  3. How to check project code with hooks pre-commit and run tests in GitLab CI.

I think that you, like many developers, are not satisfied with the current situation in the Python community: there are a large number of tools that solve similar problems, but do it in different ways. With so many options available, it takes extra effort to choose the one that best suits the challenges ahead. Each tool must be installed and configured separately, and then its basic commands must be memorized.

However, there is no guarantee that a preliminary analysis will help you make the right choice, since there is already a lot of outdated and harmful advice on the Internet.

It should also be taken into account that each member of the Python team should be familiar with all these tools. Additional complexity arises when development is carried out on different operating systems.

Let's look at possible decision chains when looking for a way to install a Python command line utility for a beginner:

  1. Can be installed pipthen execute pip install foobut in the case of a Linux distribution this may break some system package. In 2023 in pip a mechanism has appeared to protect against such problems (v23.0). If your OS doesn't use it, you can still break everything. You can also forcefully disable it via the option --break-system-packages. There's even one on Stack Overflow answer with a proposal to add this option to the global config.

  2. You can try installing the package via pipbut with the option --user. This option also has potential problems if you install multiple packages this way. Dependencies of one package may conflict with dependencies of another package.

  3. Let’s imagine that a beginner immediately decides to install the package in a separate virtual environment, but even here it’s not so simple. Python has virtualenv And venv. What should he choose? When searching for an answer to a question, you may come across virtualenvwrapper, pyenv, pipenvetc. You can wish him patience.

  4. If he's lucky and finds pipxhe will be able to immediately install his package in a separate virtual environment, but only if he has the required version of the Python interpreter installed. If this is not the case, then you will have to look for a way to install the interpreter of the required version. In 2024 pipx it became possible to install the missing version of the interpreter through the option --fetch-missing-python(v1.5.0). However, you are unlikely to notice it easily, since after an unsuccessful package installation command with the option --python they won't tell you about it.

In my opinion, I have listed a sufficient number of problems that I would not like to encounter during development.

Next we will talk about a promising tool that appeared only this year, but already solves many problems.

uv package manager

In February 2024, a new package manager appeared uv from the creators Ruff.

We noticed it about six months ago while browsing the organization's repositories astral-sh on GitHub. At that moment there was no the ability to create a cross-platform file that rigidly fixes project dependencies (lock file) And convenient installation of the project in “non-editable” modebut I still gave it a star to follow the updates, because I really liked the idea.

uv It is interesting because it solves several problems at once:

  • installing different versions of Python;

  • installing and running Python command line utilities;

  • creating a Python virtual environment and installing dependencies;

  • building a Python project.

It's also nice that uv works very fast.

For complete happiness uv not enough yet getting a list of outdated dependencies and official integration with the IDE.

If we return to installing the Python utility from the previous section, then with uv One command would be enough:

uv tool install foo

This command will optionally install the Python interpreter, create a virtual environment, install the package into it, and add a symbolic link to the user's local directory with executable files.

Let's look at an example of use uv in a small Python project with the gRPC framework.

The project directory and file tree looks like this:

.
├── .venv/                   # Директория с виртуальным окружением Python. Игнорируется в Git.
├── etc/                     # Директория c конфигами.
│   └── alembic.ini
├── src/                     # Директория с исходным кодом.
│   └── my_project/          # Python модуль проекта.
│       ├── grpc/
│       ├── migrations/
│       ├── models/
│       ├── scripts/
│       └── __init__.py
├── tests/                   # Директория с тестами.
├── .dockerignore
├── .env                     # Игнорируется в Git.
├── .envrc                   # Игнорируется в Git.
├── .gitignore
├── .gitlab-ci.yml
├── .pre-commit-config.yaml
├── CHANGELOG.md
├── compose.yaml
├── docker-entrypoint.sh
├── Dockerfile
├── pyproject.toml
├── README.md
├── uv.lock
└── VERSION

The presented structure is supported by most package managers and build systems without the need for additional configuration. Storing configs in a directory etc and source code – in src makes it easier to later copy files into a Docker image.

Below is an example file pyproject.toml with explanations in the comments:

pyproject.toml

[project]
name = "my_project"
# Мы храним версию в файле `VERSION`.
# Это позволяет унифицировать логику версионирования разных проектов (например, проектов без файла `pyproject.toml`) и
# чаще переиспользовать Docker-слои, так как не каждое обновление версии сопровождается обновлением зависимостей Python.
version = "0.0.0"
authors = [
    { name = "Ivan Petrov", email = "ipetrov@example.com" },
]
# https://docs.astral.sh/uv/reference/resolver-internals/#requires-python
requires-python = ">=3.12"
# https://docs.astral.sh/uv/concepts/dependencies/#project-dependencies
dependencies = [
    "psycopg2==2.9.*",
    "sqlalchemy==2.0.*",
    "alembic==1.13.*",
    "grpcio==1.66.*",
]

# https://docs.astral.sh/uv/configuration/
# https://docs.astral.sh/uv/reference/settings/
[tool.uv]
# https://docs.astral.sh/uv/concepts/dependencies/#development-dependencies
dev-dependencies = [
    "grpcio-tools==1.66.*",
    "pytest==8.3.*",
]

# Здесь перечислены утилиты командной строки, которые станут доступны после установки проекта.
[project.scripts]
run_server = "my_project.scripts.run_server:cli"
do_something = "my_project.scripts.do_something:cli"

# https://docs.astral.sh/uv/concepts/projects/#build-systems
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Now you can create a virtual environment, install all the dependencies into it and secure them in the lock file with the following command:

uv sync

For additional convenience of local development, we use a shell extension direnv. It allows you to automatically create, update and activate a virtual environment, as well as export environment variables when entering the project directory.

Below is an example file .envrc For direnv:

.envrc

# https://direnv.net/man/direnv-stdlib.1.html
dotenv_if_exists
uv sync --frozen
source .venv/bin/activate
# https://github.com/direnv/direnv/wiki/PS1
unset PS1

So, we have installed the Python project locally. Now let's look at creating an end-user image using Docker.

Dockerfile with two-phase build

When creating a Docker image, you need to consider the following important points:

  • to get a compact image without unnecessary dependencies you need to use multi-stage assembly;

  • For reusing layers instructions should be ordered from rarely changed to frequently changed;

  • To quickly launch the application, Python files must first be compiled into bytecode.

One more important point can be highlighted regarding the reuse of Docker layers: the access rights of files and directories on the PC must match those used on the Docker image build server. If they differ, then when building the image locally with the option --cache-from all layers of instructions COPY will be created again.

Below is an example Dockerfile with explanations in the comments:

Dockerfile

# syntax=docker/dockerfile:1
# Сборочный этап.
# В качестве базового образа используем Ubuntu, так как в основном разработка у нас ведётся на этой ОС.
# При этом ничто не мешает использовать официальные образы Python от Docker.
FROM ubuntu:noble AS build

ARG python_version=3.12

# Переопределяем стандартную команду запуска шелла для выполнения команд в форме "shell".
# https://docs.docker.com/reference/dockerfile/#shell-and-exec-form
# Опция `-e` включает мгновенный выход после ошибки для любой непроверенной команды.
#   Команда считается проверенной, если она используется в условии оператора ветвления (например, `if`)
#   или является левым операндом `&&` либо `||` оператора.
# Опция `-x` включает печать каждой команды в поток stderr перед её выполнением. Она очень полезна при отладке.
# https://manpages.ubuntu.com/manpages/noble/en/man1/sh.1.html
SHELL ["/bin/sh", "-exc"]

# Устанавливаем системные пакеты для сборки проекта.
# Используем команду `apt-get`, а не `apt`, так как у последней нестабильный интерфейс.
# `libpq-dev` — это зависимость `psycopg2` — пакета Python для работы с БД, который будет компилироваться при установке.
RUN <<EOF
apt-get update --quiet
apt-get install --quiet --no-install-recommends --assume-yes \
  build-essential \
  libpq-dev \
  "python$python_version-dev"
EOF

# Копируем утилиту `uv` из официального Docker-образа.
# https://github.com/astral-sh/uv/pkgs/container/uv
# опция `--link` позволяет переиспользовать слой, даже если предыдущие слои изменились.
# https://docs.docker.com/reference/dockerfile/#copy---link
COPY --link --from=ghcr.io/astral-sh/uv:0.4 /uv /usr/local/bin/uv

# Задаём переменные окружения.
# UV_PYTHON — фиксирует версию Python.
# UV_PYTHON_DOWNLOADS — отключает автоматическую загрузку отсутствующих версий Python.
# UV_PROJECT_ENVIRONMENT — указывает путь к виртуальному окружению Python.
# UV_LINK_MODE — меняет способ установки пакетов из глобального кэша.
#   Вместо создания жёстких ссылок, файлы пакета копируются в директорию  виртуального окружения `site-packages`.
#   Это необходимо для будущего копирования изолированной `/app` директории из  стадии `build` в финальный Docker-образ.
# UV_COMPILE_BYTECODE — включает компиляцию файлов Python в байт-код после установки.
# https://docs.astral.sh/uv/configuration/environment/
# PYTHONOPTIMIZE — убирает инструкции `assert` и код, зависящий от значения  константы `__debug__`,
#   при компиляции файлов Python в байт-код.
# https://docs.python.org/3/using/cmdline.html#environment-variables
ENV UV_PYTHON="python$python_version" \
  UV_PYTHON_DOWNLOADS=never \
  UV_PROJECT_ENVIRONMENT=/app \
  UV_LINK_MODE=copy \
  UV_COMPILE_BYTECODE=1 \
  PYTHONOPTIMIZE=1

# Копируем файлы, необходимые для установки зависимостей без кода проекта, так как обычно зависимости меняются реже кода.
COPY pyproject.toml uv.lock /_project/

# Для быстрой локальной установки зависимостей монтируем кэш-директорию, в которой будет храниться глобальный кэш uv.
# Первый вызов `uv sync` создаёт виртуальное окружение и устанавливает зависимости без текущего проекта.
# Опция `--frozen` запрещает обновлять `uv.lock` файл.
RUN --mount=type=cache,destination=/root/.cache/uv <<EOF
cd /_project
uv sync \
  --no-dev \
  --no-install-project \
  --frozen
EOF

# Переключаемся на интерпретатор из виртуального окружения.
ENV UV_PYTHON=$UV_PROJECT_ENVIRONMENT

COPY VERSION /_project/
COPY src/ /_project/src

# Устанавливаем текущий проект.
# Опция `--no-editable` отключает установку проекта в  режиме "editable".
#   Код проекта копируется в директорию виртуального окружения `site-packages`.
RUN --mount=type=cache,destination=/root/.cache/uv <<EOF
cd /_project
sed -Ei "s/^(version = \")0\.0\.0(\")$/\1$(cat VERSION)\2/" pyproject.toml
uv sync \
  --no-dev \
  --no-editable \
  --frozen
EOF

# Финальный этап.
FROM ubuntu:noble AS final

# Два следующих аргумента позволяют изменить UID и GID пользователя Docker-контейнера.
ARG user_id=1000
ARG group_id=1000
ARG python_version=3.12

ENTRYPOINT ["/docker-entrypoint.sh"]
# Для приложений на Python лучше использовать сигнал SIGINT, так как не все фреймворки (например, gRPC) корректно обрабатывают сигнал SIGTERM.
STOPSIGNAL SIGINT
EXPOSE 8080/tcp

SHELL ["/bin/sh", "-exc"]

# Создаём группу и пользователя с нужными ID.
# Если значение ID больше нуля (исключаем "root" ID) и в системе уже есть пользователь или группа с указанным ID,
# пересоздаём пользователя или группу с именем "app".
RUN <<EOF
[ $user_id -gt 0 ] && user="$(id --name --user $user_id 2> /dev/null)" && userdel "$user"

if [ $group_id -gt 0 ]; then
  group="$(id --name --group $group_id 2> /dev/null)" && groupdel "$group"
  groupadd --gid $group_id app
fi

[ $user_id -gt 0 ] && useradd --uid $user_id --gid $group_id --home-dir /app app
EOF

# Устанавливаем системные пакеты для запуска проекта.
# Обратите внимание, что в именах пакетов нет суффиксов "dev".
RUN <<EOF
apt-get update --quiet
apt-get install --quiet --no-install-recommends --assume-yes \
  libpq5 \
  "python$python_version"
rm -rf /var/lib/apt/lists/*
EOF

# Задаём переменные окружения.
# PATH — добавляет директорию виртуального окружения `bin` в начало списка директорий с исполняемыми файлами.
#   Это позволяет запускать Python-утилиты из любой директории контейнера без указания полного пути к файлу.
# PYTHONOPTIMIZE — указывает интерпретатору Python, что нужно использовать ранее скомпилированные файлы из  директории `__pycache__` с  суффиксом `opt-1` в имени.
# PYTHONFAULTHANDLER — устанавливает обработчики ошибок для дополнительных сигналов.
# PYTHONUNBUFFERED — отключает буферизацию для потоков stdout и stderr.
# https://docs.python.org/3/using/cmdline.html#environment-variables
ENV PATH=/app/bin:$PATH \
  PYTHONOPTIMIZE=1 \
  PYTHONFAULTHANDLER=1 \
  PYTHONUNBUFFERED=1

COPY docker-entrypoint.sh /

COPY --chown=$user_id:$group_id /etc /app/etc
# Копируем директорию с виртуальным окружением из предыдущего этапа.
COPY --link --chown=$user_id:$group_id --from=build /app /app

USER $user_id:$group_id
WORKDIR /app

# Выводим информацию о текущем окружении и проверяем работоспособность импорта модуля проекта.
RUN <<EOF
python --version
python -I -m site
python -I -c 'import my_project'
EOF

Some readers may have a question about creating a virtual environment in a Docker image. Why complicate things when you can use a Docker image? python:3.12 as a base and install all dependencies in the system interpreter directory?

The following are the benefits of a virtual environment:

  • allows you to use different base images, since there is no conflict with system packages;

  • The directory structure in the container's virtual environment is similar to the directory structure in local development, and the uniformity simplifies the perception and support of projects.

Create a Docker image:

docker buildx build --tag my_image:latest .

In this section, we created a Docker image, but before we send it to the client, we would like to further ensure that the code complies with accepted standards and the tests pass successfully. Let's look at how this can be done using GitLab CI as an example.

Checking project code with pre-commit hooks and running tests in GitLab CI

To achieve our goals, we will need a slightly different Docker image:

ci.Dockerfile

# syntax=docker/dockerfile:1
FROM ubuntu:noble AS final

ARG python_version=3.12

SHELL ["/bin/sh", "-exc"]

# Устанавливаем системные пакеты для сборки проекта и фреймворка pre-commit.
RUN <<EOF
apt-get update --quiet
apt-get install --quiet --no-install-recommends --assume-yes \
  build-essential \
  libpq-dev \
  git \
  ca-certificates \
  "python$python_version-dev"
EOF

COPY --link --from=ghcr.io/astral-sh/uv:0.4 /uv /usr/local/bin/uv

# Добавляем  параметр `safe.directory` в глобальный Git-конфиг для предотвращения ошибки c "unsafe repository".
RUN git config --global --add safe.directory '*'

ENV UV_PYTHON="python$python_version" \
  UV_PYTHON_DOWNLOADS=never \
  UV_PROJECT_ENVIRONMENT=/app

# Устанавливаем pre-commit.
# Заметьте, что у следующей команды нет опции `--mount`. Это приводит к хранению кэша uv в образе.
# Для команды установки хуков pre-commit тоже не нужно добавлять  опцию `--mount`, чтобы не потерять кэш pre-commit.
# На текущий момент монтируемый кэш не экспортируется: https://github.com/moby/buildkit/issues/1512.
RUN <<EOF
uv tool run --compile-bytecode pre-commit --version
EOF

COPY .pre-commit-config.yaml /_project/

# Создаём пустой Git-репозиторий, чтобы установить хуки pre-commit без копирования директории проекта.
RUN <<EOF
cd /_project
git init
uv tool run pre-commit install-hooks
EOF

COPY pyproject.toml uv.lock /_project/

RUN <<EOF
cd /_project
uv sync \
  --no-install-project \
  --frozen \
  --compile-bytecode
EOF

ENV PATH=/app/bin:$PATH \
  UV_PYTHON=$UV_PROJECT_ENVIRONMENT

WORKDIR /_project

In the previous Dockerfile no copying of the project directory. The container will access the code through a Docker volume with assemblies, which is automatically mounted in GitLab CI. This allows us to save space in the Docker registry, since we do not create an additional layer with the project code.

The following are examples of three GitLab CI jobs:

1. build_docker_image-ci — builds a Docker image and uploads it to the Docker registry.

build_docker_image-ci:
  image: docker
  variables:
    # https://docs.gitlab.com/runner/configuration/feature-flags.html
    FF_DISABLE_UMASK_FOR_DOCKER_EXECUTOR: 1
    DOCKER_IMAGE: $CI_PROJECT_PATH/ci
  script:
    # Аутентифицируемся в Docker-реестре.
    - echo -n $DOCKER_PASS | docker login --username $DOCKER_USER --password-stdin $DOCKER_REGISTRY
    # Создаём нового сборщика.
    - docker buildx create --name my_builder --driver docker-container --bootstrap --use
    # Создаём Docker-образ и загружаем его в реестр.
    - |
      docker buildx build \
        --file ci.Dockerfile \
        --cache-from type=registry,ref=$DOCKER_REGISTRY/$DOCKER_IMAGE:cache \
        --cache-to type=registry,ref=$DOCKER_REGISTRY/$DOCKER_IMAGE:cache,mode=max \
        --pull \
        --tag $DOCKER_REGISTRY/$DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA \
        --tag $DOCKER_REGISTRY/$DOCKER_IMAGE:latest \
        --push \
        .

Additionally, you need to add a periodic task to remove old CI images from the registry. For example, delete all images older than one day, except images with tags latest And cache.

2. run_pre_commit_hooks – launches pre-commit hooks.

run_pre_commit_hooks:
  image: $DOCKER_REGISTRY/$CI_PROJECT_PATH/ci:$CI_COMMIT_SHORT_SHA
  script:
    - uv tool run pre-commit run --all-files

3. run_tests — runs tests.

run_tests:
  services:
    - name: postgres:15
      variables:
        POSTGRES_USER: my_user
        POSTGRES_PASSWORD: my_password
  image: $DOCKER_REGISTRY/$CI_PROJECT_PATH/ci:$CI_COMMIT_SHORT_SHA
  variables:
    # https://docs.gitlab.com/ee/ci/services/#connecting-services
    FF_NETWORK_PER_BUILD: 1
  script:
    # uv автоматически установит проект в "editable" режиме.
    - uv run --frozen pytest

Creating a separate Docker image for GitLab CI makes code review tasks easier and faster. In this option, you do not need to use GitLab CI caching and launch a child Docker container from the main Docker container.

A Docker-in-Docker option might look something like this:

my_job:
  ...
  image: docker
  script:
    - |
      docker buildx build \
        --file ci.Dockerfile \
        --tag $DOCKER_IMAGE \
        .
    - container_id=$(docker ps --filter "label=com.gitlab.gitlab-runner.job.id=$CI_JOB_ID" --filter "label=com.gitlab.gitlab-runner.type=build" --quiet)
    - volume_name=$(docker inspect --format '{{ range .Mounts }}{{ if eq .Destination "/builds" }}{{ .Name }}{{end}}{{end}}' $container_id)
    - network_name=$(docker inspect --format '{{ range $network_name, $_ := .NetworkSettings.Networks }}{{ $network_name }}{{ end }}' $container_id)
    - |
      docker run \
        --mount type=volume,source=$volume_name,destination=/builds \
        --network $network_name \
        --workdir $CI_PROJECT_DIR \
        $DOCKER_IMAGE \
        my_command

However, in my experience, people usually have a harder time understanding something like this.

Conclusion

We considered one of the options for using the link uv and Docker for convenient local development and creation of a Docker image of the final product. In our case, one tool was replaced immediately four: pip, pyenv, pipx and Poetry, and the multi-stage build made it possible to reduce the size of the Docker image by three times in one of the projects. Hopefully there will be a package manager in the future uv will continue to develop, change the situation with Python tools for the better and will not let its users down!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *