« 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 :
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_SIZERien à 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)plusyield from, plus de comptabilité d'offset ni d'accumulation de liste - les enregistrements deviennent une dataclass frozen avec une classmethod
from_apiqui encapsule le déballage du format{"values": {...}}d'Attio - le branchement
if obj == "companies"devient un mapping déclaratifFIELDS— qui alimente aussi leschoicesd'argparse : une seule source de vérité pour les objets valides - un
matchcompose le filtre de requête, et la variable d'env est lue façon EAFP (os.environ[...]+except KeyError)
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:
return3. 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 :
- 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.
- 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.
- 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 ».
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 😅