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.
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.
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. AttributeErrorC'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 # TrueUn 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.
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.
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 %.
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 12345Le désassembleur montre le bytecode réellement exécuté :
import dis
dis.dis(lambda x: x * 2 + 1)| Levier | Gain typique | Coût |
|---|---|---|
Bon algorithme / bonne structure (set vs list) | x10 à x1000 | faible |
Fonctions natives (sum, sorted, compréhensions) | x2 à x5 | faible |
| Vectorisation NumPy / Polars | x10 à x100 | moyen |
Cache (functools.cache) | variable | faible |
| Multiprocessing | x nombre de cœurs | moyen |
| 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())Un package moderne se décrit entièrement dans 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-xxxxLa 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.
# 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" # TrueLe 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 standardhttpx pour un client HTTP sync/async propreattrs pour les descripteurs et la génération de coderich pour l'architecture d'une bibliothèque d'affichageimport dataclasses, inspect
print(inspect.getsourcefile(dataclasses))Ce projet mobilise descripteurs, __init_subclass__, context managers et typage. Objectif : déclarer des modèles et générer le SQL pour SQLite.
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.