---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Métaprogrammation

```{code-cell} python
:tags: [hide-input]

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
```

## Qu'est-ce que la métaprogrammation ?

La **métaprogrammation** est l'art d'écrire du code qui raisonne sur d'autres programmes — ou sur lui-même. En Python, cette notion recouvre deux activités distinctes mais complémentaires. La première est l'**introspection** : interroger un objet pour connaître sa nature, ses attributs, ses méthodes ou sa hiérarchie de classes, sans en avoir lu la définition statique. La seconde est la **génération ou modification de code** : créer des classes à la volée, intercepter la création d'instances, modifier le comportement d'attributs à l'aide de descripteurs, ou contrôler la construction de classes entières grâce aux métaclasses.

Ce qui rend Python particulièrement propice à la métaprogrammation, c'est que **tout est un objet**. Une classe est un objet de type `type`. Une fonction est un objet de type `function`. Un module est un objet de type `module`. On peut donc les manipuler, les inspecter, les passer en argument ou les retourner comme n'importe quelle autre valeur. Cette uniformité permet d'écrire des abstractions très puissantes, comme celles que l'on trouve dans les ORM (Django Models, SQLAlchemy), les frameworks de test (pytest, unittest) ou les bibliothèques de validation (Pydantic).

La métaprogrammation est parfois considérée comme une technique réservée aux experts, car elle peut rendre le code difficile à comprendre si elle est mal utilisée. Mais elle est omniprésente dans Python : chaque fois que vous utilisez `@property`, `@dataclass`, `@staticmethod` ou que vous héritez d'une classe abstraite, vous bénéficiez de mécanismes métaprogrammatiques. Comprendre ces mécanismes de l'intérieur vous permettra de mieux utiliser les bibliothèques existantes et, le cas échéant, d'en créer de nouvelles.

## Introspection

L'introspection désigne la capacité d'un programme à examiner sa propre structure pendant l'exécution. Python offre un riche ensemble de fonctions et de modules dédiés à cette tâche.

### Les fonctions intégrées d'introspection

Les fonctions `getattr`, `setattr`, `hasattr` et `delattr` forment le quatuor fondamental pour manipuler les attributs d'un objet de façon dynamique.

```{prf:definition} Fonctions d'accès dynamique aux attributs
:label: definition-17-01
- `getattr(obj, name, default)` : retourne la valeur de l'attribut `name` sur `obj`. Si l'attribut n'existe pas, retourne `default` s'il est fourni, sinon lève `AttributeError`.
- `setattr(obj, name, value)` : définit l'attribut `name` sur `obj` avec la valeur `value`. Équivalent à `obj.name = value`, mais le nom est une chaîne dynamique.
- `hasattr(obj, name)` : retourne `True` si `obj` possède un attribut `name` (sans lever d'exception).
- `delattr(obj, name)` : supprime l'attribut `name` sur `obj`. Équivalent à `del obj.name`.
```

```{code-cell} python
class Robot:
    def __init__(self, nom, vitesse):
        self.nom = nom
        self.vitesse = vitesse

    def deplacer(self):
        return f"{self.nom} se déplace à {self.vitesse} m/s."

r = Robot("R2D2", 3)

# Accès dynamique à un attribut dont le nom est connu à l'exécution
attribut = "vitesse"
print(getattr(r, attribut))          # 3

# Modification dynamique
setattr(r, "vitesse", 5)
print(r.vitesse)                     # 5

# Vérification
print(hasattr(r, "deplacer"))        # True
print(hasattr(r, "voler"))           # False

# Accès à une méthode par son nom et appel
methode = getattr(r, "deplacer")
print(methode())                     # R2D2 se déplace à 5 m/s.
```

La fonction `vars()` retourne le dictionnaire `__dict__` d'un objet ou d'une classe, c'est-à-dire l'ensemble de ses attributs d'instance ou de classe sous forme de dictionnaire mutable. La fonction `dir()` retourne une liste triée de tous les noms accessibles sur un objet — y compris les attributs hérités.

```{code-cell} python
print(vars(r))
# {'nom': 'R2D2', 'vitesse': 5}

# dir() inclut les attributs hérités de object
noms = [n for n in dir(r) if not n.startswith("__")]
print(noms)
# ['deplacer', 'nom', 'vitesse']
```

### Le module `inspect`

Le module `inspect` de la bibliothèque standard pousse l'introspection bien plus loin. Il permet d'examiner les fonctions (leur signature, leurs annotations, leur code source), les classes (leur hiérarchie, leurs membres) et les cadres d'exécution (la pile d'appels).

