iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
Python · Débutant

Python

Apprendre Python de zéro jusqu'au niveau expert : syntaxe, structures de données, POO, générateurs, décorateurs, typage, tests, async et internals du langage.

Python 3.13uvpytestmypyasyncio
01Introduction et installation02Variables et types de base03Conditions et boucles04Fonctions05Listes, tuples, dictionnaires et ensembles06Modules, fichiers et exceptions07Programmation orientée objet08Itérateurs, générateurs, décorateurs et context managers09Typage statique et tests10Concurrence : threads, processus et asyncio11Niveau expert : internals, métaprogrammation et performance
Chapitre 11·60 min

Niveau expert : internals, métaprogrammation et performance

Le dernier palier ne consiste pas à apprendre plus de syntaxe. Il consiste à comprendre comment Python fonctionne sous le capot : comment un attribut est résolu, comment une classe est créée, où va la mémoire, pourquoi une ligne est lente. C'est ce savoir qui permet d'écrire des bibliothèques, pas seulement de les utiliser.

Le modèle de données : tout passe par des lookups

Quand vous écrivez obj.attr, Python appelle type(obj).__getattribute__(obj, "attr"), qui cherche dans cet ordre :

1. Descripteur de données dans type(obj).__mro__   (property, __set__ défini)
2. obj.__dict__["attr"]
3. Descripteur sans données ou attribut de classe   (méthodes, fonctions)
4. type(obj).__getattr__(obj, "attr") si défini
5. AttributeError

C'est pourquoi une property a priorité sur une valeur stockée dans l'instance, et pourquoi __getattr__ n'est appelé qu'en dernier recours.

class Config:
    def __init__(self, data: dict):
        self._data = data
 
    def __getattr__(self, nom):          # appelé seulement si l'attribut est introuvable
        try:
            return self._data[nom]
        except KeyError:
            raise AttributeError(nom) from None
 
c = Config({"debug": True})
c.debug   # True

Descripteurs

Un descripteur est un objet qui définit __get__, __set__ ou __delete__, placé comme attribut de classe. property, classmethod, staticmethod et les méthodes elles-mêmes sont des descripteurs.

class Positif:
    def __set_name__(self, owner, nom):
        self.nom = "_" + nom
 
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.nom)
 
    def __set__(self, obj, valeur):
        if valeur <= 0:
            raise ValueError(f"{self.nom[1:]} doit être positif, reçu {valeur}")
        setattr(obj, self.nom, valeur)
 
class Produit:
    prix = Positif()
    stock = Positif()
 
    def __init__(self, prix, stock):
        self.prix = prix
        self.stock = stock
 
Produit(10, 0)   # ValueError: stock doit être positif, reçu 0

__set_name__ (Python 3.6+) donne au descripteur le nom sous lequel il est déclaré. C'est la mécanique derrière les champs de Django ORM et SQLAlchemy.

Création de classes et métaclasses

Une classe est un objet, instance de type. Ces deux définitions sont équivalentes :

class Point:
    x = 0
 
Point = type("Point", (), {"x": 0})

Une métaclasse est une sous-classe de type qui personnalise cette création :

class Registre(type):
    plugins: dict[str, type] = {}
 
    def __new__(mcs, nom, bases, ns):
        cls = super().__new__(mcs, nom, bases, ns)
        if bases:                         # ignore la classe de base
            mcs.plugins[nom.lower()] = cls
        return cls
 
class Plugin(metaclass=Registre): ...
class Markdown(Plugin): ...
class Html(Plugin): ...
 
Registre.plugins   # {'markdown': <class Markdown>, 'html': <class Html>}

Dans 90 % des cas, __init_subclass__ suffit et reste bien plus lisible :

class Plugin:
    registre: dict[str, type] = {}
 
    def __init_subclass__(cls, /, nom: str | None = None, **kwargs):
        super().__init_subclass__(**kwargs)
        Plugin.registre[nom or cls.__name__.lower()] = cls
 
