Serveur d'API GraphQL
Les API REST te forcent à concevoir un point de terminaison par ressource, mais les clients réels ont souvent besoin des données de cinq ressources différentes dans un seul chargement d’écran. GraphQL résout cela en laissant le client demander exactement ce dont il a besoin dans une seule requête. Ce projet construit un serveur d’API GraphQL de zéro : tu définis un schéma avec des types et des requêtes, tu écris des résolveurs qui récupèrent de vraies données, tu utilises DataLoader pour regrouper les recherches en base de données et prévenir le problème de requêtes N+1, et tu ajoutes des abonnements pour les mises à jour en temps réel. Le serveur s’exécute sur Strawberry (une bibliothèque GraphQL Python) avec un stockage de données en mémoire.
Ceci suppose Python 101 et une aisance avec pandas de Data Analysis, rien de plus. Optionnel et non noté ; voir Real-World Projects pour la liste complète.
🎯 Ce que tu vas faire
- Configurer un projet avec
uvet installer les dépendances GraphQL dont tu auras besoin. - Définir un schéma GraphQL avec des types, des requêtes et des mutations en utilisant Strawberry.
- Écrire des résolveurs qui renvoient de vraies données depuis un stockage en mémoire.
- Implémenter DataLoader pour regrouper plusieurs recherches en une seule requête par requête HTTP.
- Ajouter des abonnements qui poussent les mises à jour en direct quand les données changent.
- Exécuter le serveur et le tester avec GraphQL Playground.
Où exécuter ceci
Localement avec uv est la voie principale, c’est un serveur qui s’exécute sur localhost et sert des requêtes HTTP. Tu interagiras avec lui via un GraphQL Playground basé sur le navigateur ou un outil comme curl.
GitHub Codespaces fonctionne bien : ouvre tout le dépôt du cours dans un Codespace gratuit, Python, uv et le réseau sont déjà configurés, donc chaque étape fonctionne exactement comme en local.
Google Colab, Kaggle Notebooks et Binder peuvent exécuter le serveur pour un test rapide, mais le GraphQL Playground ne se rend peut-être pas dans le panneau de sortie d’un notebook. Le notebook démarre le serveur sur un port et le teste avec curl à la place.
Configuration
Tout ce dont tu as besoin avant d’écrire un schéma : un environnement Python, la bibliothèque GraphQL Strawberry et un framework web asynchrone pour le servir.
Installer uv
curl -LsSf https://astral.sh/uv/install.sh | sh
Ferme et rouvre ton terminal, puis confirme :
uv --version
Initialiser le projet
uv init graphql-server
cd graphql-server
uv add strawberry-graphql uvicorn
strawberry-graphql est une bibliothèque GraphQL code-first pour Python, tu définis ton schéma comme des types Python, et elle génère le schéma GraphQL et les résolveurs automatiquement. uvicorn est le serveur ASGI qui exécute l’application. asyncio est dans la bibliothèque standard et alimente le regroupement DataLoader.
Créer la structure du projet
mkdir -p gql
touch gql/__init__.py gql/types.py gql/schema.py gql/dataloaders.py gql/store.py gql/server.py
✅ Liste de vérification
- ✅
uv --versionimprime un numéro de version. - ✅
graphql-server/existe avec unpyproject.toml, etstrawberry-graphqletuvicornsont installés. - ✅ Le répertoire
gql/a tous les fichiers de modules requis.
Étape 1 : Définir le stockage de données et les types GraphQL
Avant de pouvoir écrire un schéma, tu as besoin de données à servir et de types pour les décrire. Cette étape crée un simple stockage en mémoire d’utilisateurs et de publications, puis définit les types GraphQL qui les reflètent.
1.1 Créer le stockage de données en mémoire
👟 Indice de départ : Crée gql/store.py avec des utilisateurs et des publications d’exemple.
# gql/store.py
from dataclasses import dataclass, field
@dataclass
class User:
id: int
name: str
email: str
@dataclass
class Post:
id: int
author_id: int
title: str
body: str
published: bool = False
# In-memory "database"
USERS: dict[int, User] = {
1: User(id=1, name="Alice", email="alice@example.com"),
2: User(id=2, name="Bob", email="bob@example.com"),
3: User(id=3, name="Carol", email="carol@example.com"),
}
POSTS: dict[int, Post] = {
101: Post(id=101, author_id=1, title="First Post", body="Hello world", published=True),
102: Post(id=102, author_id=1, title="Draft", body="Work in progress", published=False),
103: Post(id=103, author_id=2, title="GraphQL Tips", body="Use DataLoader!", published=True),
}
Le stockage est volontairement simple, des dictionnaires ordinaires avec des valeurs dataclass. Cela garde le focus sur la mécanique GraphQL plutôt que sur les pilotes de base de données. En production, tu remplacerais ces dictionnaires par une base de données, mais la couche GraphQL reste identique.
🎯 Résultat attendu : USERS[1].name renvoie "Alice" ; POSTS[101].author_id renvoie 1.
🩹 Si ça ne marche pas : Si USERS est vide après l’avoir défini, vérifie que la syntaxe du dictionnaire est correcte (pas de virgules manquantes entre les entrées).
1.2 Définir les types GraphQL Strawberry
# gql/types.py
import strawberry
@strawberry.type
class UserType:
id: int
name: str
email: str
@strawberry.type
class PostType:
id: int
title: str
body: str
published: bool
author: "UserType" # resolved lazily, not eagerly
@strawberry.type
class Query:
pass # extended in schema.py
@strawberry.type
class Mutation:
pass # extended in schema.py
Strawberry utilise les annotations de type Python pour générer le schéma GraphQL. Chaque classe @strawberry.type devient un type GraphQL, et chaque champ devient un champ GraphQL. Le champ author de PostType est marqué comme un UserType, sa résolution réelle (charger l’utilisateur par author_id) se fait dans un résolveur, pas dans la définition de type. Cette séparation est ce qui rend GraphQL flexible : le client peut demander post.author.name ou juste post.title, et seuls les résolveurs nécessaires aux champs demandés s’exécutent réellement.
🎯 Résultat attendu : UserType(id=1, name="Alice", email="alice@example.com") crée un type Strawberry qui se sérialise correctement.
🩹 Si ça ne marche pas : Si @strawberry.type n’est pas reconnu, vérifie que strawberry est importé depuis le bon chemin. Si la chaîne de type de champ author "UserType" cause une erreur, utilise from __future__ import annotations en haut du fichier.
1.3 Vérifier les types
✅ Liste de vérification
- ✅
USERSetPOSTSstockent les données comme des dictionnaires ordinaires. - ✅
UserTypeetPostTypesont des types Strawberry avec les bons champs. - ✅ Le champ
authordePostTyperéférenceUserType.
🤔 Question(s) socratique(s)
- En REST, tu aurais
/users/1et/posts/101comme points de terminaison séparés. En GraphQL, les deux sont des champs sur la même racineQuery. Quel avantage cela donne-t-il à un client qui a besoin des données utilisateur et publication ensemble ? - Le champ
authorest typé mais pas encore résolu. Comment la résolution paresseuse de GraphQL fait-elle en sorte que le serveur ne charge jamais plus de données que le client ne demande réellement ?
Étape 2 : Écrire les résolveurs pour les requêtes
Les résolveurs sont des fonctions qui récupèrent des données pour chaque champ. Quand un client demande posts { author { name } }, le résolveur de posts renvoie la liste des publications, et le résolveur de author sur chaque publication renvoie l’utilisateur correspondant.
2.1 Écrire les résolveurs de requêtes
👟 Indice de départ : Crée gql/schema.py avec des résolveurs pour les requêtes users, posts et user.
# gql/schema.py
import strawberry
from gql.types import UserType, PostType, Query, Mutation
from gql.store import USERS, POSTS
def resolve_users(root, info) -> list[UserType]:
return [UserType(id=u.id, name=u.name, email=u.email) for u in USERS.values()]
def resolve_user(root, info, id: int) -> UserType | None:
u = USERS.get(id)
return UserType(id=u.id, name=u.name, email=u.email) if u else None
def resolve_posts(root, info, author_id: int | None = None) -> list[PostType]:
posts = POSTS.values()
if author_id is not None:
posts = [p for p in posts if p.author_id == author_id]
return [
PostType(id=p.id, title=p.title, body=p.body, published=p.published, author=None)
for p in posts
]
def resolve_author(post: PostType, info) -> UserType | None:
u = USERS.get(post.author_id)
return UserType(id=u.id, name=u.name, email=u.email) if u else None
# Attach resolvers to the types
Query.users = resolve_users
Query.user = resolve_user
Query.posts = resolve_posts
PostType.author = resolve_author
schema = strawberry.Schema(query=Query, mutation=Mutation)
L’objet schema est le point d’entrée, Strawberry génère le schéma GraphQL complet à partir de lui, y compris l’introspection. La fonction resolve_author est attachée directement à PostType.author, donc quand un client demande post.author, cette fonction s’exécute. Quand le client ne demande pas author, elle ne s’exécute jamais, c’est l’efficacité centrale de GraphQL.
🎯 Résultat attendu : schema.execute_sync("{ users { name } }") renvoie la liste des utilisateurs. schema.execute_sync("{ posts { title author { name } } }") renvoie les publications avec les noms de leurs auteurs.
🩹 Si ça ne marche pas : Si resolve_posts renvoie author=None même quand le client le demande, le résolveur PostType.author n’est pas attaché. Si schema.execute_sync lève une erreur de champ manquant, la chaîne de requête ne correspond pas aux noms de champs du schéma.
2.2 Ajouter une requête avec des arguments
# gql/schema.py (continued)
# The resolve_user resolver already takes an id argument.
# Strawberry infers the GraphQL argument from the Python function signature.
Le paramètre id: int sur resolve_user devient automatiquement un argument GraphQL requis user(id: Int!). Aucune configuration supplémentaire nécessaire, Strawberry lit la signature de la fonction.
🎯 Résultat attendu : schema.execute_sync("{ user(id: 1) { name email } }") renvoie les données d’Alice. schema.execute_sync("{ user(id: 999) { name } }") renvoie null pour l’utilisateur.
🩹 Si ça ne marche pas : Si l’argument n’est pas reconnu dans le schéma, le nom du paramètre de la fonction ou l’annotation de type ne correspond pas à ce que Strawberry attend.
2.3 Vérifier les résolveurs
✅ Liste de vérification
- ✅
schema.execute_sync("{ users { name } }")renvoie les trois utilisateurs. - ✅
schema.execute_sync("{ user(id: 1) { name } }")renvoie Alice. - ✅
schema.execute_sync("{ posts { title } }")renvoie toutes les publications. - ✅ Seuls les champs demandés sont renvoyés, pas de sur-chargement.
🤔 Question(s) socratique(s)
- Si un client demande
posts { author { email } }, le résolveurresolve_postss’exécute d’abord (renvoyant toutes les publications avecauthor=None), puisresolve_authors’exécute pour chaque publication. C’est trois appels séparés àUSERS.get. Comment DataLoader regrouperait-il cela en un seul appel ? - Que se passe-t-il si
resolve_userrenvoieNonepour un ID inconnu ? GraphQL renvoie-t-ilnulldans la réponse, ou une erreur ? En quoi cela diffère-t-il d’un 404 REST ?
Étape 3 : Implémenter DataLoader pour prévenir le problème N+1
Le problème N+1 : récupérer une liste de 100 publications puis résoudre l’auteur de chaque publication individuellement signifie 101 requêtes en base de données (1 pour les publications + 100 pour les auteurs). DataLoader résout cela en collectant tous les ID d’auteurs d’un cycle de requête et en les groupant en une seule recherche.
3.1 Construire le DataLoader
👟 Indice de départ : Crée gql/dataloaders.py avec un UserLoader qui regroupe les recherches d’utilisateurs par ID.
# gql/dataloaders.py
from asyncio import gather
from gql.store import USERS, User
class DataLoader:
"""A simplified DataLoader that batches and caches lookups per request."""
def __init__(self, batch_fn):
self.batch_fn = batch_fn
self.cache: dict = {}
self.pending: dict[int, "Future"] = {}
async def load(self, key: int):
if key in self.cache:
return self.cache[key]
# In a real DataLoader, you'd collect keys and batch at the end of the tick.
# Here we call batch_fn directly for simplicity.
result = await self.batch_fn([key])
self.cache[key] = result[0]
return result[0]
class UserLoader:
def __init__(self):
self.loader = DataLoader(self._batch_load)
async def _batch_load(self, ids: list[int]) -> list[User]:
"""Batch load users by IDs — one call for all IDs."""
print(f" [DataLoader] Batch loading users: {ids}")
return [USERS.get(uid) for uid in ids]
async def load(self, user_id: int) -> User | None:
return await self.loader.load(user_id)
La vraie magie est dans _batch_load : au lieu d’appeler USERS.get une fois par publication, DataLoader collecte tous les ID d’utilisateurs d’un cycle de requête et appelle _batch_load une fois avec tous. L’instruction print prouve le regroupement, tu devrais voir une seule ligne de log avec tous les ID, pas une par publication. En production, ce motif DataLoader (popularisé par la bibliothèque dataloader de Facebook pour JavaScript) réduit les allers-retours en base de données de N+1 à 2.
🎯 Résultat attendu : await loader.load(1) renvoie USERS[1] et imprime une seule ligne de log de regroupement. Charger les utilisateurs 1, 2, 3 en séquence imprime une seule ligne de log avec [1, 2, 3].
🩹 Si ça ne marche pas : Si tu vois plusieurs lignes de log (une par appel de chargement), le regroupement ne fonctionne pas, vérifie que le DataLoader collecte les ID avant d’appeler batch_fn.
3.2 Intégrer DataLoader dans les résolveurs
# gql/schema.py (continued, replace resolve_author)
from gql.dataloaders import UserLoader
# Create a loader per request (in practice, use context)
_user_loader = UserLoader()
async def resolve_author_with_loader(post: PostType, info) -> UserType | None:
user = await _user_loader.load(post.author_id)
return UserType(id=user.id, name=user.name, email=user.email) if user else None
PostType.author = resolve_author_with_loader
🎯 Résultat attendu : Interroger posts { author { name } } imprime une seule ligne de log de regroupement (pas trois), confirmant que DataLoader a fusionné les recherches.
🩹 Si ça ne marche pas : Si le résolveur est encore synchrone, ajoute async et await. Si le log montre plusieurs appels de regroupement, le loader n’est pas partagé entre les résolveurs.
3.3 Vérifier le regroupement DataLoader
✅ Liste de vérification
- ✅ Charger plusieurs utilisateurs imprime une seule ligne de log de regroupement, pas une par utilisateur.
- ✅ Chaque utilisateur chargé correspond aux données attendues du stockage.
- ✅ Le DataLoader met en cache les résultats, charger deux fois le même ID ne re-regroupe pas.
🤔 Question(s) socratique(s)
- Dans une vraie application,
_batch_loadfrapperait une base de données. Si deux requêtes arrivent simultanément, elles partageraient le même_user_loaderet mélangeraient leurs ID. Comment limiterais-tu le loader à une seule requête ? - DataLoader met en cache par clé au sein d’une requête. Que se passe-t-il si l’utilisateur 1 change de nom entre deux requêtes dans la même session ? Les données obsolètes sont-elles un problème, et comment le corrigerais-tu ?
Étape 4 : Ajouter des abonnements en temps réel
Les abonnements poussent des mises à jour aux clients quand les données changent, contrairement aux requêtes (tire une fois) ou aux mutations (pousse une fois), les abonnements maintiennent une connexion ouverte. Cette étape ajoute un abonnement qui notifie les clients quand une nouvelle publication est publiée.
4.1 Définir un abonnement
👟 Indice de départ : Crée un abonnement post_published qui produit les nouvelles publications à mesure qu’elles sont créées.
# gql/schema.py (continued)
import asyncio
from typing import AsyncGenerator
import strawberry
from gql.types import PostType
@strawberry.type
class Subscription:
@strawberry.field
async def post_published(self) -> AsyncGenerator[PostType, None]:
"""Yields new posts as they are published."""
# In production, this would read from a message queue or WebSocket.
# For demo, yield a fake post after a short delay.
await asyncio.sleep(1)
yield PostType(id=999, title="Live Post", body="This appeared in real time!", published=True, author=None)
Les abonnements utilisent la syntaxe generator asynchrone de Python, yield envoie chaque mise à jour au client. En production, tu remplacerais le asyncio.sleep par une vraie source d’événements (pub/sub Redis, un déclencheur de base de données ou une file de messages). Strawberry gère le protocole WebSocket qui garde la connexion ouverte et délivre chaque valeur produite.
🎯 Résultat attendu : schema.execute_sync n’est pas utilisé pour les abonnements, à la place, l’abonnement s’exécute de manière asynchrone et produit la publication après 1 seconde.
🩹 Si ça ne marche pas : Si l’abonnement ne produit rien, le generator asynchrone n’est pas configuré correctement, vérifie l’annotation de type AsyncGenerator et l’instruction yield.
4.2 Câbler l’abonnement dans le schéma
# gql/schema.py (update the schema creation)
schema = strawberry.Schema(query=Query, mutation=Mutation, subscription=Subscription)
🎯 Résultat attendu : schema.as_str() inclut type Subscription { postPublished: PostType! } dans la définition du schéma.
🩹 Si ça ne marche pas : Si le type d’abonnement n’apparaît pas dans le schéma, la classe Subscription n’est pas passée à strawberry.Schema.
4.3 Vérifier les abonnements
✅ Liste de vérification
- ✅ Le schéma inclut un type
Subscriptionavec le champpostPublished. - ✅ Un generator asynchrone produit des publications quand l’abonnement s’exécute.
- ✅ L’objet
schemainclut les abonnements quand il est imprimé.
🤔 Question(s) socratique(s)
- Les abonnements maintiennent une connexion WebSocket ouverte. Que devient cette connexion si le serveur redémarre ? Comment un client détecterait-il et récupérerait-il d’un abonnement interrompu ?
- Pour une application de chat, tu aurais besoin d’abonnements pour les nouveaux messages, les indicateurs de frappe et la présence. Comment combinerais-tu plusieurs types d’abonnements sans créer un WebSocket séparé pour chacun ?
Étape 5 : Exécuter le serveur et le tester
Tout se rassemble dans le serveur : le schéma, les résolveurs, le DataLoader et l’abonnement. Strawberry embarque un GraphQL Playground intégré pour les tests interactifs.
5.1 Écrire le point d’entrée du serveur
👟 Indice de départ : Crée gql/server.py qui exécute le serveur Strawberry avec le playground activé.
# gql/server.py
import uvicorn
import strawberry
from gql.types import UserType, PostType, Query, Mutation
from gql.store import USERS, POSTS
def resolve_users(root, info):
return [UserType(id=u.id, name=u.name, email=u.email) for u in USERS.values()]
def resolve_user(root, info, id: int):
u = USERS.get(id)
return UserType(id=u.id, name=u.name, email=u.email) if u else None
def resolve_posts(root, info, author_id: int | None = None):
posts = POSTS.values()
if author_id is not None:
posts = [p for p in posts if p.author_id == author_id]
return [
PostType(id=p.id, title=p.title, body=p.body, published=p.published, author=None)
for p in posts
]
def resolve_author(post, info):
u = USERS.get(post.author_id)
return UserType(id=u.id, name=u.name, email=u.email) if u else None
Query.users = resolve_users
Query.user = resolve_user
Query.posts = resolve_posts
PostType.author = resolve_author
schema = strawberry.Schema(query=Query, mutation=Mutation)
if __name__ == "__main__":
print("Starting GraphQL server at http://localhost:8000/graphql")
uvicorn.run("gql.server:app", host="0.0.0.0", port=8000, reload=True)
L’intégration de Strawberry avec ASGI signifie que tu peux l’exécuter directement avec uvicorn. Le drapeau reload=True surveille les changements de fichiers pendant le développement. Le GraphQL Playground est disponible à http://localhost:8000/graphql dans ton navigateur, il fournit l’auto-complétion, la documentation du schéma et un historique de tes requêtes.
🎯 Résultat attendu : Exécuter uv run python -m gql.server démarre un serveur à http://localhost:8000/graphql. Ouvrir cette URL dans un navigateur montre le GraphQL Playground.
🩹 Si ça ne marche pas : Si le port 8000 est déjà utilisé, change le numéro de port. Si le playground ne charge pas, vérifie que strawberry[fastapi] ou la bonne intégration est installée.
5.2 Tester avec curl
# Test a query
curl -X POST http://localhost:8000/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ users { name email } }"}'
# Test with author resolution
curl -X POST http://localhost:8000/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ posts { title author { name } } }"}'
Les tests curl confirment que le serveur fonctionne en dehors du navigateur, utiles pour le scription, la CI et le débogage. La réponse est un objet JSON avec une clé data contenant les résultats de la requête.
🎯 Résultat attendu : Le premier curl renvoie {"data": {"users": [{"name": "Alice", ...}, ...]}}. Le second renvoie les publications avec les noms d’auteurs résolus.
🩹 Si ça ne marche pas : Si curl renvoie une erreur de connexion, le serveur ne tourne pas. Si la réponse a une clé errors, la chaîne de requête ne correspond pas au schéma.
5.3 Vérifier le serveur
✅ Liste de vérification
- ✅
uv run python -m gql.serverdémarre un serveur àhttp://localhost:8000/graphql. - ✅ Le GraphQL Playground se charge dans un navigateur avec auto-complétion et documentation du schéma.
- ✅ Les requêtes
curlrenvoient des réponses JSON correctes avec les champs demandés.
🤔 Question(s) socratique(s)
- L’introspection GraphQL permet aux clients de découvrir tout le schéma en interrogeant
__schema. En production, c’est un risque de sécurité, que ferais-tu pour désactiver l’introspection tout en gardant l’API fonctionnelle ? - Si tu ajoutais une mutation
createPost, comment déclencherais-tu l’abonnementpost_publishedpour que tous les clients connectés voient la nouvelle publication apparaître en temps réel ?
⚠️ Pièges courants
- Le problème de requêtes N+1. Sans DataLoader, la résolution de l’auteur de chaque publication est un appel séparé en base de données. Pour une page montrant 50 publications, c’est 51 requêtes. Utilise toujours DataLoader pour la résolution des relations en GraphQL, c’est le plus gros gain de performance.
- Sur-chargement dans les résolveurs. Tout l’intérêt de GraphQL est que les clients ne demandent que ce dont ils ont besoin. Si ton résolveur charge toute la table de la base de données et la convertit entièrement en types Strawberry, tu as perdu le gain d’efficacité. Filtre et pagine dans le résolveur.
- Abonnements qui gardent les connexions ouvertes. Chaque abonnement maintient une connexion WebSocket. Si tu as des milliers d’abonnés simultanés, tu as besoin de passer à l’échelle horizontalement (pub/sub Redis ou un courtier de messages), un seul serveur ne peut pas tenir efficacement des milliers de connexions persistantes.
- Oublier que les erreurs GraphQL n’arrêtent pas l’exécution. Un résolveur de champ qui lève une exception renvoie
nullpour ce champ plus une erreur dans le tableauerrors, le reste de la requête renvoie quand même les données. C’est différent de REST, où un 500 tue toute la réponse. - Introspection en production. L’introspection GraphQL permet à quiconque de découvrir tout le schéma de ton API. En production, désactive-la sauf si tu construis une API publique.
Ce que tu viens de construire
Un serveur d’API GraphQL complet : un schéma avec des types et des requêtes, des résolveurs qui récupèrent les données depuis un stockage en mémoire, un regroupement DataLoader pour prévenir les requêtes N+1, des abonnements en temps réel pour les mises à jour en direct et un playground interactif pour les tests. L’architecture, conception schéma-en-premier, un résolveur par champ, DataLoader pour le regroupement, est le même modèle utilisé par les serveurs GraphQL de production chez des entreprises comme GitHub, Shopify et Airbnb.
Exécute une version plus complète sans configuration locale
examples/graphql-server/ dans le dépôt du cours a une version plus riche avec des mutations, un backend de base de données réel et le DataLoader câblé de bout en bout. Clone-le, ou ouvre tout le dépôt dans un GitHub Codespace, et lance-le depuis là.
Où aller ensuite
- Ajoute une mutation
createPostqui acceptetitle,bodyetauthorId, stocke la nouvelle publication et déclenche l’abonnementpost_published. - Implémente la pagination avec une pagination de style relay basée sur curseur (arguments
first,after) pour que les grands ensembles de résultats se chargent par morceaux. - Ajoute un middleware d’authentification qui vérifie un jeton Bearer sur chaque requête et expose l’utilisateur courant aux résolveurs via
info.context.
Partage ton projet avec la classe
Tu as construit quelque chose dont tu es fier ? examples/student-projects/ est une galerie de projets soumis par d’autres étudiants, et son README contient un parcours complet et accessible aux débutants pour ajouter le tien via une pull request, même si tu n’as jamais utilisé git auparavant : forker le dépôt, créer une branche, commiter tes fichiers et ouvrir la PR, une étape à la fois. Aucune expérience préalable de git n’est supposée.
Bienvenue dans l’écriture de Python en dehors du navigateur. 🎓
Terminez chaque étape puis marquez ce projet comme terminé pour gagner ses points.