TL;DR

In this post I discuss my current choice of tools for setting up a Python project and running continuous integration via Github Actions.

In line with recent fashion, my choices revolve significantly around some of the recently introduced tools by Astral. Their common leitmotiv is that, although designed for the Python ecosystem, the tools themselves are written in Rust. This, along with some very nice design decisions (see this talk about uv, for example), makes them very reliable and fast.

As a working example, the actions available here have been implemented following this setup.

Summary

Dependencies: uv

Over recent years, I have fought many a battle against dependencies in Python projects. I have deployed and maintained projects using several tools: requirements.txt, Conda, Pipenv and Poetry. And it’s been a nightmare.

The history and status of Python packaging is worth a rant of its own, so we’ll save it for another time. Suffice it to say, the arrival of uv has been a delight, to the point that working with Python has become a far more joyful experience.

uv is what I use currently in all of my new projects, and also what I try to migrate old projects to when they need maintaining.

Dependencies for continuous integration are pinned in the dev group, in order to ensure that checks stay consistent across different environments. They can be added to the project by running:

uv add --group dev PACKAGE_NAME

They may be installed in a workflow run via:

uv sync --all-extras --dev

uv is also charged with installing the right version of Python, compatible with the version in pyproject.toml.

Since all CI checks need to recover dependencies, it is a good idea to define this part of the workflow in a separate and reusable way. In order to achieve this goal, we define a separate action for setting up uv, by building on top of the official astral-sh/setup-uv action. This ensures that cache is handled by checking the uv.lock file.

Unit tests: pytest

No surprises here.

Pytest continues to be the weapon of choice for coding test suites in Python. There are a few plugins that I like to use while developing, but the only interesting one to keep in CI is pytest-cov, which is used in order to produce coverage reports. Note: the setup for Github actions discussed in this post does not report coverage.

I tend to separate tests and main code by using separate tests/ and src/ folders. We can run the test suite as follows:

uv run pytest -v --cov=src

Type checking: ty

While Python is dynamically typed and interpreted, there definitely is a trend to favour the use of type hints and static type checkers, in an attempt to supply the kind of checks that a compiler would be tasked with in other languages.

A static type checker is useful in two places:

  • As a code linter, to provide feedback about types as you code.
  • As a CI check.

Until recently, using a type checker involved an intelligent use of the flags that allow for ignoring missing hints. By now, type hints are common and ubiquitous, and the most common libraries have already added them to their APIs. Static type checking allows for the early detection of entire classes of bugs that would otherwise have to be covered in unit tests.

Mypy is currently the most used type checker. However, the recently appeared Ty promises to be a serious candidate to replace it. Even though it is still in preview mode and not recommended for production use, I have added it to my setup both as a linter and a CI check.

It may be run by calling:

uv run ty check

Linting & formatting: ruff

In the past I have used several linters, most notably Pyflakes and Flake8. They are useful as IDE extensions while coding, and also as CI checks, especially for pointing out unused imports, methods and variables.

The arrival of automatic code formatters, notably Black, was an eye-opener. The point is to let all code formatting to be handled by an opinionated tool, so that the developer does not need to waste any energy on it. There is one consequence that is particularly nice: there will be no formatting discussions during code review in a team. This makes room to focus reviews on other, more important issues.

Ruff is a tool that doubles as a linter and code formatter. Its performance is so good that it is also quickly becoming a preferred choice in more and more projects.

One may check for linting issues by running:

uv run ruff check .

Code will be automatically formatted by calling:

uv run ruff format .

In CI, it is preferable to run a background check that fails whenever the formatter would introduce any changes:

uv run ruff format --check .

Security check: bandit

Bandit is helpful at finding common security issues in Python code.

This is the type of CI check that rarely fails unless you do not follow the most common good practices. However, it is worth to pay a lot of attention to any issue raised by it, because the security issues that it detects are show stoppers. For this reason, it is an excellent CI check.

It can be run via:

uv run bandit .

YAML Examples

Below are two YAML examples for a setup that uses the tools that we have discussed.

First, an action designed to be reused by other checks.

# .github/actions/setup-uv/action.yml
name: Setup UV
description: Install uv and sync dependencies
runs:
  using: "composite"
  steps:
    - uses: actions/checkout@v4
    - name: Install uv
      uses: astral-sh/setup-uv@v5
      with:
        enable-cache: true
        cache-dependency-glob: "uv.lock"
    - name: Set up Python 3.13
      run: uv python install 3.13
      shell: bash
    - name: Install dependencies
      run: uv sync --all-extras --dev
      shell: bash

Second, a workflow that defines a separate job for each check. The workflow is triggered on pull requests, and check results are reported in the Github pull requests UI.

# .github/workflows/pr-checks.yml
name: Run tests
on:
  - pull_request
jobs:
  pytest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-uv
      - name: Pytest
        run: uv run pytest -v --cov=src
  ty:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-uv
      - name: Ty
        run: uv run ty check
  ruff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-uv
      - name: ruff
        run: uv run ruff check .
  ruff-format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-uv
      - name: ruff
        run: uv run ruff format --check .
  bandit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-uv
      - name: Bandit
        run: uv run bandit .