Skip to content

Commit c77d6b1

Browse files
Copilotxadupre
andauthored
Add per-repo CSV cache to github_stat_pr notebook
Agent-Logs-Url: https://github.com/sdpython/teachpyx/sessions/165cfed9-fab7-41fa-8323-206c02efe66c Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com>
1 parent 64aa1d6 commit c77d6b1

1 file changed

Lines changed: 126 additions & 19 deletions

File tree

_doc/practice/years/2026/github_stat_pr.ipynb

Lines changed: 126 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
"pour **un ou plusieurs dépôts**, les regroupe par auteur et par semaine sur l'année écoulée,\n",
1111
"puis affiche le résultat sous forme de graphique.\n",
1212
"\n",
13+
"Les données récupérées sont **mises en cache** localement (un fichier CSV par dépôt).\n",
14+
"Lors des exécutions suivantes, seules les PR plus récentes que la dernière date mise en cache\n",
15+
"sont requêtées, ce qui réduit considérablement le nombre d'appels à l'API.\n",
16+
"\n",
1317
"**Dépendances :** `requests`, `pandas`, `matplotlib`.\n",
1418
"\n",
1519
"**Token GitHub :** l'API GitHub limite les appels non authentifiés à 60 requêtes par heure.\n",
@@ -31,6 +35,7 @@
3135
"source": [
3236
"import os\n",
3337
"import datetime\n",
38+
"import pathlib\n",
3439
"import requests\n",
3540
"import pandas as pd\n",
3641
"import matplotlib.pyplot as plt\n",
@@ -43,8 +48,9 @@
4348
"source": [
4449
"## Paramètres\n",
4550
"\n",
46-
"Modifiez `REPOS` pour lister les dépôts à analyser sous la forme\n",
47-
"`[(owner, repo), ...]`. Vous pouvez ajouter autant de dépôts que vous le souhaitez."
51+
"* `REPOS` — liste de dépôts à analyser sous la forme `[(owner, repo), ...]`.\n",
52+
"* `CACHE_DIR` — répertoire où sont stockés les fichiers CSV de cache (un par dépôt).\n",
53+
" Utilisez `\".\"` pour enregistrer les fichiers à côté du notebook."
4854
]
4955
},
5056
{
@@ -58,6 +64,9 @@
5864
" # (\"sdpython\", \"onnx-extended\"), # ajoutez d'autres dépôts ici\n",
5965
"]\n",
6066
"\n",
67+
"# Répertoire de cache (créé automatiquement si nécessaire)\n",
68+
"CACHE_DIR = pathlib.Path(\".\")\n",
69+
"\n",
6170
"# Jeton d'authentification GitHub (optionnel mais recommandé)\n",
6271
"GITHUB_TOKEN = os.environ.get(\"GITHUB_TOKEN\", \"\")"
6372
]
@@ -66,14 +75,17 @@
6675
"cell_type": "markdown",
6776
"metadata": {},
6877
"source": [
69-
"## Récupération des PR fusionnées via l'API GitHub\n",
78+
"## Récupération des PR fusionnées via l'API GitHub (avec cache)\n",
7079
"\n",
71-
"L'API REST GitHub expose le point d'accès `/repos/{owner}/{repo}/pulls`\n",
72-
"avec `state=closed`. On filtre ensuite les PR dont le champ `merged_at` est renseigné\n",
73-
"et dont la date de fusion est dans les 12 derniers mois.\n",
80+
"Pour chaque dépôt :\n",
7481
"\n",
75-
"La pagination est gérée via le paramètre `page`.\n",
76-
"La boucle principale itère sur chaque dépôt listé dans `REPOS`."
82+
"1. On charge le fichier CSV de cache s'il existe (`prs_cache_{owner}_{repo}.csv`).\n",
83+
"2. On détermine la date la plus récente déjà présente dans le cache.\n",
84+
"3. On ne récupère auprès de l'API que les PR fusionnées **après** cette date\n",
85+
" (ou toutes si le cache est vide).\n",
86+
"4. On fusionne les nouvelles PR avec le cache, on supprime les doublons\n",
87+
" et on élague les entrées datant de plus de 365 jours.\n",
88+
"5. On sauvegarde le cache mis à jour sur disque."
7789
]
7890
},
7991
{
@@ -82,19 +94,63 @@
8294
"metadata": {},
8395
"outputs": [],
8496
"source": [
85-
"def fetch_merged_prs(owner: str, repo: str, token: str = \"\") -> list[dict]:\n",
86-
" \"\"\"Récupère toutes les PR fusionnées au cours de l'année écoulée pour un dépôt.\n",
97+
"CACHE_DATE_FMT = \"%Y-%m-%dT%H:%M:%S%z\"\n",
98+
"\n",
99+
"\n",
100+
"def _cache_path(cache_dir: pathlib.Path, owner: str, repo: str) -> pathlib.Path:\n",
101+
" \"\"\"Retourne le chemin du fichier CSV de cache pour un dépôt.\"\"\"\n",
102+
" safe = f\"{owner}_{repo}\".replace(\"/\", \"_\")\n",
103+
" return cache_dir / f\"prs_cache_{safe}.csv\"\n",
104+
"\n",
105+
"\n",
106+
"def load_cache(\n",
107+
" cache_dir: pathlib.Path, owner: str, repo: str\n",
108+
") -> pd.DataFrame:\n",
109+
" \"\"\"Charge le cache CSV pour un dépôt (retourne un DataFrame vide si absent).\"\"\"\n",
110+
" path = _cache_path(cache_dir, owner, repo)\n",
111+
" if not path.exists():\n",
112+
" return pd.DataFrame(columns=[\"author\", \"merged_at\", \"repo\"])\n",
113+
" df = pd.read_csv(path, parse_dates=[\"merged_at\"])\n",
114+
" # S'assurer que la colonne est bien tz-aware (UTC)\n",
115+
" if df[\"merged_at\"].dt.tz is None:\n",
116+
" df[\"merged_at\"] = df[\"merged_at\"].dt.tz_localize(\"UTC\")\n",
117+
" else:\n",
118+
" df[\"merged_at\"] = df[\"merged_at\"].dt.tz_convert(\"UTC\")\n",
119+
" return df\n",
120+
"\n",
121+
"\n",
122+
"def save_cache(\n",
123+
" cache_dir: pathlib.Path, owner: str, repo: str, df: pd.DataFrame\n",
124+
") -> None:\n",
125+
" \"\"\"Sauvegarde le DataFrame dans le fichier CSV de cache.\"\"\"\n",
126+
" cache_dir.mkdir(parents=True, exist_ok=True)\n",
127+
" path = _cache_path(cache_dir, owner, repo)\n",
128+
" df.to_csv(path, index=False, date_format=CACHE_DATE_FMT)\n",
129+
"\n",
130+
"\n",
131+
"def fetch_merged_prs(\n",
132+
" owner: str,\n",
133+
" repo: str,\n",
134+
" token: str = \"\",\n",
135+
" fetch_since: datetime.datetime | None = None,\n",
136+
") -> list[dict]:\n",
137+
" \"\"\"Récupère les PR fusionnées pour un dépôt à partir d'une date donnée.\n",
87138
"\n",
88139
" :param owner: propriétaire du dépôt GitHub\n",
89140
" :param repo: nom du dépôt GitHub\n",
90141
" :param token: jeton d'authentification GitHub (optionnel)\n",
91-
" :return: liste de dictionnaires avec les champs ``author``, ``merged_at``, ``repo``\n",
142+
" :param fetch_since: si fourni, on s'arrête dès que ``merged_at`` est antérieur\n",
143+
" à cette date (les PR plus anciennes sont déjà en cache).\n",
144+
" Si ``None``, on remonte jusqu'à 365 jours en arrière.\n",
145+
" :return: liste de dictionnaires ``{author, merged_at, repo}``\n",
92146
" \"\"\"\n",
93147
" headers = {\"Accept\": \"application/vnd.github+json\"}\n",
94148
" if token:\n",
95149
" headers[\"Authorization\"] = f\"Bearer {token}\"\n",
96150
"\n",
97-
" since = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365)\n",
151+
" cutoff = fetch_since if fetch_since is not None else (\n",
152+
" datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365)\n",
153+
" )\n",
98154
"\n",
99155
" results = []\n",
100156
" page = 1\n",
@@ -135,7 +191,7 @@
135191
" if not merged_at:\n",
136192
" continue\n",
137193
" merged_dt = datetime.datetime.fromisoformat(merged_at.replace(\"Z\", \"+00:00\"))\n",
138-
" if merged_dt < since:\n",
194+
" if merged_dt <= cutoff:\n",
139195
" stop = True\n",
140196
" break\n",
141197
" author = (pr.get(\"user\") or {}).get(\"login\", \"unknown\")\n",
@@ -149,13 +205,64 @@
149205
" return results\n",
150206
"\n",
151207
"\n",
152-
"merged_prs = []\n",
208+
"def load_prs_with_cache(\n",
209+
" owner: str, repo: str, token: str = \"\", cache_dir: pathlib.Path = pathlib.Path(\".\")\n",
210+
") -> pd.DataFrame:\n",
211+
" \"\"\"Charge les PR fusionnées pour un dépôt en utilisant le cache local.\n",
212+
"\n",
213+
" * Si le cache existe, seules les PR plus récentes que la dernière entrée\n",
214+
" mise en cache sont récupérées via l'API.\n",
215+
" * Le cache est élagué pour ne conserver que les 365 derniers jours.\n",
216+
" * Le cache mis à jour est sauvegardé sur disque.\n",
217+
"\n",
218+
" :return: DataFrame avec les colonnes ``author``, ``merged_at``, ``repo``\n",
219+
" \"\"\"\n",
220+
" now = datetime.datetime.now(datetime.timezone.utc)\n",
221+
" cutoff_365 = now - datetime.timedelta(days=365)\n",
222+
"\n",
223+
" cached_df = load_cache(cache_dir, owner, repo)\n",
224+
"\n",
225+
" if cached_df.empty:\n",
226+
" fetch_since = None # récupérer toute l'année\n",
227+
" print(f\" {owner}/{repo} : cache vide, récupération complète…\")\n",
228+
" else:\n",
229+
" # Relancer depuis le début de la journée du dernier enregistrement\n",
230+
" # pour ne pas manquer de PR fusionnées en cours de journée.\n",
231+
" latest = cached_df[\"merged_at\"].max()\n",
232+
" fetch_since = latest.replace(hour=0, minute=0, second=0, microsecond=0)\n",
233+
" print(\n",
234+
" f\" {owner}/{repo} : cache chargé ({len(cached_df)} entrées), \"\n",
235+
" f\"récupération des PR depuis {fetch_since.date()}…\"\n",
236+
" )\n",
237+
"\n",
238+
" new_prs = fetch_merged_prs(owner, repo, token, fetch_since=fetch_since)\n",
239+
" print(f\" → {len(new_prs)} nouvelle(s) PR(s) récupérée(s) via l'API.\")\n",
240+
"\n",
241+
" if new_prs:\n",
242+
" new_df = pd.DataFrame(new_prs)\n",
243+
" combined = pd.concat([cached_df, new_df], ignore_index=True)\n",
244+
" else:\n",
245+
" combined = cached_df.copy()\n",
246+
"\n",
247+
" # Dédoublonnage et élagage\n",
248+
" combined.drop_duplicates(subset=[\"repo\", \"author\", \"merged_at\"], inplace=True)\n",
249+
" combined = combined[combined[\"merged_at\"] >= cutoff_365].copy()\n",
250+
" combined.sort_values(\"merged_at\", inplace=True)\n",
251+
" combined.reset_index(drop=True, inplace=True)\n",
252+
"\n",
253+
" save_cache(cache_dir, owner, repo, combined)\n",
254+
" print(f\" → cache mis à jour ({len(combined)} entrées au total).\")\n",
255+
"\n",
256+
" return combined\n",
257+
"\n",
258+
"\n",
259+
"merged_prs_frames = []\n",
153260
"for owner, repo in REPOS:\n",
154-
" prs = fetch_merged_prs(owner, repo, GITHUB_TOKEN)\n",
155-
" print(f\" {owner}/{repo} : {len(prs)} PR(s) fusionnée(s)\")\n",
156-
" merged_prs.extend(prs)\n",
261+
" repo_df = load_prs_with_cache(owner, repo, GITHUB_TOKEN, CACHE_DIR)\n",
262+
" merged_prs_frames.append(repo_df)\n",
157263
"\n",
158-
"print(f\"Total : {len(merged_prs)} PR(s) fusionnée(s) sur l'ensemble des dépôts.\")"
264+
"merged_prs = pd.concat(merged_prs_frames, ignore_index=True) if merged_prs_frames else pd.DataFrame()\n",
265+
"print(f\"\\nTotal : {len(merged_prs)} PR(s) fusionnée(s) sur l'ensemble des dépôts.\")"
159266
]
160267
},
161268
{
@@ -171,7 +278,7 @@
171278
"metadata": {},
172279
"outputs": [],
173280
"source": [
174-
"df = pd.DataFrame(merged_prs)\n",
281+
"df = merged_prs.copy() if not merged_prs.empty else pd.DataFrame(columns=[\"author\", \"merged_at\", \"repo\"])\n",
175282
"\n",
176283
"if df.empty:\n",
177284
" print(\"Aucune donnée à afficher.\")\n",

0 commit comments

Comments
 (0)