```{code-cell} python
import inspect

def additionner(a: int, b: int = 0) -> int:
    """Additionne deux entiers."""
    return a + b

# Signature complète
sig = inspect.signature(additionner)
print(sig)                           # (a: int, b: int = 0) -> int

# Paramètres individuels
for nom, param in sig.parameters.items():
    print(f"  {nom}: annotation={param.annotation}, "
          f"défaut={param.default}")

# Vérifier si un objet est une fonction, une classe, etc.
print(inspect.isfunction(additionner))  # True
print(inspect.isclass(Robot))           # True

# Source du code (quand disponible)
# print(inspect.getsource(additionner))
```

## `__init_subclass__`

Le hook `__init_subclass__` est une méthode de classe appelée automatiquement chaque fois qu'une classe hérite de la classe qui le définit. C'est une façon élégante d'effectuer des traitements sur les sous-classes au moment de leur création, sans recourir aux métaclasses.

```{prf:definition} `__init_subclass__`
:label: definition-17-02
`__init_subclass__(cls, **kwargs)` est un hook de classe appelé par `type.__init_subclass__` lorsqu'une classe hérite de la classe courante. `cls` est la sous-classe nouvellement créée. Les arguments de classe supplémentaires (`class Enfant(Parent, option=True)`) sont transmis via `**kwargs`.
```

```{code-cell} python
class PluginBase:
    _registry: dict = {}

    def __init_subclass__(cls, commande: str = None, **kwargs):
        super().__init_subclass__(**kwargs)
        if commande is not None:
            PluginBase._registry[commande] = cls
            print(f"Plugin enregistré : '{commande}' → {cls.__name__}")

class PluginBonjour(PluginBase, commande="bonjour"):
    def executer(self):
        return "Bonjour, monde !"

class PluginAuRevoir(PluginBase, commande="aurevoir"):
    def executer(self):
        return "Au revoir !"

# Le registre est rempli automatiquement à la définition des classes
print(PluginBase._registry)
plugin = PluginBase._registry["bonjour"]()
print(plugin.executer())
```

Ce pattern est utilisé dans de nombreux frameworks (Django REST Framework, Celery, Click) pour enregistrer automatiquement des composants sans que le développeur ait à les déclarer manuellement dans un registre central.

## Descripteurs

Un **descripteur** est un objet qui définit comment l'accès à un attribut d'une autre classe se comporte. C'est le mécanisme sous-jacent de `@property`, `@staticmethod`, `@classmethod` et des champs des ORM comme Django.

```{prf:definition} Protocole descripteur
:label: definition-17-03
Un objet est un descripteur s'il implémente au moins une de ces méthodes :
- `__get__(self, obj, objtype=None)` : appelée lors de la lecture de l'attribut (`obj.attr`).
- `__set__(self, obj, value)` : appelée lors de l'écriture (`obj.attr = value`).
- `__delete__(self, obj)` : appelée lors de la suppression (`del obj.attr`).

Un descripteur qui implémente `__set__` ou `__delete__` est dit **descripteur de données** (*data descriptor*) ; il a la priorité sur le `__dict__` de l'instance. Un descripteur qui n'implémente que `__get__` est un **descripteur non-données** (*non-data descriptor*).
```

```{code-cell} python
class PositifOuNul:
    """Descripteur qui valide qu'une valeur est >= 0."""

    def __set_name__(self, owner, name):
        self._name = name
        self._private = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self._private, 0)

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(
                f"'{self._name}' doit être positif ou nul, reçu : {value}"
            )
        setattr(obj, self._private, value)

class Produit:
    prix = PositifOuNul()
    quantite = PositifOuNul()

    def __init__(self, nom, prix, quantite):
        self.nom = nom
        self.prix = prix
        self.quantite = quantite

p = Produit("Pomme", 0.50, 100)
print(p.prix)       # 0.5
p.prix = 0.75
print(p.prix)       # 0.75

try:
    p.prix = -1
except ValueError as e:
    print(e)        # 'prix' doit être positif ou nul, reçu : -1
```

### Comment `@property` est implémenté

La fonction intégrée `property` est elle-même un descripteur. On peut en écrire une version simplifiée pour comprendre son fonctionnement :

```python
class ma_property:
    def __init__(self, fget=None, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("attribut illisible")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("attribut non modifiable")
        self.fset(obj, value)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel)
```

## Métaclasses

En Python, **tout objet est une instance d'une classe**. Mais une classe elle-même est aussi un objet — une instance de sa **métaclasse**. Par défaut, toutes les classes sont des instances de `type`, qui est à la fois une classe et sa propre métaclasse.

```{prf:definition} Métaclasse
:label: definition-17-04
Une **métaclasse** est la classe d'une classe. Elle contrôle la création de classes : quand Python rencontre l'instruction `class Foo(Bar):`, il appelle la métaclasse de `Bar` pour construire l'objet classe `Foo`. La métaclasse par défaut est `type`. On peut spécifier une métaclasse différente avec `class Foo(Bar, metaclass=MaMeta):`.
```

