Briefing

  • Vous avez l’expérience de la guerre, Monsieur Rambo ?
  • J’ai vidé quelques chargeurs.

Un projet bien parti

Un peu d’entretien sur un projet legacy, juste quelques features à ajouter et quelques bugs à régler…

  • Alors Rambo, cette app est cruciale pour la sécurité nationale ! Elle a déjà coûté des millions ! Quand est-ce que vous allez livrer ? Et ça va marcher du premier coup ?

La qualité ?

(@US Army Special Forces, 1982)

Et pourtant…

  • C’est vachement dur mon colonel. Faut tout garder en tête et tout faire bien en même temps. Pour survivre au code, il faut devenir le code, mon colonel.
Impossible à inspecter, compliqué, dense, couplé, frénétique, empilé, en équipe, pas linéaire…

On reprend du début : les versions de Python

Uv, installation et dépendances

Git, plateforme et intégration continue

Ruff, formattage

  • ruff format .
from pandas import *
import numpy as np; import scipy as sp
a_dict = { "aaaaaaaaaa" : "aaaaaaaaaa", "bbbbbbbbbbb": "bbbbbbbbbbb",
          """yes triple quotes are legal here""": """guaranteed"""}
def a_function(df: DataFrame, very_long_argument=None, another_one="spam") -> dict[str, list[float | None]]:
  '''Never forget the docstring!'''
  if df.isnotempty() and len(very_long_argument) > 42:
                        print("I can use as many spaces that I want!!!!")
  return {"pi": [3.1415926535]}
a_function(a_dict, another_one="bites the dust")
from pandas import *
import numpy as np
import scipy as sp

a_dict = {
    "aaaaaaaaaa": "aaaaaaaaaa",
    "bbbbbbbbbbb": "bbbbbbbbbbb",
    """yes triple quotes are legal here""": """guaranteed""",
}


def a_function(
    df: DataFrame, very_long_argument=None, another_one="spam"
) -> dict[str, list[float | None]]:
    """Never forget the docstring!"""
    if df.isnotempty() and len(very_long_argument) > 42:
        print("I can use as many spaces that I want!!!!")
    return {"pi": [3.1415926535]}


a_function(a_dict, another_one="bites the dust")

Ruff, analyse statique

  • ruff check --statistics . (sinon ça tient pas sur le slide)
  • règles activées à la carte, 800 au total
2   ANN001  [ ] missing-type-function-argument
1   T201    [*] print
1   ARG001  [ ] unused-function-argument
1   D100    [ ] undocumented-public-module
1   D400    [ ] ends-in-period
1   F403    [ ] undefined-local-with-import-star
1   F405    [ ] undefined-local-with-import-star-usage
1   PLR2004 [ ] magic-value-comparison

Typage dynamique = déterminé à l’exécution, et pas possible avant

  • liste hétérogène : events = [EventA(), None, 42, "spam", [{}]]

  • parsing : MyClass(**json.loads(request.text))

  • duck typing

    try:
        animal.quack()
    except AttributeError:
        print("not a duck")
    else:
        print("looks like a duck")
  • modification à la volée

    del animal.__class__.quack
    animal.bark = lambda : "woof"
    assert animal.bark() == "woof"  # ressemble à un chien maintenant !
  • liste de type

    animals: list[Type[Animal]] = []
    stack: list[Type[Animal]] = [Animal]  # ceci est une liste de classe
    while stack:
        new = stack.pop()
        stack.extend(new.__subclasses__())  # la classe 'connait' ses sous-classes
        if not inspect.isabstract(new):  # et on sait si la classe est abstraite
            animals.append(new)

Mypy, types et annotations

  • Annotations : d’abord une documentation pour les devs, et ensuite pour l’IDE et mypy

    
    def func(a: int, b: float, c: list[tuple[TextIO, ndarray]]) -> bool | None:
        l: list[dict[str, float]] = []   # permet de se rappeler ce qu'il y a dedans
        x: MyClass = other_func()  # plus la peine d'aller regarder la signature
        i = 3  # pas la peine pour les types simples et nommés avec une convention
  • mypy déduit les types quand il peut, et vérifie que les types sont compatibles

my-script.py:14: error: "Series[Any]" not callable  [operator]
my-script.py:19: error: Argument 1 to "a_function" has incompatible type "dict[str, str]"; expected "DataFrame"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)
  • Parfois une perte de lisibilité

Erreur : Index a pas de date

df.index.date

Pas d’erreur mais un cast en Python 🤦

from typing import cast
index = cast(DatetimeIndex, df.index)
index.date

IDE

PyCharm à poil fait déjà du bon boulot

Pre-commit