class Markdown(Plugin, nom="md"): ...

Mon avis : si vous écrivez une métaclasse dans du code applicatif, vous faites probablement fausse route. Elles ont leur place dans les frameworks.

Mémoire et cycle de vie des objets

CPython gère la mémoire par comptage de références : chaque objet sait combien de noms pointent vers lui, et il est libéré dès que ce compteur tombe à zéro. Un ramasse-miettes complémentaire (gc) détecte les cycles.

import sys
a = []
sys.getrefcount(a)   # 2 (a + l'argument temporaire)

Pour qu'un cache ne garde pas des objets en vie indéfiniment, weakref :

import weakref
 
class Image: ...
cache: weakref.WeakValueDictionary[str, Image] = weakref.WeakValueDictionary()

Mesurer la mémoire :

import tracemalloc
 
tracemalloc.start()
donnees = [str(i) * 10 for i in range(100_000)]
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:3]:
    print(stat)

__slots__ supprime le dictionnaire par instance. Sur un million d'objets, le gain mémoire atteint facilement 40 à 50 %.

Mesurer avant d'optimiser

python -m cProfile -s cumtime script.py | head -30
python -m timeit "sum(x*x for x in range(1000))"

Pour un profil visuel et à faible surcoût en production, py-spy :

py-spy record -o profil.svg -- python script.py
py-spy top --pid 12345

Le désassembleur montre le bytecode réellement exécuté :

import dis
dis.dis(lambda x: x * 2 + 1)

Les leviers de performance, dans l'ordre

LevierGain typiqueCoût
Bon algorithme / bonne structure (set vs list)x10 à x1000faible
Fonctions natives (sum, sorted, compréhensions)x2 à x5faible
Vectorisation NumPy / Polarsx10 à x100moyen
Cache (functools.cache)variablefaible
Multiprocessingx nombre de cœursmoyen
Extension compilée (Cython, Rust via PyO3)x10 à x100élevé

Depuis 3.11, CPython est 25 % plus rapide en moyenne grâce à l'interpréteur spécialisant. Python 3.13 introduit un compilateur JIT expérimental (désactivé par défaut).

Exemple de vectorisation :

import numpy as np
 
# Python pur : ~1 s pour 10 millions
total = sum(x * x for x in range(10_000_000))
 
# NumPy : ~20 ms
arr = np.arange(10_000_000, dtype=np.int64)
total = int((arr * arr).sum())

Packaging et publication

Un package moderne se décrit entièrement dans pyproject.toml :

pyproject.toml
[project]
name = "slugify-fr"
version = "0.1.0"
description = "Slugs lisibles pour le français"
requires-python = ">=3.11"
dependencies = []
 
[project.scripts]
slugify-fr = "slugify_fr.cli:main"
 
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
uv build
uv publish --token pypi-xxxx

La section [project.scripts] crée une commande exécutable lors de l'installation. Pour distribuer l'outil en conteneur, le guide Docker pour débuter couvre les bases d'une image.

Les patterns Pythoniques à reconnaître

# Dispatch par type
from functools import singledispatch
 
@singledispatch
def serialiser(obj): raise TypeError(type(obj))
 
@serialiser.register
def _(obj: datetime): return obj.isoformat()
 
@serialiser.register
def _(obj: Decimal): return str(obj)
 
# Fonction partielle
from functools import partial
log_erreur = partial(log, niveau="ERROR")
 
# Enum riche
from enum import StrEnum, auto
class Statut(StrEnum):
    BROUILLON = auto()
    PUBLIE = auto()
 
Statut.PUBLIE == "publie"   # True

Lire le code des autres

Le meilleur exercice pour atteindre le niveau expert : lire du code source. Quelques candidats de qualité, de taille raisonnable :

  • functools.py et dataclasses.py dans la bibliothèque standard
  • httpx pour un client HTTP sync/async propre
  • attrs pour les descripteurs et la génération de code
  • rich pour l'architecture d'une bibliothèque d'affichage
