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

Évolutivité

  • Un code en service n’est pas statique :
    • Évolutions nécessaires : l’environnement bouge (course de la reine rouge)
    • Évolutions exigées : bugfix, nouvelle feature, client pas content…
    • Évolutions souhaitées : réutilisation, refactorisation…
    • Alternative : évoluer ou spirale de la dette technique
  • Tout à fait gérable si le déploiement est bien automatisé et maitrisé
    • Capacité à évoluer = réparabilité = confiance dans le pipeline de test
    • De petites mises à jour régulières sont plus facile à gérer qu’une grosse
  • Facile de publier
    • Publier une lib en wheel : uv build && uv publish
    • Publier une app : conteneur, compilation avec Nuitka…
    • Se donner un schéma de version et le suivre : SemVer, date…

Image de prod d’une app

# Image Debian/Python avec uv
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim as build

WORKDIR /app

COPY . .  # copie le code dans l'image

RUN uv sync --frozen --no-dev  # installe les dépendances de prod

# Image Debian/Python sans uv
FROM python:3.12-slim as prod

WORKDIR /app

# Copie depuis le stage de build des fichiers nécessaires
COPY --from=build  /app/main.py  /app
COPY --from=build  /app/src  /app/src
COPY --from=build  /app/log_config.toml  /app/log_config.toml
COPY --from=build  /app/.venv  /app/.venv
RUN mkdir log

# Lance le main en acceptant les commandes utilisateurs :
#   docker run my-app:latest --arg1 value --flag
ENTRYPOINT ["/app/.venv/bin/python", "/app/main.py"]

Et plus qu’à docker build -t my-app:latest .

Sécurité

  • Application avec une fonction de sécurité : possible (langage memory safe, thread safe) mais pas le langage de prédilection

    • Procédures, certifications, analyse de risque, beaucoup de tests…
    • SQLite a 590 fois plus de lignes de test que de lib…
  • Faire attention aux bases et déléguer :

    • Réseau : libs et framework (Django…) avec sécurité intégrée (injection SQL, CSRF…)
    • Connexion : SSH, Wireguard pour le VPN, et sinon toujours crypté (HTTPS…)
    • Penser aux systèmes de gestion des secrets (token, certificats, clé…)
  • Mise à jour, mise à jour, mise à jour, MISE À JOUR

    • L’exploitation de faille peut aller très vite

      • Heartbleed : faille critique d’OpenSSL mettant en jeu la sécurité immédiate de tous les serveurs
      • EternalBlue révélé le 14/04/2017, rançongiciel WannaCry le 12/05/2017, effaceur NotPetya le 27/06/2017
    • Oui mais si la mise à jour fait tout péter ?

      • Retraite soldat ! Faut savoir faire un rollback, une récupération depuis backup…
      • De petites mises à jour régulières sont plus facile à gérer qu’une grosse dans l’urgence

Crypto

  • Algos à connaitre, en bien ou en mal (en cas de doute : ANSSI)

    • Signature (hash) : SHA2 ou SHA3
    • Crypto symétrique : AES 256
    • Crypto asymétrique : EdDSA/Ed25519 (pas RSA)
  • Mathématiquement aucune attaque connue, par contre très dur à implémenter sans bug ni faille

    • énergie libérée par le soleil dans toute sa durée de vie : ~ 10^47 J
    • essayer toutes les clés de 256 bits, à 1eV (=énergie d’un photon) par test : > 10^58 J
    • 11 ordres de grandeur d’écart = le nombre d’étoiles dans notre galaxie

Authentification

  • Point faible de la défense : le soldat !
    • On fait tous des erreurs, des oublis…
    • Impossible de tout le temps être à l’état de l’art partout, on a qu’une tête
    • Mot de passe facilement mémorisable trop faible !
    • /!\ Contournements /!\ quand trop lourd : post-it, mot de passe simpliste…
  • Authentification :
    • faudrait des mots de passe d’au moins 50 caractères aléatoires (un token quoi)
    • gestionnaire de mot de passe intégré aux applis (navigateur) et authentification forte

Et la dernière flèche high explosive de Rambo : l’IA

  • L’IA est un outil naturel pour le dev en 2024
  • Copilot intégré à l’IDE pour les chanceux
  • perplexity.ai pour la recherche Web + génération de bouts de code pour les autres
  • Ça continue de progresser (Nemotron-70B a battu GPT-4 la semaine dernière)
  • Parfait pour cracher du code à partir de doc (=les tests) ou la doc à partir du code

Agilité, DevOps, CI/CD…

  • CD > CI > Agile && CD > DevOps && CD in facile_de_dire_si_on_en_fait
  • Important : écrire les objectifs et leurs horizons
    • Plusieurs horizons en temps (semaine, trimestre, année…) et en objectifs
    • Permet de prioriser, d’explorer quand c’est pas clair, et de confirmer avec le client
    • Hors start-up, le client met un point d’honneur à ne rien comprendre à l’informatique (Pas mon métier ! It’s magic !), donc à nous de franchir le gouffre
  • Logiciel = flux de code (commit…) vers la prod, flux de RETEX inverse
    • Pas un “produit”, pas même un projet, plutôt un processus
    • On s’arrête d’y toucher quand on a plus de sous, d’énergie ou d’utilisateur, pas quand c’est “fini”