<!--
  canonical-url: https://www.avonture.be/fr/blog/dagger-python
  published:     2024-12-26
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Dagger.io - Utiliser dagger pour automatiser vos workflows CI

> Automatisez vos workflows CI/CD avec Dagger.io et Python. Lancez rapidement vos outils de qualité de code (Pylint, Black, Ruff) et testez tout votre pipeline en local pour simplifier l'intégration.

---
![Dagger.io - Utiliser dagger pour automatiser vos workflows CI](/img/v2/dagger.webp)

**TL;DR**

Cet article montre comment « daggeriser » un projet Python avec Dagger.io afin que les étapes de CI (Pylint, Black, mypy, Ruff) soient définies une seule fois dans des fonctions Python et s'exécutent à l'identique en local (via un CLI `dagger` dockerisé, enveloppé dans des cibles `make lint`/`make format`) et dans une CI distante comme GitLab — fini la boucle lente « push et prie » pour déboguer un `.gitlab-ci.yml` uniquement sur le serveur. On y voit `dagger init`, l'écriture des fonctions du pipeline, leur exécution concurrente avec `run-all`, et la configuration du runner GitLab pour partager le socket Docker.

**Attention, c'est une bombe.** Docker a révolutionné le monde ; n'ayons pas peur de le dire haut et fort, et il y a fort à parier que [Dagger.io](https://dagger.io/), créé par les mêmes personnes que Docker, suivra le même chemin.

Dagger.io veut être un outil qui vous permet d'exécuter les étapes d'un workflow exactement de la même manière qu'un système de **CI/CD** (**Continuous Integration / Continuous Delivery**) comme ceux de GitHub, GitLab, Jenkins, ...

Mais qu'est-ce qu'une CI ? C'est une étape réalisée par votre serveur une fois que vous avez poussé une nouvelle version de votre projet. Pendant une CI, vous pouvez valider la syntaxe de votre code, vérifier qu'il respecte certaines règles (de formatage par exemple), lancer des outils d'analyse de qualité de code comme la vérification que vous n'avez pas de variables non déclarées ou non typées, ni de code mort (par exemple une fonction que vous n'utilisez plus).

Pendant une CI, vous pouvez aussi lancer vos tests unitaires et les exécuter à chaque nouvelle version poussée.

Le but de la CI, c'est de ... **planter dès que quelque chose n'est pas dans l'état attendu** ; par exemple vous avez oublié un `;`, vous avez une violation (des espaces à la place de tabulations), un test unitaire ne passe plus, etc. L'objectif de la CI est donc de garantir que votre code est excellent.

## Le résultat : un pipeline qui tourne, sans YAML, sans push {#the-result-a-working-pipeline-no-yaml-no-push}

Une fois Dagger branché sur un petit projet Python, `dagger init` génère un pipeline fonctionnel en deux minutes environ — aucun serveur CI impliqué, aucun `.gitlab-ci.yml` à pousser puis attendre :

```bash
$ ls -alh
Permissions Size User       Group      Date Modified    Name
drwxr-xr-x     - christophe christophe 2024-12-26 16:00 .docker
drwxr-xr-x     - christophe christophe 2024-12-26 16:02 .pipeline
drwxr-xr-x     - christophe christophe 2024-12-26 16:02 src
.rw-r--r--    94 christophe christophe 2024-12-26 16:02 dagger.json
.rw-------   10k christophe christophe 2024-12-26 16:02 LICENSE
```

Et le pipeline est immédiatement appelable, directement sur votre machine :

```bash
Setup tracing at https://dagger.cloud/traces/setup. To hide: export STOPIT=1

Call one or more functions, interconnected into a pipeline

USAGE
  dagger call [options] \<function>

FUNCTIONS
  container-echo   Returns a container that echoes whatever string argument is provided
  grep-dir         Returns lines that match a pattern in the files of the provided Directory
```

Deux fonctions d'exemple (`container-echo`, `grep-dir`) existent dès le départ — la preuve qu'un pipeline Dagger n'est rien d'autre que des fonctions Python que vous pouvez lancer en local. La suite de cet article transforme ces exemples en véritables étapes Pylint/Black/mypy/Ruff, puis branche exactement le même pipeline dans une CI GitLab.

## Pourquoi ça fonctionne {#why-it-works}

* Les étapes de CI sont de simples fonctions Python — aucun DSL YAML à apprendre, aucune syntaxe qui n'existe que sur le serveur.
* Exactement la même fonction tourne sur votre portable et dans GitLab/GitHub : même container, même sortie, plus de surprise après un push.
* Dagger exécute tout dans un container qu'il contrôle, donc les outils dont votre pipeline a besoin (Pylint, Black, mypy, Ruff, ...) sont figés et reproductibles, pas « ce qui traîne sur le runner ».
* Les fonctions se composent : appelez-en une pour linter, ou appelez une fonction `run-all` pour lancer lint/format/type-check/ruff en parallèle et s'arrêter au premier échec.
* La vieille boucle push → attendre → lire le log → deviner → repousser peut être entièrement jouée hors ligne, avant que quoi que ce soit n'atteigne le serveur.

## Installation {#installation}

Dans ce tutoriel, nous allons **daggeriser** un repository, c'est-à-dire partir de zéro, créer un petit script Python, créer une image Docker Dagger, puis initialiser notre projet pour utiliser Dagger.

Créons un dossier temporaire pour Dagger et plaçons-nous dedans : `mkdir /tmp/dagger && cd $_`.

Créez-y un script Python dans un sous-dossier `src` et appelons-le `src/main.py` :

**src/main.py**

```python
if random.randint(0, 1) == 0:
    print("Hello world")
else:
    print("Bonjour le monde!")
```

Formidable application qui nous dit, au hasard, *Hello world* ou *Bonjour le monde!*

### Nos objectifs {#our-objectives}

Chaque fois que nous pousserons notre code vers notre outil de versioning (comme GitLab), nous voulons :

* Lancer [Pylint](https://pypi.org/project/pylint/), *Lint python scripts using Pylint - Run analyses your code without actually running it*,
* Lancer [Black](https://black.readthedocs.io/en/stable/), *Format the script using Black*,
* Lancer [Mypy](https://github.com/python/mypy/), *Mypy is a program that will type check your Python code* et
* Lancer [Ruff](https://github.com/astral-sh/ruff), *an extremely fast Python linter and code formatter*

(J'ai détaillé ces quatre outils dans [Python - Code Quality tools](/blog/python-qa).)

Ces étapes sont déclenchées dans notre CI (GitLab, GitHub, ...) à chaque push et, pour faire les mêmes actions en local, nous devons créer par exemple quelques actions make (`make lint`, `make format`, ...).

### Nous voulons Dagger {#we-want-dagger}

![Dagger](./images/dagger.webp)

Sans surprise, à un moment il faut installer Dagger. Installer ? Aïe non ; on ne va pas l'installer car, en amoureux de Docker, nous allons utiliser Docker et créer notre image Dagger.

Créez un nouveau sous-dossier appelé `.docker` et, dans ce dossier, un fichier appelé `Dockerfile` :

**.docker/Dockerfile**

```docker
# cspell:disable

FROM python:3.11-slim-buster AS base

# Dagger is using the Docker technology so we need to install Docker in the image
# Also install required Linux dependencies
# hadolint ignore=DL3008
RUN set -e -x \
    && apt-get update -yqq \
    && apt-get install -y --no-install-recommends docker.io wget \
    && apt-get clean \
    && rm -rf /tmp/* /var/lib/apt/lists/*

# Install Dagger
ARG VERSION=0.15.1
ARG ARCHIVE=dagger_v${VERSION}_linux_amd64.tar.gz
ARG URL=https://github.com/dagger/dagger/releases/download/v${VERSION}/${ARCHIVE}

# hadolint ignore=DL3003
RUN set -e -x \
    && cd /tmp \
    && wget --quiet --output-document ${ARCHIVE} ${URL} \
    && tar xvfz ${ARCHIVE} \
    && mv dagger /usr/local/bin/ \
    && rm -rf /tmp/

WORKDIR /app/src

ENTRYPOINT [ "/usr/local/bin/dagger" ]
```

Il faut construire notre image, lançons donc `docker build -t dagger_daemon -f .docker/Dockerfile .`

### Daggeriser notre application {#daggerize-our-application}

Comme dit plus haut, nous devons créer quelques éléments pour *daggeriser* notre application.

Pour cela, il faut exécuter la commande `dagger init` et, comme nous utilisons une image Docker où `dagger` est le point d'entrée, la commande à lancer est : `docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon init --sdk=python --source=./.pipeline`

> **Décortiquons cette longue ligne de commande ::**
>
> * avec `-it` nous pourrons interagir (si besoin) avec le container et nous allouons un terminal TTY, c'est-à-dire que nous récupérons la sortie de la commande exactement comme si nous l'avions lancée sur notre machine,
> * vous devez partager votre `/var/run/docker.sock` local avec le container car Dagger utilise Docker-out-of-Docker (alias `DooD`) et, pour cette raison, le container doit pouvoir interagir avec votre instance de Docker (`-v /var/run/docker.sock:/var/run/docker.sock`) et
> * vous devez monter votre dossier courant local dans le container (`-v .:/app/src`).
> * `dagger_daemon` est le nom de notre image
> * `init --sdk=python --source=./.pipeline` est la commande Dagger à lancer

Il faudra environ deux minutes pour télécharger et initialiser Dagger (la première fois). En regardant votre système de fichiers, vous verrez, oh, que le propriétaire est `root` et pas vous — c'est le listing de fichiers montré plus haut.

Lancez `sudo chown -R christophe:christophe .` (et remplacez mon prénom par votre nom d'utilisateur Linux).

Regardons l'arborescence :

```tree expanded=true showJSX=false debug=false
.
├── LICENSE
├── dagger.json
└── .pipeline
    ├── pyproject.toml
    ├── sdk
    ├── src
    │   └── src
    │       ├── __init__.py
    │       └── main.py

```

Rappelez-vous, `dagger` a été défini comme notre entrypoint (voir notre `Dockerfile`) donc, pour obtenir l'écran d'aide de Dagger, lancez simplement `docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call --help` — vous obtiendrez la même liste de fonctions que celle montrée en haut de cet article (cette première fois prendra plus de temps puisque Dagger doit construire le pipeline).

## Plus de démos {#more-demos}

### Créer notre fonction de linting {#create-our-linting-function}

Les fonctions sont définies dans le fichier `.pipeline/src/src/main.py`.

Ouvrez ce fichier et ajoutez une nouvelle fonction comme ci-dessous :

**.pipeline/src/src/main.py**

```python
@function
async def lint(self, source: str) -> str:
    """
    Run Pylint on the codebase.
    """
    return f"\033[33mI'm inside your Dagger pipeline and I'll lint {source}\033[0m"
```

Enregistrez le fichier et relancez `docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call --help`. Regardez, nous avons notre nouvelle fonction :

```bash
USAGE
  dagger call [options] \<function>

FUNCTIONS
  container-echo   Returns a container that echoes whatever string argument is provided
  grep-dir         Returns lines that match a pattern in the files of the provided Directory
  lint             Run Pylint on the codebase.
```

Et nous obtenons aussi la liste des paramètres de notre fonction lint avec `docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call lint --help` :

```bash
USAGE
  dagger call lint [arguments]

ARGUMENTS
  --source string   [required]
```

Maintenant, retour dans `.pipeline/src/src/main.py` et remplacez tout le fichier (nous n'avons pas besoin des fonctions d'exemple) par ce contenu :

**.pipeline/src/src/main.py**

```python
from dagger import dag, DefaultPath, Directory, function, object_type
from typing import Annotated

@object_type
class Src:
    @function
    async def lint(self, source: Annotated[Directory, DefaultPath("src")]) -> str:
        """
        Run Pylint on the codebase.
        """
        print(f"\033[33mI'm inside your Dagger pipeline and I'll lint {source}\033[0m")

        return await (
            dag.container()
            .from_("python:3.13-slim")
            .with_exec(["pip","install","pylint"])
            .with_mounted_directory("/app/src", source)
            .with_workdir("/app/src")
            .with_exec(["pylint","."])
            .stdout()
        )
```

Nous supprimons donc les deux fonctions d'exemple et nous implémentons notre fonction de linting. Nous définissons aussi le dossier `src` comme dossier par défaut afin de ne plus devoir ajouter `--source src` à chaque appel de dagger.

En lançant `docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call lint`, nous demandons au pipeline de lancer notre linter (Pylint ici) sur notre dossier courant.

Comme c'est la première fois, Dagger doit faire quelques initialisations (comme installer PyLint) puis nous obtenons la sortie :

![Sortie partielle - Pylint](./images/pylint.webp)

Oui ! PyLint a fonctionné et nous a alertés d'une docstring de module manquante.

Éditez `src/main.py` et ajoutez une docstring de module valide (ou ignorez simplement cet avertissement) :

**src/main.py**

```python
# pylint: disable=missing-module-docstring

import random

if random.randint(0, 1) == 0:
    print("Hello world")
else:
    print("Bonjour le monde!")
```

En relançant `docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call lint`, nous serons félicités avec un score de 10/10.

Éditez à nouveau le fichier `src/main.py`, faites par exemple une faute de frappe en modifiant la ligne `else:` et en supprimant le `:` final : le linter ne sera plus content.

> **Information:**
>
> Nous avons créé notre première tâche avec succès et nous l'avons lancée avec succès sur notre machine.

### Créer un makefile {#create-a-makefile}

Ça devient difficile de retenir toutes ces commandes `docker xxx`, non ? Simplifions cela en créant un `makefile` (si c'est nouveau pour vous, lisez d'abord [Linux Makefile - When to use a makefile](/blog/makefile-using-make)).

Grâce au `makefile` suivant, nous pourrons simplement lancer `make build` pour créer notre image Docker Dagger et `make lint` pour lancer la fonction lint.

Vous aurez aussi une action `make bash` pour entrer dans un shell interactif (tapez `exit` pour quitter le shell et revenir à la console de votre host). Facile, non ?

Et `make help` affichera l'écran d'aide de Dagger.

**makefile**

```
DAEMON_NAME=dagger_daemon
DOCKER_SOCK=-v /var/run/docker.sock:/var/run/docker.sock

.PHONY:build
build:
	docker build -t ${DAEMON_NAME} -f .docker/Dockerfile .

.PHONY:bash
bash:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src --entrypoint /bin/bash ${DAEMON_NAME}

.PHONY:help
help:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call --help

.PHONY:lint
lint:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call lint
```

### Formater le code avec Black {#formatting-the-code-using-black}

Éditez le fichier `.pipeline/src/src/main.py` et ajoutez cette nouvelle fonction :

**.pipeline/src/src/main.py**

```python
from dagger import dag, DefaultPath, Directory, function, object_type
from typing import Annotated

@object_type
class Src:
    @function
    async def lint(self, source: Annotated[Directory, DefaultPath("src")]) -> str:
        """
        Run Pylint on the codebase.
        """
        print(f"\033[33mI'm inside your Dagger pipeline and I'll lint {source}\033[0m")

        return await (
            dag.container()
            .from_("python:3.13-slim")
            .with_exec(["pip","install","pylint"])
            .with_mounted_directory("/app/src", source)
            .with_workdir("/app/src")
            .with_exec(["pylint","."])
            .stdout()
        )

    # highlight-start
    @function
    async def format(self, source: Annotated[Directory, DefaultPath("src")]) -> str:
        """
        Format the codebase using Black.
        """
        return await (
            dag.container()
            .from_("python:3.13-slim")
            .with_exec(["pip","install","black"])
            .with_mounted_directory("/app/src", source)
            .with_workdir("/app/src")
            .with_exec(["black","."])
            .stdout()
        )
    # highlight-end
```

Donc, à partir de maintenant, vous pouvez lancer `dagger call format` (depuis l'intérieur du container, c'est-à-dire après un `make bash`) ou `docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call format` (depuis votre host).

### Vers l'univers et l'infini {#towards-the-universe-and-infinity}

Ok, nous avons maintenant deux tâches et il nous en reste beaucoup à implémenter. On peut faire plein de copier/coller ou prendre le temps de réfléchir :

1. Ce serait bien de ne pas devoir spécifier à chaque fois le dossier source,
2. Ce serait bien de lancer toutes les tâches d'un coup, en asynchrone,
3. Ce serait bien d'avoir un dossier de configuration où stocker les fichiers de configuration

Nous devons faire une petite modification à notre image Docker :

**.docker/Dockerfile**

```docker
# cspell:disable

FROM python:3.11-slim-buster AS base

# Dagger is using the Docker technology so we need to install Docker in the image
# Also install required Linux dependencies
# hadolint ignore=DL3008
RUN set -e -x \
    && apt-get update -yqq \
    && apt-get install -y --no-install-recommends docker.io wget \
    && apt-get clean \
    && rm -rf /tmp/* /var/lib/apt/lists/*

# Install Dagger
ARG VERSION=0.15.1
ARG ARCHIVE=dagger_v${VERSION}_linux_amd64.tar.gz
ARG URL=https://github.com/dagger/dagger/releases/download/v${VERSION}/${ARCHIVE}

# hadolint ignore=DL3003
RUN set -e -x \
    && cd /tmp \
    && wget --quiet --output-document ${ARCHIVE} ${URL} \
    && tar xvfz ${ARCHIVE} \
    && mv dagger /usr/local/bin/ \
    && rm -rf /tmp/

# highlight-start
# anyio is required by Python to be able to run tasks concurrently and asynchronously
RUN set -e -x \
    && pip install anyio
# highlight-end

WORKDIR /app/src

ENTRYPOINT [ "/usr/local/bin/dagger" ]
```

Cela fait, lancez `make build` pour créer une nouvelle image Docker avec `anyio` installé.

Maintenant, nous allons mettre à jour le fichier `.pipeline/src/src/main.py` et ajouter quelques nouvelles fonctions :

**.pipeline/src/src/main.py**

```python
# cspell:ignore anyio,rcfile,pylintrc,workdir
import anyio

from typing import Annotated

import dagger
from dagger import DefaultPath, Doc, dag, function, object_type, Directory

@object_type
class Src:

    # This is the directory where source files are located (f.i. "/app/src")
    source: Annotated[
        Directory,
        Doc("The source folder; where the codebase is located"),
        DefaultPath("src")
    ]

    # This is the directory where configuration files are located (f.i. "/app/.config")
    config: Annotated[
        Directory,
        Doc("The folder container configuration files"),
        DefaultPath(".config")
    ]

    @function
    async def lint(self) -> str:
        """
        Lint python scripts using Pylint - Run analyses your code without actually running it (https://pypi.org/project/pylint/)
        """
        return await (
            dag.container()
            .from_("python:3.13-slim")
            .with_exec(["pip","install","pylint"])
            .with_mounted_directory("/app/src", self.source)
            .with_mounted_directory("/app/config", self.config)
            .with_workdir("/app/src")
            .with_exec(["pylint",".","--rcfile","/app/config/.pylintrc"])
            .stdout()
        )

    @function
    async def format(self) -> str:
        """
        Format the script using Black (https://black.readthedocs.io/en/stable/)
        """
        return await (
            dag.container()
            .from_("python:3.13-slim")
            .with_exec(["pip","install","black"])
            .with_mounted_directory("/app/src", self.source)
            .with_mounted_directory("/app/config", self.config)
            .with_workdir("/app/src")
            .with_exec(["black",".","--config","/app/config/black.toml"])
            .stdout()
        )

    @function
    async def mypy(self)-> str:
        """
        Mypy is a program that will type check your Python code (https://github.com/python/mypy/)
        """
        mypy_cache = dag.cache_volume("python_mypy")

        return await (
            dag.container()
            .from_("python:3.13-slim")
            .with_exec(["pip","install","mypy"])
            .with_mounted_directory("/app/src", self.source)
            .with_mounted_directory("/app/config", self.config)
            .with_mounted_cache("/tmp/mypy", mypy_cache)
            .with_workdir("/app/src")
            # .with_exec(["ls","-alhR"])
            .with_exec(["mypy","--config-file",f"/app/config/mypy.ini","."])
            .stdout()
        )

    @function
    async def ruff(self) -> str:
        """
        An extremely fast Python linter and code formatter (https://github.com/astral-sh/ruff)
        """
        return await (
            dag.container()
            .from_("python:3.13-slim")
            .with_exec(["pip","install","ruff"])
            .with_mounted_directory("/app/src", self.source)
            .with_mounted_directory("/app/config", self.config)
            .with_workdir("/app/src")
            .with_exec(["ruff","check","--config","/app/config/pyproject.toml", "."])
            .with_exec(["ruff","format","--config","/app/config/pyproject.toml", "."])
            .stdout()
        )

    @function
    async def run_all(self):
        """Run linter, type-checker, unit tests concurrently"""
        async with anyio.create_task_group() as tg:
            tg.start_soon(self.ruff)
            tg.start_soon(self.lint)
            tg.start_soon(self.format)
            tg.start_soon(self.mypy)
```

Ce nouveau fichier apporte beaucoup de changements :

Nous avons défini deux variables globales appelées `source` et `config`. Désormais, nous ne passons plus le dossier `source` à la fonction lint (paramètre local) mais il suffit de le définir une seule fois (paramètre global). Nous avons aussi ajouté un dossier `config` pour pouvoir indiquer à Dagger où sont stockés nos fichiers de configuration.

**.pipeline/src/src/main.py**

```python
source: Annotated[
    Directory,
    Doc("The source folder; where the codebase is located"),
    DefaultPath("src")
]

config: Annotated[
    Directory,
    Doc("The folder container configuration files"),
    DefaultPath(".config")
]
```

Nous avons deux nouvelles fonctions appelées `mypy` et `ruff` et une dernière appelée `run_all` :

**.pipeline/src/src/main.py**

```python
@function
    async def run_all(self):
        """Run linter, type-checker, unit tests concurrently"""
        async with anyio.create_task_group() as tg:
            tg.start_soon(self.ruff)
            tg.start_soon(self.lint)
            tg.start_soon(self.format)
            tg.start_soon(self.mypy)
```

Celle-ci démarre les quatre fonctions en parallèle et attend qu'une échoue ou que les quatre réussissent.

## Sous le capot (passez votre chemin si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}

### Fichiers de configuration {#configuration-files}

Avant de lancer ces nouvelles fonctions, nous devons créer quelques fichiers de configuration.

**.config/.pylintrc**

```
[MASTER]
disable=
    broad-exception-caught,
    consider-using-assignment-expr,
    invalid-name,
    missing-module-docstring,
    too-few-public-methods,
    too-many-instance-attributes,
    too-many-positional-arguments

; Pickle collected data for later comparisons.
persistent=yes

; List of plugins (as comma-separated values of python modules names) to load,
; usually to register additional checkers.
load-plugins=
    pylint.extensions.check_elif,
    pylint.extensions.bad_builtin,
    pylint.extensions.docparams,
    pylint.extensions.for_any_all,
    pylint.extensions.set_membership,
    pylint.extensions.code_style,
    pylint.extensions.overlapping_exceptions,
    pylint.extensions.typing,
    pylint.extensions.redefined_variable_type,
    pylint.extensions.comparison_placement,
    ; pylint.extensions.mccabe,

; Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
; number of processors available to use.
jobs=0

; When enabled, pylint would attempt to guess common misconfiguration and emit
; user-friendly hints instead of false-positive error messages.
suggestion-mode=yes

; Minimum supported python version
py-version = 3.13.0

; Specify a score threshold to be exceeded before program exits with error.
fail-under=9.50

[FORMAT]
; The same as in prospector.yaml, .pylintrc, black.toml and pyproject.toml
max-line-length=120
```

**.config/black.toml**

```toml
[tool.black]

# The same as in prospector.yaml, .pylintrc, black.toml and pyproject.toml
line-length = 120

# We'll format the code for Python 3.13.0; we can add multiple versions (the ones supported by
# the script, all versions separated by a comma like in ['py312', 'py313'] (run "black --help" to get them)
target-version = ['py313']
```

**.config/mypy.ini**

```ini
[mypy]
show_error_codes = true

; First we turn on *all the checks*, and then we turn off those that are too annoying.
strict = True

; Don't know why but mypy returns errors like
;     Customer? has no attribute "input_path"  [attr-defined]
; when that attribute is well present. Ignore the error temporarily
disable_error_code = attr-defined

cache_dir = /tmp/mypy

; Disallows defining functions without type annotations or with incomplete type annotations
disallow_untyped_defs = true

no_implicit_optional = true

strict_equality = true
warn_redundant_casts = true
warn_unused_ignores = true

python_version = 3.13
```

**.config/pyproject.toml**

```toml
[tool.ruff]

exclude=[]

# The same as in prospector.yaml, .pylintrc, black.toml and pyproject.toml
line-length = 120
indent-width = 4
```

**makefile**

```
DAEMON_NAME=dagger_daemon
DOCKER_SOCK=-v /var/run/docker.sock:/var/run/docker.sock

.PHONY:build
build:
	docker build -t ${DAEMON_NAME} -f .docker/Dockerfile .

.PHONY:bash
bash:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src --entrypoint /bin/bash ${DAEMON_NAME}

.PHONY:format
format:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call format

.PHONY:help
help:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call --help

.PHONY:lint
lint:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call lint

.PHONY:mypy
mypy:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call mypy

.PHONY:remove
remove:
	docker rmi --force ${DAEMON_NAME}

.PHONY:ruff
ruff:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call ruff

.PHONY:run-all
run-all:
	docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call run-all
```

### Implémenter une CI GitLab avec Dagger {#implementing-a-gitlab-ci-using-dagger}

Revenons à notre objectif : simplifier le processus de pipeline à la fois au niveau de la CI (GitLab, GitHub, etc.) et en local.

Nous venons de faire la partie locale, attaquons la CI distante.

Pour les chapitres suivants, je suppose que vous utilisez un serveur GitLab auto-hébergé.

#### Autoriser le runner GitLab à accéder à Docker {#allowing-the-gitlab-runner-to-access-docker}

Je ne suis pas expert en configuration de runner GitLab mais la configuration suivante fonctionne pour moi. J'ai détaillé cette mise en place, et le piège du partage de dossiers qui va avec, dans [GitLab - Running Docker-out-of-Docker in your CI](/blog/gitlab-docker-out-of-docker).

Connectez-vous en SSH à votre serveur de runner GitLab et éditez le fichier `/etc/gitlab-runner/config.toml` (vous devez être root). Ajoutez simplement `/var/run/docker.sock:/var/run/docker.sock` à la propriété `volumes` :

**/etc/gitlab-runner/config.toml**

```toml
[runners.docker]
    # Adding /var/run/docker.sock:/var/run/docker.sock
    # will allow to use commands like "docker image list" i.e. running Docker commands
    # in CI scripts. This is called "Docker socket binding".
    volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
```

Assurez-vous aussi que l'utilisateur Linux utilisé par votre runner GitLab (le nom par défaut est `gitlab-runner`) fait partie du groupe `docker`. Cela se fait en lançant `sudo usermod -aG docker gitlab-runner` en CLI (voir <https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#use-the-shell-executor>).

> **Information:**
>
> Documentation officielle de GitLab à propos des [volumes](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#volumes-in-the-runnersdocker-section).

Pour vérifier que ça marche, lancez `sudo su gitlab-runner` pour basculer sur cet utilisateur puis lancez `docker info` et `docker image list` et regardez si ça fonctionne. Si oui, votre utilisateur fait bien partie du groupe Docker.

#### Configurer votre CI {#configure-your-ci}

Créez un repository GitLab et poussez-y votre projet existant.

Maintenant, créez un fichier appelé `.gitlab-ci.yml` avec ce contenu :

**.gitlab-ci.yml**

```yaml
.docker:
  image: docker:latest
  services:
    - docker:${DOCKER_VERSION}-dind
  variables:
    DOCKER_HOST: unix:///var/run/docker.sock # Docker Socket Binding
.dagger:
  extends: [.docker]
  before_script:
    - apk add curl
    - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sh
lint:
  extends: [.dagger]
  script:
    - dagger call --source src --config .config lint
format:
  extends: [.dagger]
  script:
    - dagger call --source src --config .config format
mypy:
  extends: [.dagger]
  script:
    - dagger call --source src --config .config mypy
ruff:
  extends: [.dagger]
  script:
    - dagger call --source src --config .config ruff
```

Et poussez les changements vers GitLab. La présence du fichier `.gitlab-ci.yml` indiquera à GitLab d'instancier un pipeline après chaque commit et, dans notre exemple, de lancer les quatre jobs.

> **Docker Socket Binding:**
>
> L'exemple fourni utilise la technique appelée **Docker Socket Binding** : nous n'avons pas besoin de définir la variable `DOCKER_HOST` comme on peut le voir dans [la documentation officielle de Dagger](https://docs.dagger.io/ci/integrations/gitlab). En effet, si elle n'est pas spécifiée, `DOCKER_HOST` vaut `unix:///var/run/docker.sock` ([doc](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnersdocker-section)).
>
> Comme nous avons partagé le daemon Docker (`/var/run/docker.sock`) dans notre fichier de configuration GitLab `/etc/gitlab-runner/config.toml`, nous avons autorisé la CI à accéder au socket.

Mais vous pouvez aussi utiliser le mode asynchrone puisque nous avons implémenté une fonctionnalité `run-all` :

**.gitlab-ci.yml**

```yaml
.docker:
  image: docker:latest
  services:
    - docker:${DOCKER_VERSION}-dind
  variables:
    DOCKER_HOST: unix:///var/run/docker.sock # Docker Socket Binding
.dagger:
  extends: [.docker]
  before_script:
    - apk add curl
    - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sh
run-all:
  extends: [.dagger]
  script:
    - dagger call --source src --config .config run-all
```

## Conclusion {#conclusion}

Nous avons construit une image Docker Dagger sur mesure, daggerisé un projet existant, et défini quatre fonctions (`lint`, `format`, `mypy` et `ruff`) plus une fonction `run-all` pour toutes les lancer en parallèle. Les actions `make` nous évitent de retenir les longues commandes du CLI Docker, et exactement les mêmes fonctions tournent maintenant aussi dans une CI GitLab, en partageant le socket Docker avec le runner.

La boucle push → attendre → lire le log → deviner → repousser de l'introduction a disparu : tout ce qui se déclenche en CI peut d'abord être joué et débogué en local, en quelques secondes, et non en minutes sur un serveur distant.

Si ce n'est pas déjà fait, lisez [Python - Code Quality tools](/blog/python-qa) pour plus de détails sur Pylint, Black, mypy et Ruff eux-mêmes.
