← Retour aux articles

« Write it in pythonic style as a pythonista would »

7 jul 2026

Le prompt du titre n'est pas de moi — il vient de Sébastien Vian, CTO de Wisetax, hébergée dans notre coworking à Marseille. Il le dégaine sur du Python généré par LLM, et ça m'a suffisamment fait rire pour l'essayer le matin même sur un vrai script. La réécriture était sympa ; c'est la conversation qui a suivi qui s'est révélée intéressante.

Le contexte : notre CRM (Attio) était pollué par un vieil import en masse, et je voulais un nettoyage entièrement scripté — un seul fichier, requests comme unique dépendance, dry run par défaut, une confirmation explicite avant toute suppression.

1. Le script naïf est un défaut raisonnable

La première version de Claude, c'est des fonctions plates et de l'état explicite — zéro malice :

python
def query_records(s: requests.Session, obj: str, filter_: dict | None) -> list[dict]:
    records, offset = [], 0
    while True:
        body = {"limit": PAGE_SIZE, "offset": offset,
                "sorts": [{"attribute": "created_at", "direction": "asc"}]}
        if filter_:
            body["filter"] = filter_
        resp = s.post(f"{BASE_URL}/objects/{obj}/records/query", json=body)
        resp.raise_for_status()
        page = resp.json()["data"]
        records.extend(page)
        if len(page) < PAGE_SIZE:
            return records
        offset += PAGE_SIZE

Rien à reprocher. Ça marche, tout le monde peut le lire, et le formatage par type d'objet est un simple branchement if obj == "companies". Pour un script qu'on lance deux fois puis qu'on jette, c'est sans doute le bon niveau — et c'est ce qu'un modèle produit par défaut, parce que c'est ce que le lecteur moyen sait suivre.

2. Ce que « pythonista » change

Un prompt plus tard — la citation exacte du titre — même comportement, autre forme :

  • la pagination devient un générateur : itertools.count(step=PAGE_SIZE) plus yield from, plus de comptabilité d'offset ni d'accumulation de liste
  • les enregistrements deviennent une dataclass frozen avec une classmethod from_api qui encapsule le déballage du format {"values": {...}} d'Attio
  • le branchement if obj == "companies" devient un mapping déclaratif FIELDS — qui alimente aussi les choices d'argparse : une seule source de vérité pour les objets valides
  • un match compose le filtre de requête, et la variable d'env est lue façon EAFP (os.environ[...] + except KeyError)
python
FIELDS = {
    "companies": (("name", "value"), ("domains", "domain")),
    "people": (("name", "full_name"), ("email_addresses", "email_address")),
}

def query_records(self, obj: str, record_filter: dict) -> Iterator[Record]:
    for offset in count(step=PAGE_SIZE):
        response = self._session.post(
            f"{BASE_URL}/objects/{obj}/records/query",
            json={"filter": record_filter, "limit": PAGE_SIZE, "offset": offset,
                  "sorts": [{"attribute": "created_at", "direction": "asc"}]},
        )
        response.raise_for_status()
        page = response.json()["data"]
        yield from (Record.from_api(data, obj) for data in page)
        if len(page) < PAGE_SIZE:
            return

3. Lui faire justifier chaque idiome

Je n'ai pas pris la réécriture telle quelle — certains de ces idiomes, je ne les pratique pas souvent, et hocher la tête devant du code qu'on ne saurait pas défendre, c'est comme ça qu'on finit par ne plus posséder ses propres scripts. Alors j'ai demandé, en commençant par : pourquoi passer session à chaque fonction ?

La réponse a posé le véritable espace de conception :

  1. La passer en paramètre — la dépendance est explicite dans chaque signature ; en test on lui donne une fausse session et on ne touche jamais au réseau. L'injection de dépendance dans sa forme la moins chère.
  2. Une globale de module — des appels plus courts, mais les fonctions dépendent désormais d'effets de bord à l'import, et la variable d'env est lue au moment de l'import. Les globales mutables de module, c'est un code smell.
  3. L'envelopper dans une classe — la bonne forme quand les fonctions grossissent ou portent plus d'état (base URL, politique de retry…). Pour un script de 150 lignes, ça ajoute de la cérémonie sans rien retirer.

J'ai quand même pris l'option 3 — je voulais la lecture client.query_records(...) — et c'était un refactor de dix lignes. Ensuite est venu cls() : pourquoi ce nom, c'est quoi le pattern ? Réponse : @classmethod reçoit la classe elle-même, cls est une convention exactement comme self, et from_api est un constructeur alternatif — le même nommage from_* que la stdlib utilise dans datetime.fromtimestamp() ou dict.fromkeys(), qui signale « construis-moi une instance à partir de cette autre représentation ».

python
class AttioClient:
    def __init__(self, token: str) -> None:
        self._session = requests.Session()
        self._session.headers.update({"Authorization": f"Bearer {token}"})

    @classmethod
    def from_env(cls) -> AttioClient:
        try:
            return cls(os.environ["ATTIO_ACCESS_TOKEN"])
        except KeyError:
            sys.exit("ATTIO_ACCESS_TOKEN is not set")

C'est la code review inversée : le modèle propose les idiomes, on challenge ceux qu'on n'aurait pas écrits, il les défend avec les trade-offs, et on arbitre. Chaque réponse est une petite leçon de Python ancrée dans du code dont on a vraiment besoin — bien plus durable que de lire la doc de classmethod dans l'abstrait.

4. Ce qui reste

Le pythonique n'est pas gratuit. frozen=True, slots=True sur un script jetable ne protège rien que quiconque risquerait de casser, et le match sur trois conditions de filtre, c'est du goût, pas de la nécessité. La version naïve aurait nettoyé le CRM tout aussi bien.

Le bilan honnête : le prompt achète le style, mais la compréhension est venue en demandant pourquoi, un idiome à la fois. Je le referai pour tout script que je compte garder — probablement pas pour un vrai one-shot 😅