import dataclasses, inspect
print(inspect.getsourcefile(dataclasses))

Projet final : un mini-ORM

Ce projet mobilise descripteurs, __init_subclass__, context managers et typage. Objectif : déclarer des modèles et générer le SQL pour SQLite.

miniorm.py
import sqlite3
from contextlib import contextmanager
from typing import Any, Self
 
class Champ:
    sql_type = "TEXT"
 
    def __init__(self, *, requis: bool = True):
        self.requis = requis
 
    def __set_name__(self, owner, nom):
        self.nom = nom
 
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.nom)
 
    def __set__(self, obj, valeur):
        if valeur is None and self.requis:
            raise ValueError(f"{self.nom} est requis")
        obj.__dict__[self.nom] = self.valider(valeur)
 
    def valider(self, v: Any) -> Any:
        return v
 
class Texte(Champ):
    def valider(self, v):
        if v is not None and not isinstance(v, str):
            raise TypeError(f"{self.nom} : str attendu")
        return v
 
class Entier(Champ):
    sql_type = "INTEGER"
    def valider(self, v):
        return None if v is None else int(v)
 
class Modele:
    _champs: dict[str, Champ]
    _table: str
 
    def __init_subclass__(cls, **kw):
        super().__init_subclass__(**kw)
        cls._table = cls.__name__.lower()
        cls._champs = {k: v for k, v in vars(cls).items() if isinstance(v, Champ)}
 
    def __init__(self, **valeurs):
        self.id: int | None = valeurs.pop("id", None)
        for nom in self._champs:
            setattr(self, nom, valeurs.get(nom))
 
    @classmethod
    def creer_table(cls, conn: sqlite3.Connection) -> None:
        cols = ", ".join(f"{n} {c.sql_type}" for n, c in cls._champs.items())
        conn.execute(f"CREATE TABLE IF NOT EXISTS {cls._table} (id INTEGER PRIMARY KEY, {cols})")
 
    def sauver(self, conn: sqlite3.Connection) -> Self:
        noms = list(self._champs)
        valeurs = [getattr(self, n) for n in noms]
        cur = conn.execute(
            f"INSERT INTO {self._table} ({', '.join(noms)}) VALUES ({', '.join('?' * len(noms))})",
            valeurs,
        )
        self.id = cur.lastrowid
        return self
 
    @classmethod
    def tous(cls, conn: sqlite3.Connection) -> list[Self]:
        conn.row_factory = sqlite3.Row
        return [cls(**dict(r)) for r in conn.execute(f"SELECT * FROM {cls._table}")]
 
    def __repr__(self):
        champs = ", ".join(f"{n}={getattr(self, n)!r}" for n in self._champs)
        return f"{type(self).__name__}(id={self.id}, {champs})"
 
@contextmanager
def session(chemin: str = ":memory:"):
    conn = sqlite3.connect(chemin)
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()
 
class Auteur(Modele):
    nom = Texte()
    naissance = Entier(requis=False)
 
with session() as db:
    Auteur.creer_table(db)
    Auteur(nom="Ada Lovelace", naissance=1815).sauver(db)
    Auteur(nom="Guido van Rossum").sauver(db)
    print(Auteur.tous(db))

Les valeurs passent par des paramètres ?, jamais par formatage de chaîne : c'est ce qui protège de l'injection SQL. Les noms de tables et colonnes, eux, viennent du code et non de l'utilisateur. Pour aller plus loin sur les bases relationnelles, voir les bases de PostgreSQL.

Pistes d'extension : un filtrer(**criteres), une méthode supprimer, des champs Booleen et Date, puis une version async avec aiosqlite.


Être expert en Python, ce n'est pas connaître chaque recoin de la syntaxe. C'est savoir pourquoi un attribut est résolu d'une certaine façon, mesurer avant d'optimiser, et choisir la construction la plus simple qui fait le travail. La plupart du temps, cette construction simple est une fonction et un dictionnaire.

Précédent
Concurrence : threads, processus et asyncio

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main