.pre-commit-config.yaml :

repos:
  - repo: local
    hooks:
      - id: ruff format
        name: Ruff as Formatter
        entry: uv run ruff format
        language: system
      - id: ruff check
        name: Ruff as Linter
        entry: uv run ruff check
        language: system
      - id: mypy
        name: MyPy
        entry: uv run mypy
        language: system

Si activé (pre-commit install) se lance automatiquement à chaque commit

rambo:~/awesome-code(uber-feature *$% u)$ git commit -a -m "Adrienne!"
Ruff as Formatter........................................................Passed
Ruff as Linter...........................................................Passed
MyPy.....................................................................Passed

Doit (aka “do it” en anglais)

Fichier dodo.py :

def task_fmt() -> dict:
    """Format the code."""
    return {"actions": ["uv run ruff format ."]}

def task_lint() -> dict:
    """Lint the code."""
    return {"actions": ["uv run ruff check ."]}

def task_type() -> dict:
    """Check the types."""
    return {"actions": ["uv run mypy ."]}

def task_pycheck() -> dict:
    """Run all the Python quality tools."""
    return {
        "actions": None,
        "verbosity": 1,
        "task_dep": ["fmt", "lint", "type"],
    }

Permet de lancer des commandes :

rambo:~/awesome-code(uber-feature *$% u)$ doit list
fmt       Format the code.
lint      Lint the code.
pycheck   Run all the Python quality tools.
type      Check the types.
rambo:~/awesome-code(uber-feature *$% u)$ doit pycheck
.  fmt
.  lint
.  type

Pipeline de validation automatique

  • Des pipelines facilement configurable avec du pseudo-Docker en YAML
  • Permet de lancer toutes les étapes de validation et de livrer une image finale
# Pseudo code
build:  # image finale pour livraison
    image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim  # uv déjà installé
    script:
      - uv sync --frozen  # Installation des dépendances
    artifacts:
        image: [my-app:latest, my-app:$VERSION]  # Sauvegarde de l'image

test:  # étape de validation
    from: build  # on repart de l'image de prod
    script:
      - uv sync --frozen --dev  # on installe les dépendances de dev
      - uv run ruff format .    # on teste le formattage
      - uv run ruff check .     # on passe l'analyse statique
      - uv run mypy .           # on vérifie les types
      - uv run pytest           # on passe les tests
      - mkdir report            # on sauvegarde les résultats pour analyse éventuelle
      - mv .pytest_cache/report.html report/pytest_report.html
    artifacts:
        paths:
          - report

Pytest, tests de haut en bas et vice-versa

my-lib.py :

def add(a, b):
    return a + b

test_my-lib.py :

import random
import pytest
from my-lib import add

@pytest.fixture()
def setup_and_tear_down():
    print('This is run before each test')
    yield
    print('This is run after each test')

def test_add(setup_and_tear_down):
    assert add(1, 2) == 3
    assert add(1, -2) == -1
    assert add("1", "2") == "12"
    assert add(0.1, 0.2) == pytest.approx(0.3)  # déjà essayé 0.1+0.2==0.3 au prompt ?
    a, b = random.random(), random.random()
    assert add(a, b) == add(b, a)

    with pytest.raises(TypeError):
        add(1, "2")

Et y’a plus qu’à lancer pytest.

Gestion des ressources

  • Pas besoin de s’occuper de la mémoire ou des threads
  • Plein de façons de s’assurer de la libération des autres ressources
# try/finally
db = SqliteDatabase('test.db')
try:
    db.connect()
    do_stuff(db)
finally:
    db.close()

# context manager
with open("my_file.txt") as file:
    print(file.read())

# module contextlib
from contextlib import closing
with closing(urlopen('https://www.python.org')) as page:
    print([line for line in page])

# module atexit
import atexit
file = open("output.log")
atexit.register(file.close)

Exception

  • Seul mécanisme d’erreur en Python, du crash à la sortie de boucle (si)

  • Toutes les exceptions standards dérivent de BaseException :

    >>> BaseException.__subclasses__()
    [<class 'BaseExceptionGroup'>, <class 'Exception'>, <class 'GeneratorExit'>,
    ... <class 'KeyboardInterrupt'>, <class 'SystemExit'>]
  • Mais on n’utilise jamais BaseException(Group), on attrape au plus Exception

  • On raise ses propres exceptions, pour pouvoir trier :

    class AppError(Exception):
        """The app top error"""
    
    class AppConfigError(AppError):
        """Error reading and validating the configuration"""
    
    class AppTimeoutError(AppError, TimeoutError):  # bon usage de l'héritage multiple
        """Timeout of the app."""
    
    raise AppConfigError("Qui a ENCORE oublié le fichier de conf ?")