```{code-cell} python
# type() à trois arguments crée une classe dynamiquement
# type(nom, bases, dictionnaire)
Animal = type("Animal", (object,), {
    "cri": "...",
    "parler": lambda self: f"Je fais '{self.cri}' !"
})

class Chien(Animal):
    cri = "Ouaf"

d = Chien()
print(d.parler())           # Je fais 'Ouaf' !
print(type(Chien))          # <class 'type'>
print(type(type))           # <class 'type'>
```

### Créer une métaclasse

On crée une métaclasse en héritant de `type` et en surchargeant `__new__` (qui construit l'objet classe) et/ou `__init__` (qui l'initialise).

```{code-cell} python
class SingletonMeta(type):
    """Métaclasse qui implémente le patron Singleton."""
    _instances: dict = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class ConfigurationApp(metaclass=SingletonMeta):
    def __init__(self, chemin="/etc/app.conf"):
        self.chemin = chemin
        print(f"Configuration chargée depuis {chemin}")

c1 = ConfigurationApp("/home/user/.apprc")
c2 = ConfigurationApp("/autre/chemin")   # __init__ non rappelé
print(c1 is c2)      # True
print(c1.chemin)     # /home/user/.apprc
```

```{prf:remark}
:label: remark-17-01
**`__new__` vs `__init__` dans une métaclasse** : `__new__(mcs, nom, bases, namespace)` est appelée en premier et retourne le nouvel objet classe. `__init__(cls, nom, bases, namespace)` reçoit ensuite cet objet pour l'initialiser. Dans la majorité des cas pratiques, on surcharge `__new__` pour modifier le dictionnaire d'attributs avant que la classe ne soit créée, et `__init__` pour effectuer des actions après la création. La méthode `__call__` de la métaclasse est invoquée lorsqu'on appelle la classe pour créer une instance.
```

### Cas d'usage réels

Les ORM Django utilisent une métaclasse (`ModelBase`) pour transformer les déclarations de champs (`CharField`, `IntegerField`) en descripteurs actifs, enregistrer les modèles dans un registre global et générer automatiquement les requêtes SQL. SQLAlchemy utilise un mécanisme similaire. Ces usages justifient les métaclasses : quand on a besoin d'agir sur la *structure* d'une classe au moment de sa *définition*, pas à celui de son *instanciation*.

```{prf:remark}
:label: remark-17-02
**Quand utiliser les métaclasses ?** La réponse honnête est : rarement. Pour la plupart des problèmes, `__init_subclass__`, les décorateurs de classe ou les descripteurs suffisent et sont plus lisibles. Les métaclasses s'imposent lorsqu'on doit modifier le comportement de `type.__new__` lui-même, ou lorsqu'on crée un framework qui doit agir sur des centaines de classes d'utilisateurs de façon transparente. Comme le dit Tim Peters : *"Si vous pensez avoir besoin d'une métaclasse, vous avez probablement tort — mais si vous en avez réellement besoin, vous le saurez."*
```

## `__class_getitem__` et génériques

Depuis Python 3.7, la syntaxe `list[int]` et `dict[str, int]` fonctionne grâce à la méthode spéciale `__class_getitem__`. Elle est appelée lorsqu'on indexe une classe avec des crochets.

```{code-cell} python
class Pile:
    """Pile générique avec annotation de type."""

    def __class_getitem__(cls, item):
        # Retourne un alias de type pour les annotations
        return f"Pile[{item.__name__ if hasattr(item, '__name__') else item}]"

    def __init__(self):
        self._data = []

    def empiler(self, valeur):
        self._data.append(valeur)

    def depiler(self):
        return self._data.pop()

# Utilisation dans les annotations
def traiter(pile: Pile[int]) -> None:
    pass

print(Pile[int])     # Pile[int]
print(Pile[str])     # Pile[str]
```

Les classes génériques standard (`list`, `dict`, `tuple`, `set`) implémentent `__class_getitem__` depuis Python 3.9, ce qui permet d'écrire directement `list[int]` dans les annotations sans importer `List` depuis `typing`. Pour des génériques personnalisés complets, on hérite de `typing.Generic[T]`.