Tri sélectif

  • Avec des except multiples, du plus sélectif au moins sélectif :

    try:
        success = func()
    except TimeoutError:  # tous les timeout, de la lib de connexion comme de mon code
        retry()
    except AppError as err:  # autre erreur de mon code
        if err.code >= 400:  # on peut se servir du contenu de l'erreur si y'en a
            log.warn("quand serveur fâché, lui toujours faire ainsi")
            retry()
        else:
            log.error("j'ai laisser trainer un truc, rhoo spa graaaave...")
            backup()
    except Exception:  # voiture balai, seulement si nécessaire (aucun tri)
        log.exception("oupsi")
        reboot()

Logging

  • Tout l’écosystème Python se sert de la lib standard logging, souvent indirectement
    • Très puissant, mais lourd à configurer

    • Fait cascader les logs de tous les fichiers, toutes les libs vers le logger configuré par l’utilisateur

      import toml
      import logging
      import logging.config # oui c'est obligatoire, et non je sais pas pourquoi
      from pathlib import Path
      import mylib
      
      log = logging.getLogger(__name__)
      
      def main():
          if not Path("log-config.toml").exists():
              # basicConfig pour de petits trucs
              logging.basicConfig(filename='myapp.log', level=logging.INFO)
          else:
              # sinon il faut définir formatter, handler, logger...
              # => on peut mettre la conf du logger dans un fichier une fois pour toute
              logging.config.dictConfig(toml.load("log-config.toml"))
      
          log.info('Started')
          mylib.do_something()
          log.info('Finished')
      
      if __name__ == '__main__':
          main()
  • Protip : évitez de platrer vos serveurs grâce à logging.handlers.RotatingFileHandler

Modularité

  • Modularité native : fonction, classes, modules, package, libs…
  • Unité de code autonome, pas (trop) couplée, interface claire
  • Un dev (fatigué) doit pouvoir s’en servir sans lire l’implémentation

Documentation

rambo:~/awesome-code(doc *$% u)$ uv add --dev sphinx
rambo:~/awesome-code(doc *$% u)$ mkdir doc && cd doc
rambo:~/awesome-code(doc *$% u)$ uv run sphinx-quickstart .
Bienvenue dans le kit de démarrage rapide de Sphinx 8.0.2.
Veuillez saisir des valeurs pour les paramètres suivants.

Chemin racine sélectionné : .

Vous avez deux options pour l'emplacement du répertoire de construction de la sortie de Sphinx.
> Séparer les répertoires source et de sortie (y/n) [n]: y

Le nom du projet apparaîtra à plusieurs endroits dans la documentation construite.
> Nom du projet: gros-missile-secret
> Nom(s) de(s) l'auteur(s): Rambo
> Version du projet []: 6.6.6

Fichier en cours de création doc/source/conf.py.
Fichier en cours de création doc/source/index.rst.
Fichier en cours de création doc/Makefile.
Fichier en cours de création doc/make.bat.

Terminé : la structure initiale a été créée.

Vous devez maintenant compléter votre fichier principal /home/rambo/awesome-code/doc/source/index.rst et créer d'autres fichiers sources de documentation. Utilisez le Makefile pour construire la documentation comme ceci :
   make builder
où « builder » est l'un des constructeurs disponibles, tel que html, latex, ou linkcheck.
rambo:~/awesome-code(doc *$% u)$ uv run sphinx-apidoc -o source/apidoc ..
rambo:~/awesome-code(doc *$% u)$ tree
.
├── build
├── make.bat
├── Makefile
└── source
    ├── apidoc
    │   ├── main.rst
    │   ├── modules.rst
    │   └── src.rst
    ├── conf.py
    ├── index.rst
    ├── _static
    └── _templates
rambo:~/awesome-code(doc *$% u)$ make html
Sphinx v8.0.2 en cours d'exécution
chargement des traductions [en]... fait
[...]
La compilation a réussi.

Les pages HTML sont dans build/html.
rambo:~/awesome-code(doc *$% u)$ firefox build/html/index.html

Rappel : “Vous devez maintenant compléter votre fichier principal index.rst et créer d’autres fichiers sources de documentation.”

Réutilisabilité

le code le moins cher est celui qui n’a pas besoin d’être écrit

le second code le moins cher est celui que quelqu’un d’autre paye pour écrire et que vous pouvez juste réutiliser librement

  • la stdlib, toujours à portée de main
  • les libs Python incontournables : requests, numpy/pandas, flask, pytest
  • la richesse de PyPI et GitHub

Modèle d’architecture