```{code-cell} python
:tags: [hide-input]

fig, ax = plt.subplots(figsize=(12, 7))
ax.set_xlim(0, 14)
ax.set_ylim(0, 8)
ax.axis('off')
ax.set_title(
    "Relation entre objet, classe et métaclasse en Python",
    fontsize=15, fontweight='bold', pad=15
)

couleurs = {
    "meta": "#e74c3c",
    "classe": "#3498db",
    "instance": "#27ae60",
    "fleche": "#2c3e50",
}

def boite(ax, x, y, w, h, texte_principal, texte_secondaire, couleur):
    rect = patches.FancyBboxPatch(
        (x, y), w, h,
        boxstyle="round,pad=0.15", linewidth=2.5,
        edgecolor=couleur, facecolor=couleur, alpha=0.15
    )
    ax.add_patch(rect)
    bord = patches.FancyBboxPatch(
        (x, y), w, h,
        boxstyle="round,pad=0.15", linewidth=2.5,
        edgecolor=couleur, facecolor='none'
    )
    ax.add_patch(bord)
    ax.text(x + w/2, y + h*0.65, texte_principal,
            ha='center', va='center', fontsize=12,
            fontweight='bold', color=couleur)
    ax.text(x + w/2, y + h*0.28, texte_secondaire,
            ha='center', va='center', fontsize=9,
            color='#555555', style='italic')

# Métaclasse
boite(ax, 0.5, 5.0, 3.5, 2.2, "type", "métaclasse par défaut", couleurs["meta"])

# Classe
boite(ax, 5.0, 5.0, 3.5, 2.2, "MaClasse", "class MaClasse:", couleurs["classe"])

# Instance
boite(ax, 9.5, 5.0, 3.5, 2.2, "instance", "MaClasse()", couleurs["instance"])

# Flèche type → MaClasse (instanciation de la classe)
ax.annotate('', xy=(5.0, 6.1), xytext=(4.0, 6.1),
            arrowprops=dict(arrowstyle='->', color=couleurs["meta"], lw=2.2))
ax.text(4.5, 6.55, "instance de", ha='center', fontsize=8.5,
        color=couleurs["meta"], style='italic')

# Flèche MaClasse → instance (instanciation)
ax.annotate('', xy=(9.5, 6.1), xytext=(8.5, 6.1),
            arrowprops=dict(arrowstyle='->', color=couleurs["classe"], lw=2.2))
ax.text(9.0, 6.55, "instance de", ha='center', fontsize=8.5,
        color=couleurs["classe"], style='italic')

# Flèche type → type (type est instance de lui-même)
ax.annotate('', xy=(0.5, 5.6), xytext=(0.5, 4.2),
            arrowprops=dict(arrowstyle='->', color=couleurs["meta"], lw=2,
                            connectionstyle="arc3,rad=-0.5"))
ax.text(0.05, 4.85, "type(type)\n= type", ha='center', fontsize=8,
        color=couleurs["meta"], style='italic')

# Héritage : MaClasse hérite de object
boite(ax, 5.0, 1.5, 3.5, 2.2, "object", "classe racine", "#8e44ad")

ax.annotate('', xy=(6.75, 5.0), xytext=(6.75, 3.7),
            arrowprops=dict(arrowstyle='->', color="#8e44ad", lw=2.2,
                            linestyle='dashed'))
ax.text(7.6, 4.35, "hérite de", ha='center', fontsize=8.5,
        color="#8e44ad", style='italic')

# Légende
legend_items = [
    (couleurs["meta"], "Métaclasse"),
    (couleurs["classe"], "Classe"),
    (couleurs["instance"], "Instance"),
    ("#8e44ad", "Héritage"),
]
for i, (c, lbl) in enumerate(legend_items):
    ax.add_patch(patches.FancyBboxPatch(
        (0.5 + i*3.3, 0.3), 0.4, 0.4,
        boxstyle="round,pad=0.05", facecolor=c, alpha=0.7))
    ax.text(1.1 + i*3.3, 0.5, lbl, fontsize=9, va='center', color=c,
            fontweight='bold')

plt.tight_layout()
plt.show()
```

## Résumé

Dans ce chapitre, nous avons exploré les mécanismes par lesquels Python permet au code de s'inspecter et de se modifier lui-même :

- L'**introspection** avec `getattr`, `setattr`, `hasattr`, `vars()`, `dir()` et le module `inspect` permet d'examiner n'importe quel objet à l'exécution, sans en connaître la structure à l'avance.
- **`__init_subclass__`** offre un point d'extension propre pour réagir à la création de sous-classes, sans la complexité des métaclasses — idéal pour les registres de plugins ou les frameworks.
- Les **descripteurs** (`__get__`, `__set__`, `__delete__`) sont le mécanisme sous-jacent de `@property`, `@staticmethod` et des champs d'ORM. Maîtriser ce protocole permet de comprendre comment Python gère réellement l'accès aux attributs.
- Les **métaclasses** contrôlent la création de classes entières. Elles sont puissantes mais doivent rester un outil de dernier recours, réservé aux frameworks.
- **`__class_getitem__`** permet la syntaxe générique `MaClasse[T]`, utilisée par le système de types depuis Python 3.9.

Dans le chapitre suivant, nous abordons un tout autre paradigme : la programmation fonctionnelle en Python, avec `functools`, `itertools`, `operator` et les fonctions d'ordre supérieur.
