From 6222fbbf3a8b2e8d72e7cc9ccd3e1dceea7def64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:41:44 +0000 Subject: [PATCH 01/14] Initial plan From 26a2b5665c722deffb648816c9229e956c8fbcf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:47:03 +0000 Subject: [PATCH 02/14] Add notebook: GitHub merged PR stats by person, aggregated by week Agent-Logs-Url: https://github.com/sdpython/teachpyx/sessions/452c41d6-d398-4a66-b857-558224a8b8a7 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --- _doc/practice/years/2026/github_stat_pr.ipynb | 293 ++++++++++++++++++ _doc/practice/years/2026/index.rst | 1 + 2 files changed, 294 insertions(+) create mode 100644 _doc/practice/years/2026/github_stat_pr.ipynb diff --git a/_doc/practice/years/2026/github_stat_pr.ipynb b/_doc/practice/years/2026/github_stat_pr.ipynb new file mode 100644 index 0000000..f6b1f00 --- /dev/null +++ b/_doc/practice/years/2026/github_stat_pr.ipynb @@ -0,0 +1,293 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Nombre de PR fusionnées par personne agrégées par semaine\n", + "\n", + "Ce notebook récupère, via l'API GitHub, le nombre de *pull requests* (PR) fusionnées\n", + "pour un dépôt donné, les regroupe par auteur et par semaine sur l'année écoulée,\n", + "puis affiche le résultat sous forme de graphique.\n", + "\n", + "**Dépendances :** `requests`, `pandas`, `matplotlib`.\n", + "\n", + "**Token GitHub :** l'API GitHub limite les appels non authentifiés à 60 requêtes par heure.\n", + "Pour lever cette limite, définissez la variable d'environnement `GITHUB_TOKEN`\n", + "avec un *Personal Access Token* (PAT) GitHub :\n", + "\n", + "```bash\n", + "export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx\n", + "```\n", + "\n", + "Sans token, le notebook fonctionne mais peut être limité sur de grands dépôts." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import datetime\n", + "import requests\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import matplotlib.dates as mdates" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Paramètres\n", + "\n", + "Modifiez `OWNER` et `REPO` pour pointer vers le dépôt de votre choix." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "OWNER = \"sdpython\"\n", + "REPO = \"teachpyx\"\n", + "\n", + "# Jeton d'authentification GitHub (optionnel mais recommandé)\n", + "GITHUB_TOKEN = os.environ.get(\"GITHUB_TOKEN\", \"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Récupération des PR fusionnées via l'API GitHub\n", + "\n", + "L'API REST GitHub expose le point d'accès `/repos/{owner}/{repo}/pulls`\n", + "avec `state=closed`. On filtre ensuite les PR dont le champ `merged_at` est renseigné\n", + "et dont la date de fusion est dans les 12 derniers mois.\n", + "\n", + "La pagination est gérée via le paramètre `page`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def fetch_merged_prs(owner: str, repo: str, token: str = \"\") -> list[dict]:\n", + " \"\"\"Récupère toutes les PR fusionnées au cours de l'année écoulée.\n", + "\n", + " :param owner: propriétaire du dépôt GitHub\n", + " :param repo: nom du dépôt GitHub\n", + " :param token: jeton d'authentification GitHub (optionnel)\n", + " :return: liste de dictionnaires avec les champs ``author``, ``merged_at``\n", + " \"\"\"\n", + " headers = {\"Accept\": \"application/vnd.github+json\"}\n", + " if token:\n", + " headers[\"Authorization\"] = f\"Bearer {token}\"\n", + "\n", + " since = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365)\n", + "\n", + " results = []\n", + " page = 1\n", + " per_page = 100\n", + "\n", + " while True:\n", + " url = (\n", + " f\"https://api.github.com/repos/{owner}/{repo}/pulls\"\n", + " f\"?state=closed&per_page={per_page}&page={page}&sort=updated&direction=desc\"\n", + " )\n", + " response = requests.get(url, headers=headers, timeout=30)\n", + " try:\n", + " response.raise_for_status()\n", + " except requests.HTTPError as exc:\n", + " status = exc.response.status_code\n", + " if status == 401:\n", + " raise RuntimeError(\n", + " \"Authentification refusée (401). Vérifiez votre GITHUB_TOKEN.\"\n", + " ) from exc\n", + " if status == 403:\n", + " raise RuntimeError(\n", + " \"Accès refusé (403). Vous avez peut-être atteint la limite de l'API \"\n", + " \"GitHub (60 requêtes/h sans token). Définissez GITHUB_TOKEN.\"\n", + " ) from exc\n", + " if status == 404:\n", + " raise RuntimeError(\n", + " f\"Dépôt introuvable (404) : {owner}/{repo}. Vérifiez OWNER et REPO.\"\n", + " ) from exc\n", + " raise\n", + " prs = response.json()\n", + "\n", + " if not prs:\n", + " break\n", + "\n", + " stop = False\n", + " for pr in prs:\n", + " merged_at = pr.get(\"merged_at\")\n", + " if not merged_at:\n", + " continue\n", + " merged_dt = datetime.datetime.fromisoformat(merged_at.replace(\"Z\", \"+00:00\"))\n", + " if merged_dt < since:\n", + " stop = True\n", + " break\n", + " author = (pr.get(\"user\") or {}).get(\"login\", \"unknown\")\n", + " results.append({\"author\": author, \"merged_at\": merged_dt})\n", + "\n", + " if stop:\n", + " break\n", + "\n", + " page += 1\n", + "\n", + " return results\n", + "\n", + "\n", + "merged_prs = fetch_merged_prs(OWNER, REPO, GITHUB_TOKEN)\n", + "print(f\"{len(merged_prs)} PR(s) fusionnée(s) trouvée(s) au cours de l'année écoulée.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Agrégation par auteur et par semaine" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "df = pd.DataFrame(merged_prs)\n", + "\n", + "if df.empty:\n", + " print(\"Aucune donnée à afficher.\")\n", + "else:\n", + " # Tronque la date au lundi de la semaine\n", + " df[\"week\"] = df[\"merged_at\"].dt.to_period(\"W\").dt.start_time\n", + "\n", + " weekly = (\n", + " df.groupby([\"author\", \"week\"])\n", + " .size()\n", + " .reset_index(name=\"pr_count\")\n", + " )\n", + " print(weekly.head(10))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tableau croisé (auteur × semaine)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if not df.empty:\n", + " pivot = weekly.pivot_table(\n", + " index=\"author\", columns=\"week\", values=\"pr_count\", aggfunc=\"sum\", fill_value=0\n", + " )\n", + " # Tri par nombre total de PR décroissant\n", + " pivot = pivot.loc[pivot.sum(axis=1).sort_values(ascending=False).index]\n", + " pivot" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Visualisation : nombre de PR fusionnées par semaine (empilé par auteur)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if not df.empty:\n", + " fig, ax = plt.subplots(figsize=(14, 5))\n", + "\n", + " stacked_height = None\n", + " weeks = pivot.columns # DatetimeIndex\n", + " week_nums = mdates.date2num(weeks.to_pydatetime())\n", + "\n", + " for author in pivot.index:\n", + " values = pivot.loc[author].values\n", + " if stacked_height is None:\n", + " ax.bar(week_nums, values, width=5, label=author)\n", + " stacked_height = values.copy()\n", + " else:\n", + " ax.bar(week_nums, values, width=5, bottom=stacked_height, label=author)\n", + " stacked_height += values\n", + "\n", + " ax.xaxis.set_major_formatter(mdates.DateFormatter(\"%Y-%m-%d\"))\n", + " ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO, interval=4))\n", + " plt.xticks(rotation=45, ha=\"right\")\n", + " ax.set_xlabel(\"Semaine\")\n", + " ax.set_ylabel(\"Nombre de PR fusionnées\")\n", + " ax.set_title(f\"PR fusionnées par semaine — {OWNER}/{REPO}\")\n", + " ax.legend(loc=\"upper left\", bbox_to_anchor=(1, 1), title=\"Auteur\")\n", + " plt.tight_layout()\n", + " plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Visualisation : carte de chaleur (heatmap auteur × semaine)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if not df.empty:\n", + " fig, ax = plt.subplots(figsize=(14, max(3, len(pivot) * 0.5)))\n", + "\n", + " im = ax.imshow(pivot.values, aspect=\"auto\", cmap=\"YlOrRd\")\n", + " plt.colorbar(im, ax=ax, label=\"Nombre de PR\")\n", + "\n", + " ax.set_yticks(range(len(pivot.index)))\n", + " ax.set_yticklabels(pivot.index)\n", + "\n", + " # Affiche une étiquette de semaine sur 4\n", + " step = max(1, len(pivot.columns) // 12)\n", + " ax.set_xticks(range(0, len(pivot.columns), step))\n", + " ax.set_xticklabels(\n", + " [str(d)[:10] for d in pivot.columns[::step]], rotation=45, ha=\"right\"\n", + " )\n", + "\n", + " ax.set_title(f\"Heatmap des PR fusionnées — {OWNER}/{REPO}\")\n", + " ax.set_xlabel(\"Semaine\")\n", + " ax.set_ylabel(\"Auteur\")\n", + " plt.tight_layout()\n", + " plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/_doc/practice/years/2026/index.rst b/_doc/practice/years/2026/index.rst index 2274725..5a38bae 100644 --- a/_doc/practice/years/2026/index.rst +++ b/_doc/practice/years/2026/index.rst @@ -8,3 +8,4 @@ :caption: machine learning parcoursup_2026 + github_stat_pr From 64aa1d6ebdcdd7461e33630e25c2faf36fb11d82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:54:19 +0000 Subject: [PATCH 03/14] Support a list of repos in github_stat_pr notebook Agent-Logs-Url: https://github.com/sdpython/teachpyx/sessions/a014fc92-18dd-45ab-bfde-5ceac46f5764 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --- _doc/practice/years/2026/github_stat_pr.ipynb | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/_doc/practice/years/2026/github_stat_pr.ipynb b/_doc/practice/years/2026/github_stat_pr.ipynb index f6b1f00..fbb88d5 100644 --- a/_doc/practice/years/2026/github_stat_pr.ipynb +++ b/_doc/practice/years/2026/github_stat_pr.ipynb @@ -7,7 +7,7 @@ "# Nombre de PR fusionnées par personne agrégées par semaine\n", "\n", "Ce notebook récupère, via l'API GitHub, le nombre de *pull requests* (PR) fusionnées\n", - "pour un dépôt donné, les regroupe par auteur et par semaine sur l'année écoulée,\n", + "pour **un ou plusieurs dépôts**, les regroupe par auteur et par semaine sur l'année écoulée,\n", "puis affiche le résultat sous forme de graphique.\n", "\n", "**Dépendances :** `requests`, `pandas`, `matplotlib`.\n", @@ -43,7 +43,8 @@ "source": [ "## Paramètres\n", "\n", - "Modifiez `OWNER` et `REPO` pour pointer vers le dépôt de votre choix." + "Modifiez `REPOS` pour lister les dépôts à analyser sous la forme\n", + "`[(owner, repo), ...]`. Vous pouvez ajouter autant de dépôts que vous le souhaitez." ] }, { @@ -52,8 +53,10 @@ "metadata": {}, "outputs": [], "source": [ - "OWNER = \"sdpython\"\n", - "REPO = \"teachpyx\"\n", + "REPOS = [\n", + " (\"sdpython\", \"teachpyx\"),\n", + " # (\"sdpython\", \"onnx-extended\"), # ajoutez d'autres dépôts ici\n", + "]\n", "\n", "# Jeton d'authentification GitHub (optionnel mais recommandé)\n", "GITHUB_TOKEN = os.environ.get(\"GITHUB_TOKEN\", \"\")" @@ -69,7 +72,8 @@ "avec `state=closed`. On filtre ensuite les PR dont le champ `merged_at` est renseigné\n", "et dont la date de fusion est dans les 12 derniers mois.\n", "\n", - "La pagination est gérée via le paramètre `page`." + "La pagination est gérée via le paramètre `page`.\n", + "La boucle principale itère sur chaque dépôt listé dans `REPOS`." ] }, { @@ -79,12 +83,12 @@ "outputs": [], "source": [ "def fetch_merged_prs(owner: str, repo: str, token: str = \"\") -> list[dict]:\n", - " \"\"\"Récupère toutes les PR fusionnées au cours de l'année écoulée.\n", + " \"\"\"Récupère toutes les PR fusionnées au cours de l'année écoulée pour un dépôt.\n", "\n", " :param owner: propriétaire du dépôt GitHub\n", " :param repo: nom du dépôt GitHub\n", " :param token: jeton d'authentification GitHub (optionnel)\n", - " :return: liste de dictionnaires avec les champs ``author``, ``merged_at``\n", + " :return: liste de dictionnaires avec les champs ``author``, ``merged_at``, ``repo``\n", " \"\"\"\n", " headers = {\"Accept\": \"application/vnd.github+json\"}\n", " if token:\n", @@ -135,7 +139,7 @@ " stop = True\n", " break\n", " author = (pr.get(\"user\") or {}).get(\"login\", \"unknown\")\n", - " results.append({\"author\": author, \"merged_at\": merged_dt})\n", + " results.append({\"author\": author, \"merged_at\": merged_dt, \"repo\": f\"{owner}/{repo}\"})\n", "\n", " if stop:\n", " break\n", @@ -145,8 +149,13 @@ " return results\n", "\n", "\n", - "merged_prs = fetch_merged_prs(OWNER, REPO, GITHUB_TOKEN)\n", - "print(f\"{len(merged_prs)} PR(s) fusionnée(s) trouvée(s) au cours de l'année écoulée.\")" + "merged_prs = []\n", + "for owner, repo in REPOS:\n", + " prs = fetch_merged_prs(owner, repo, GITHUB_TOKEN)\n", + " print(f\" {owner}/{repo} : {len(prs)} PR(s) fusionnée(s)\")\n", + " merged_prs.extend(prs)\n", + "\n", + "print(f\"Total : {len(merged_prs)} PR(s) fusionnée(s) sur l'ensemble des dépôts.\")" ] }, { @@ -171,7 +180,7 @@ " df[\"week\"] = df[\"merged_at\"].dt.to_period(\"W\").dt.start_time\n", "\n", " weekly = (\n", - " df.groupby([\"author\", \"week\"])\n", + " df.groupby([\"repo\", \"author\", \"week\"])\n", " .size()\n", " .reset_index(name=\"pr_count\")\n", " )\n", @@ -182,7 +191,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Tableau croisé (auteur × semaine)" + "## Tableau croisé (auteur × semaine, agrégé sur tous les dépôts)" ] }, { @@ -192,6 +201,7 @@ "outputs": [], "source": [ "if not df.empty:\n", + " # Agrégation sur tous les dépôts\n", " pivot = weekly.pivot_table(\n", " index=\"author\", columns=\"week\", values=\"pr_count\", aggfunc=\"sum\", fill_value=0\n", " )\n", @@ -200,6 +210,29 @@ " pivot" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tableau croisé par dépôt" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if not df.empty and len(REPOS) > 1:\n", + " for repo_name, grp in weekly.groupby(\"repo\"):\n", + " pvt = grp.pivot_table(\n", + " index=\"author\", columns=\"week\", values=\"pr_count\", aggfunc=\"sum\", fill_value=0\n", + " )\n", + " pvt = pvt.loc[pvt.sum(axis=1).sort_values(ascending=False).index]\n", + " print(f\"\\n=== {repo_name} ===\")\n", + " display(pvt)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -234,7 +267,7 @@ " plt.xticks(rotation=45, ha=\"right\")\n", " ax.set_xlabel(\"Semaine\")\n", " ax.set_ylabel(\"Nombre de PR fusionnées\")\n", - " ax.set_title(f\"PR fusionnées par semaine — {OWNER}/{REPO}\")\n", + " repos_label = \", \".join(f\"{o}/{r}\" for o, r in REPOS)\n ax.set_title(f\"PR fusionnées par semaine — {repos_label}\")\n", " ax.legend(loc=\"upper left\", bbox_to_anchor=(1, 1), title=\"Auteur\")\n", " plt.tight_layout()\n", " plt.show()" @@ -269,7 +302,7 @@ " [str(d)[:10] for d in pivot.columns[::step]], rotation=45, ha=\"right\"\n", " )\n", "\n", - " ax.set_title(f\"Heatmap des PR fusionnées — {OWNER}/{REPO}\")\n", + " repos_label = \", \".join(f\"{o}/{r}\" for o, r in REPOS)\n ax.set_title(f\"Heatmap des PR fusionnées — {repos_label}\")\n", " ax.set_xlabel(\"Semaine\")\n", " ax.set_ylabel(\"Auteur\")\n", " plt.tight_layout()\n", From c77d6b18812459b860572f3d2e5bd7c597155698 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:05:07 +0000 Subject: [PATCH 04/14] 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> --- _doc/practice/years/2026/github_stat_pr.ipynb | 145 +++++++++++++++--- 1 file changed, 126 insertions(+), 19 deletions(-) diff --git a/_doc/practice/years/2026/github_stat_pr.ipynb b/_doc/practice/years/2026/github_stat_pr.ipynb index fbb88d5..ab0ec44 100644 --- a/_doc/practice/years/2026/github_stat_pr.ipynb +++ b/_doc/practice/years/2026/github_stat_pr.ipynb @@ -10,6 +10,10 @@ "pour **un ou plusieurs dépôts**, les regroupe par auteur et par semaine sur l'année écoulée,\n", "puis affiche le résultat sous forme de graphique.\n", "\n", + "Les données récupérées sont **mises en cache** localement (un fichier CSV par dépôt).\n", + "Lors des exécutions suivantes, seules les PR plus récentes que la dernière date mise en cache\n", + "sont requêtées, ce qui réduit considérablement le nombre d'appels à l'API.\n", + "\n", "**Dépendances :** `requests`, `pandas`, `matplotlib`.\n", "\n", "**Token GitHub :** l'API GitHub limite les appels non authentifiés à 60 requêtes par heure.\n", @@ -31,6 +35,7 @@ "source": [ "import os\n", "import datetime\n", + "import pathlib\n", "import requests\n", "import pandas as pd\n", "import matplotlib.pyplot as plt\n", @@ -43,8 +48,9 @@ "source": [ "## Paramètres\n", "\n", - "Modifiez `REPOS` pour lister les dépôts à analyser sous la forme\n", - "`[(owner, repo), ...]`. Vous pouvez ajouter autant de dépôts que vous le souhaitez." + "* `REPOS` — liste de dépôts à analyser sous la forme `[(owner, repo), ...]`.\n", + "* `CACHE_DIR` — répertoire où sont stockés les fichiers CSV de cache (un par dépôt).\n", + " Utilisez `\".\"` pour enregistrer les fichiers à côté du notebook." ] }, { @@ -58,6 +64,9 @@ " # (\"sdpython\", \"onnx-extended\"), # ajoutez d'autres dépôts ici\n", "]\n", "\n", + "# Répertoire de cache (créé automatiquement si nécessaire)\n", + "CACHE_DIR = pathlib.Path(\".\")\n", + "\n", "# Jeton d'authentification GitHub (optionnel mais recommandé)\n", "GITHUB_TOKEN = os.environ.get(\"GITHUB_TOKEN\", \"\")" ] @@ -66,14 +75,17 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Récupération des PR fusionnées via l'API GitHub\n", + "## Récupération des PR fusionnées via l'API GitHub (avec cache)\n", "\n", - "L'API REST GitHub expose le point d'accès `/repos/{owner}/{repo}/pulls`\n", - "avec `state=closed`. On filtre ensuite les PR dont le champ `merged_at` est renseigné\n", - "et dont la date de fusion est dans les 12 derniers mois.\n", + "Pour chaque dépôt :\n", "\n", - "La pagination est gérée via le paramètre `page`.\n", - "La boucle principale itère sur chaque dépôt listé dans `REPOS`." + "1. On charge le fichier CSV de cache s'il existe (`prs_cache_{owner}_{repo}.csv`).\n", + "2. On détermine la date la plus récente déjà présente dans le cache.\n", + "3. On ne récupère auprès de l'API que les PR fusionnées **après** cette date\n", + " (ou toutes si le cache est vide).\n", + "4. On fusionne les nouvelles PR avec le cache, on supprime les doublons\n", + " et on élague les entrées datant de plus de 365 jours.\n", + "5. On sauvegarde le cache mis à jour sur disque." ] }, { @@ -82,19 +94,63 @@ "metadata": {}, "outputs": [], "source": [ - "def fetch_merged_prs(owner: str, repo: str, token: str = \"\") -> list[dict]:\n", - " \"\"\"Récupère toutes les PR fusionnées au cours de l'année écoulée pour un dépôt.\n", + "CACHE_DATE_FMT = \"%Y-%m-%dT%H:%M:%S%z\"\n", + "\n", + "\n", + "def _cache_path(cache_dir: pathlib.Path, owner: str, repo: str) -> pathlib.Path:\n", + " \"\"\"Retourne le chemin du fichier CSV de cache pour un dépôt.\"\"\"\n", + " safe = f\"{owner}_{repo}\".replace(\"/\", \"_\")\n", + " return cache_dir / f\"prs_cache_{safe}.csv\"\n", + "\n", + "\n", + "def load_cache(\n", + " cache_dir: pathlib.Path, owner: str, repo: str\n", + ") -> pd.DataFrame:\n", + " \"\"\"Charge le cache CSV pour un dépôt (retourne un DataFrame vide si absent).\"\"\"\n", + " path = _cache_path(cache_dir, owner, repo)\n", + " if not path.exists():\n", + " return pd.DataFrame(columns=[\"author\", \"merged_at\", \"repo\"])\n", + " df = pd.read_csv(path, parse_dates=[\"merged_at\"])\n", + " # S'assurer que la colonne est bien tz-aware (UTC)\n", + " if df[\"merged_at\"].dt.tz is None:\n", + " df[\"merged_at\"] = df[\"merged_at\"].dt.tz_localize(\"UTC\")\n", + " else:\n", + " df[\"merged_at\"] = df[\"merged_at\"].dt.tz_convert(\"UTC\")\n", + " return df\n", + "\n", + "\n", + "def save_cache(\n", + " cache_dir: pathlib.Path, owner: str, repo: str, df: pd.DataFrame\n", + ") -> None:\n", + " \"\"\"Sauvegarde le DataFrame dans le fichier CSV de cache.\"\"\"\n", + " cache_dir.mkdir(parents=True, exist_ok=True)\n", + " path = _cache_path(cache_dir, owner, repo)\n", + " df.to_csv(path, index=False, date_format=CACHE_DATE_FMT)\n", + "\n", + "\n", + "def fetch_merged_prs(\n", + " owner: str,\n", + " repo: str,\n", + " token: str = \"\",\n", + " fetch_since: datetime.datetime | None = None,\n", + ") -> list[dict]:\n", + " \"\"\"Récupère les PR fusionnées pour un dépôt à partir d'une date donnée.\n", "\n", " :param owner: propriétaire du dépôt GitHub\n", " :param repo: nom du dépôt GitHub\n", " :param token: jeton d'authentification GitHub (optionnel)\n", - " :return: liste de dictionnaires avec les champs ``author``, ``merged_at``, ``repo``\n", + " :param fetch_since: si fourni, on s'arrête dès que ``merged_at`` est antérieur\n", + " à cette date (les PR plus anciennes sont déjà en cache).\n", + " Si ``None``, on remonte jusqu'à 365 jours en arrière.\n", + " :return: liste de dictionnaires ``{author, merged_at, repo}``\n", " \"\"\"\n", " headers = {\"Accept\": \"application/vnd.github+json\"}\n", " if token:\n", " headers[\"Authorization\"] = f\"Bearer {token}\"\n", "\n", - " since = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365)\n", + " cutoff = fetch_since if fetch_since is not None else (\n", + " datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365)\n", + " )\n", "\n", " results = []\n", " page = 1\n", @@ -135,7 +191,7 @@ " if not merged_at:\n", " continue\n", " merged_dt = datetime.datetime.fromisoformat(merged_at.replace(\"Z\", \"+00:00\"))\n", - " if merged_dt < since:\n", + " if merged_dt <= cutoff:\n", " stop = True\n", " break\n", " author = (pr.get(\"user\") or {}).get(\"login\", \"unknown\")\n", @@ -149,13 +205,64 @@ " return results\n", "\n", "\n", - "merged_prs = []\n", + "def load_prs_with_cache(\n", + " owner: str, repo: str, token: str = \"\", cache_dir: pathlib.Path = pathlib.Path(\".\")\n", + ") -> pd.DataFrame:\n", + " \"\"\"Charge les PR fusionnées pour un dépôt en utilisant le cache local.\n", + "\n", + " * Si le cache existe, seules les PR plus récentes que la dernière entrée\n", + " mise en cache sont récupérées via l'API.\n", + " * Le cache est élagué pour ne conserver que les 365 derniers jours.\n", + " * Le cache mis à jour est sauvegardé sur disque.\n", + "\n", + " :return: DataFrame avec les colonnes ``author``, ``merged_at``, ``repo``\n", + " \"\"\"\n", + " now = datetime.datetime.now(datetime.timezone.utc)\n", + " cutoff_365 = now - datetime.timedelta(days=365)\n", + "\n", + " cached_df = load_cache(cache_dir, owner, repo)\n", + "\n", + " if cached_df.empty:\n", + " fetch_since = None # récupérer toute l'année\n", + " print(f\" {owner}/{repo} : cache vide, récupération complète…\")\n", + " else:\n", + " # Relancer depuis le début de la journée du dernier enregistrement\n", + " # pour ne pas manquer de PR fusionnées en cours de journée.\n", + " latest = cached_df[\"merged_at\"].max()\n", + " fetch_since = latest.replace(hour=0, minute=0, second=0, microsecond=0)\n", + " print(\n", + " f\" {owner}/{repo} : cache chargé ({len(cached_df)} entrées), \"\n", + " f\"récupération des PR depuis {fetch_since.date()}…\"\n", + " )\n", + "\n", + " new_prs = fetch_merged_prs(owner, repo, token, fetch_since=fetch_since)\n", + " print(f\" → {len(new_prs)} nouvelle(s) PR(s) récupérée(s) via l'API.\")\n", + "\n", + " if new_prs:\n", + " new_df = pd.DataFrame(new_prs)\n", + " combined = pd.concat([cached_df, new_df], ignore_index=True)\n", + " else:\n", + " combined = cached_df.copy()\n", + "\n", + " # Dédoublonnage et élagage\n", + " combined.drop_duplicates(subset=[\"repo\", \"author\", \"merged_at\"], inplace=True)\n", + " combined = combined[combined[\"merged_at\"] >= cutoff_365].copy()\n", + " combined.sort_values(\"merged_at\", inplace=True)\n", + " combined.reset_index(drop=True, inplace=True)\n", + "\n", + " save_cache(cache_dir, owner, repo, combined)\n", + " print(f\" → cache mis à jour ({len(combined)} entrées au total).\")\n", + "\n", + " return combined\n", + "\n", + "\n", + "merged_prs_frames = []\n", "for owner, repo in REPOS:\n", - " prs = fetch_merged_prs(owner, repo, GITHUB_TOKEN)\n", - " print(f\" {owner}/{repo} : {len(prs)} PR(s) fusionnée(s)\")\n", - " merged_prs.extend(prs)\n", + " repo_df = load_prs_with_cache(owner, repo, GITHUB_TOKEN, CACHE_DIR)\n", + " merged_prs_frames.append(repo_df)\n", "\n", - "print(f\"Total : {len(merged_prs)} PR(s) fusionnée(s) sur l'ensemble des dépôts.\")" + "merged_prs = pd.concat(merged_prs_frames, ignore_index=True) if merged_prs_frames else pd.DataFrame()\n", + "print(f\"\\nTotal : {len(merged_prs)} PR(s) fusionnée(s) sur l'ensemble des dépôts.\")" ] }, { @@ -171,7 +278,7 @@ "metadata": {}, "outputs": [], "source": [ - "df = pd.DataFrame(merged_prs)\n", + "df = merged_prs.copy() if not merged_prs.empty else pd.DataFrame(columns=[\"author\", \"merged_at\", \"repo\"])\n", "\n", "if df.empty:\n", " print(\"Aucune donnée à afficher.\")\n", From 3a9d9d9279009abab5ba10937086564885b6bb71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:08:27 +0000 Subject: [PATCH 05/14] Add AUTHOR_WHITELIST param and remove repo names from chart titles Agent-Logs-Url: https://github.com/sdpython/teachpyx/sessions/fa66ca3f-8015-4f8d-9840-12e3b51b006f Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --- _doc/practice/years/2026/github_stat_pr.ipynb | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/_doc/practice/years/2026/github_stat_pr.ipynb b/_doc/practice/years/2026/github_stat_pr.ipynb index ab0ec44..0009be1 100644 --- a/_doc/practice/years/2026/github_stat_pr.ipynb +++ b/_doc/practice/years/2026/github_stat_pr.ipynb @@ -50,7 +50,9 @@ "\n", "* `REPOS` — liste de dépôts à analyser sous la forme `[(owner, repo), ...]`.\n", "* `CACHE_DIR` — répertoire où sont stockés les fichiers CSV de cache (un par dépôt).\n", - " Utilisez `\".\"` pour enregistrer les fichiers à côté du notebook." + " Utilisez `\".\"` pour enregistrer les fichiers à côté du notebook.\n", + "* `AUTHOR_WHITELIST` — liste d'identifiants GitHub à conserver. Si la liste est vide,\n", + " tous les auteurs sont inclus." ] }, { @@ -67,6 +69,10 @@ "# Répertoire de cache (créé automatiquement si nécessaire)\n", "CACHE_DIR = pathlib.Path(\".\")\n", "\n", + "# Liste blanche d'auteurs : seuls ces auteurs seront inclus dans l'analyse.\n", + "# Laissez vide ([]) pour inclure tous les auteurs.\n", + "AUTHOR_WHITELIST: list[str] = []\n", + "\n", "# Jeton d'authentification GitHub (optionnel mais recommandé)\n", "GITHUB_TOKEN = os.environ.get(\"GITHUB_TOKEN\", \"\")" ] @@ -283,6 +289,13 @@ "if df.empty:\n", " print(\"Aucune donnée à afficher.\")\n", "else:\n", + " # Filtre par liste blanche d'auteurs (si définie)\n", + " if AUTHOR_WHITELIST:\n", + " df = df[df[\"author\"].isin(AUTHOR_WHITELIST)].copy()\n", + " if df.empty:\n", + " print(\"Aucun auteur de la liste blanche trouvé dans les données.\")\n", + "\n", + "if not df.empty:\n", " # Tronque la date au lundi de la semaine\n", " df[\"week\"] = df[\"merged_at\"].dt.to_period(\"W\").dt.start_time\n", "\n", @@ -374,7 +387,7 @@ " plt.xticks(rotation=45, ha=\"right\")\n", " ax.set_xlabel(\"Semaine\")\n", " ax.set_ylabel(\"Nombre de PR fusionnées\")\n", - " repos_label = \", \".join(f\"{o}/{r}\" for o, r in REPOS)\n ax.set_title(f\"PR fusionnées par semaine — {repos_label}\")\n", + " ax.set_title(\"PR fusionnées par semaine\")\n", " ax.legend(loc=\"upper left\", bbox_to_anchor=(1, 1), title=\"Auteur\")\n", " plt.tight_layout()\n", " plt.show()" @@ -409,7 +422,7 @@ " [str(d)[:10] for d in pivot.columns[::step]], rotation=45, ha=\"right\"\n", " )\n", "\n", - " repos_label = \", \".join(f\"{o}/{r}\" for o, r in REPOS)\n ax.set_title(f\"Heatmap des PR fusionnées — {repos_label}\")\n", + " ax.set_title(\"Heatmap des PR fusionnées\")\n", " ax.set_xlabel(\"Semaine\")\n", " ax.set_ylabel(\"Auteur\")\n", " plt.tight_layout()\n", From 83ba05a75216050c12a15c3a114c6535fa88c07a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:11:10 +0000 Subject: [PATCH 06/14] Add per-repo bar chart and heatmap visualizations Agent-Logs-Url: https://github.com/sdpython/teachpyx/sessions/e9c51566-3ef2-4456-9c5a-f2873fae04a4 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --- _doc/practice/years/2026/github_stat_pr.ipynb | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/_doc/practice/years/2026/github_stat_pr.ipynb b/_doc/practice/years/2026/github_stat_pr.ipynb index 0009be1..4eb5869 100644 --- a/_doc/practice/years/2026/github_stat_pr.ipynb +++ b/_doc/practice/years/2026/github_stat_pr.ipynb @@ -428,6 +428,78 @@ " plt.tight_layout()\n", " plt.show()" ] + }, + { + "cell_type": "markdown", + "id": "", + "metadata": {}, + "source": [ + "## Visualisation par dépôt\n", + "\n", + "Les graphiques suivants (diagramme empilé + heatmap) sont produits pour chaque dépôt\n", + "lorsque plusieurs dépôts sont analysés." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "", + "metadata": {}, + "outputs": [], + "source": [ + "def _plot_repo(pvt: pd.DataFrame, repo_name: str) -> None:\n", + " \"\"\"Trace le diagramme empilé et la heatmap pour un dépôt.\"\"\"\n", + " if pvt.empty:\n", + " return\n", + "\n", + " # --- Diagramme empilé ---\n", + " fig, ax = plt.subplots(figsize=(14, 5))\n", + " stacked_height = None\n", + " week_nums = mdates.date2num(pvt.columns.to_pydatetime())\n", + " for author in pvt.index:\n", + " values = pvt.loc[author].values\n", + " if stacked_height is None:\n", + " ax.bar(week_nums, values, width=5, label=author)\n", + " stacked_height = values.copy()\n", + " else:\n", + " ax.bar(week_nums, values, width=5, bottom=stacked_height, label=author)\n", + " stacked_height += values\n", + " ax.xaxis.set_major_formatter(mdates.DateFormatter(\"%Y-%m-%d\"))\n", + " ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO, interval=4))\n", + " plt.xticks(rotation=45, ha=\"right\")\n", + " ax.set_xlabel(\"Semaine\")\n", + " ax.set_ylabel(\"Nombre de PR fusionnées\")\n", + " ax.set_title(f\"PR fusionnées par semaine — {repo_name}\")\n", + " ax.legend(loc=\"upper left\", bbox_to_anchor=(1, 1), title=\"Auteur\")\n", + " plt.tight_layout()\n", + " plt.show()\n", + "\n", + " # --- Heatmap ---\n", + " fig, ax = plt.subplots(figsize=(14, max(3, len(pvt) * 0.5)))\n", + " im = ax.imshow(pvt.values, aspect=\"auto\", cmap=\"YlOrRd\")\n", + " plt.colorbar(im, ax=ax, label=\"Nombre de PR\")\n", + " ax.set_yticks(range(len(pvt.index)))\n", + " ax.set_yticklabels(pvt.index)\n", + " step = max(1, len(pvt.columns) // 12)\n", + " ax.set_xticks(range(0, len(pvt.columns), step))\n", + " ax.set_xticklabels(\n", + " [str(d)[:10] for d in pvt.columns[::step]], rotation=45, ha=\"right\"\n", + " )\n", + " ax.set_title(f\"Heatmap des PR fusionnées — {repo_name}\")\n", + " ax.set_xlabel(\"Semaine\")\n", + " ax.set_ylabel(\"Auteur\")\n", + " plt.tight_layout()\n", + " plt.show()\n", + "\n", + "\n", + "if not df.empty and len(REPOS) > 1:\n", + " for repo_name, grp in weekly.groupby(\"repo\"):\n", + " pvt = grp.pivot_table(\n", + " index=\"author\", columns=\"week\", values=\"pr_count\", aggfunc=\"sum\", fill_value=0\n", + " )\n", + " pvt = pvt.loc[pvt.sum(axis=1).sort_values(ascending=False).index]\n", + " _plot_repo(pvt, repo_name)\n" + ] } ], "metadata": { From f0e2806f043042d40287d396fa4eed6d5f2b0b81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:24:01 +0000 Subject: [PATCH 07/14] Replace notebook with standalone Python script that saves PNG images Agent-Logs-Url: https://github.com/sdpython/teachpyx/sessions/52588561-50c4-49d5-8656-4e9a19a3fde0 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --- _doc/practice/years/2026/github_stat_pr.ipynb | 518 ------------------ _doc/practice/years/2026/github_stat_pr.py | 379 +++++++++++++ _doc/practice/years/2026/github_stat_pr.rst | 31 ++ 3 files changed, 410 insertions(+), 518 deletions(-) delete mode 100644 _doc/practice/years/2026/github_stat_pr.ipynb create mode 100644 _doc/practice/years/2026/github_stat_pr.py create mode 100644 _doc/practice/years/2026/github_stat_pr.rst diff --git a/_doc/practice/years/2026/github_stat_pr.ipynb b/_doc/practice/years/2026/github_stat_pr.ipynb deleted file mode 100644 index 4eb5869..0000000 --- a/_doc/practice/years/2026/github_stat_pr.ipynb +++ /dev/null @@ -1,518 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Nombre de PR fusionnées par personne agrégées par semaine\n", - "\n", - "Ce notebook récupère, via l'API GitHub, le nombre de *pull requests* (PR) fusionnées\n", - "pour **un ou plusieurs dépôts**, les regroupe par auteur et par semaine sur l'année écoulée,\n", - "puis affiche le résultat sous forme de graphique.\n", - "\n", - "Les données récupérées sont **mises en cache** localement (un fichier CSV par dépôt).\n", - "Lors des exécutions suivantes, seules les PR plus récentes que la dernière date mise en cache\n", - "sont requêtées, ce qui réduit considérablement le nombre d'appels à l'API.\n", - "\n", - "**Dépendances :** `requests`, `pandas`, `matplotlib`.\n", - "\n", - "**Token GitHub :** l'API GitHub limite les appels non authentifiés à 60 requêtes par heure.\n", - "Pour lever cette limite, définissez la variable d'environnement `GITHUB_TOKEN`\n", - "avec un *Personal Access Token* (PAT) GitHub :\n", - "\n", - "```bash\n", - "export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx\n", - "```\n", - "\n", - "Sans token, le notebook fonctionne mais peut être limité sur de grands dépôts." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import datetime\n", - "import pathlib\n", - "import requests\n", - "import pandas as pd\n", - "import matplotlib.pyplot as plt\n", - "import matplotlib.dates as mdates" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Paramètres\n", - "\n", - "* `REPOS` — liste de dépôts à analyser sous la forme `[(owner, repo), ...]`.\n", - "* `CACHE_DIR` — répertoire où sont stockés les fichiers CSV de cache (un par dépôt).\n", - " Utilisez `\".\"` pour enregistrer les fichiers à côté du notebook.\n", - "* `AUTHOR_WHITELIST` — liste d'identifiants GitHub à conserver. Si la liste est vide,\n", - " tous les auteurs sont inclus." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "REPOS = [\n", - " (\"sdpython\", \"teachpyx\"),\n", - " # (\"sdpython\", \"onnx-extended\"), # ajoutez d'autres dépôts ici\n", - "]\n", - "\n", - "# Répertoire de cache (créé automatiquement si nécessaire)\n", - "CACHE_DIR = pathlib.Path(\".\")\n", - "\n", - "# Liste blanche d'auteurs : seuls ces auteurs seront inclus dans l'analyse.\n", - "# Laissez vide ([]) pour inclure tous les auteurs.\n", - "AUTHOR_WHITELIST: list[str] = []\n", - "\n", - "# Jeton d'authentification GitHub (optionnel mais recommandé)\n", - "GITHUB_TOKEN = os.environ.get(\"GITHUB_TOKEN\", \"\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Récupération des PR fusionnées via l'API GitHub (avec cache)\n", - "\n", - "Pour chaque dépôt :\n", - "\n", - "1. On charge le fichier CSV de cache s'il existe (`prs_cache_{owner}_{repo}.csv`).\n", - "2. On détermine la date la plus récente déjà présente dans le cache.\n", - "3. On ne récupère auprès de l'API que les PR fusionnées **après** cette date\n", - " (ou toutes si le cache est vide).\n", - "4. On fusionne les nouvelles PR avec le cache, on supprime les doublons\n", - " et on élague les entrées datant de plus de 365 jours.\n", - "5. On sauvegarde le cache mis à jour sur disque." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "CACHE_DATE_FMT = \"%Y-%m-%dT%H:%M:%S%z\"\n", - "\n", - "\n", - "def _cache_path(cache_dir: pathlib.Path, owner: str, repo: str) -> pathlib.Path:\n", - " \"\"\"Retourne le chemin du fichier CSV de cache pour un dépôt.\"\"\"\n", - " safe = f\"{owner}_{repo}\".replace(\"/\", \"_\")\n", - " return cache_dir / f\"prs_cache_{safe}.csv\"\n", - "\n", - "\n", - "def load_cache(\n", - " cache_dir: pathlib.Path, owner: str, repo: str\n", - ") -> pd.DataFrame:\n", - " \"\"\"Charge le cache CSV pour un dépôt (retourne un DataFrame vide si absent).\"\"\"\n", - " path = _cache_path(cache_dir, owner, repo)\n", - " if not path.exists():\n", - " return pd.DataFrame(columns=[\"author\", \"merged_at\", \"repo\"])\n", - " df = pd.read_csv(path, parse_dates=[\"merged_at\"])\n", - " # S'assurer que la colonne est bien tz-aware (UTC)\n", - " if df[\"merged_at\"].dt.tz is None:\n", - " df[\"merged_at\"] = df[\"merged_at\"].dt.tz_localize(\"UTC\")\n", - " else:\n", - " df[\"merged_at\"] = df[\"merged_at\"].dt.tz_convert(\"UTC\")\n", - " return df\n", - "\n", - "\n", - "def save_cache(\n", - " cache_dir: pathlib.Path, owner: str, repo: str, df: pd.DataFrame\n", - ") -> None:\n", - " \"\"\"Sauvegarde le DataFrame dans le fichier CSV de cache.\"\"\"\n", - " cache_dir.mkdir(parents=True, exist_ok=True)\n", - " path = _cache_path(cache_dir, owner, repo)\n", - " df.to_csv(path, index=False, date_format=CACHE_DATE_FMT)\n", - "\n", - "\n", - "def fetch_merged_prs(\n", - " owner: str,\n", - " repo: str,\n", - " token: str = \"\",\n", - " fetch_since: datetime.datetime | None = None,\n", - ") -> list[dict]:\n", - " \"\"\"Récupère les PR fusionnées pour un dépôt à partir d'une date donnée.\n", - "\n", - " :param owner: propriétaire du dépôt GitHub\n", - " :param repo: nom du dépôt GitHub\n", - " :param token: jeton d'authentification GitHub (optionnel)\n", - " :param fetch_since: si fourni, on s'arrête dès que ``merged_at`` est antérieur\n", - " à cette date (les PR plus anciennes sont déjà en cache).\n", - " Si ``None``, on remonte jusqu'à 365 jours en arrière.\n", - " :return: liste de dictionnaires ``{author, merged_at, repo}``\n", - " \"\"\"\n", - " headers = {\"Accept\": \"application/vnd.github+json\"}\n", - " if token:\n", - " headers[\"Authorization\"] = f\"Bearer {token}\"\n", - "\n", - " cutoff = fetch_since if fetch_since is not None else (\n", - " datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365)\n", - " )\n", - "\n", - " results = []\n", - " page = 1\n", - " per_page = 100\n", - "\n", - " while True:\n", - " url = (\n", - " f\"https://api.github.com/repos/{owner}/{repo}/pulls\"\n", - " f\"?state=closed&per_page={per_page}&page={page}&sort=updated&direction=desc\"\n", - " )\n", - " response = requests.get(url, headers=headers, timeout=30)\n", - " try:\n", - " response.raise_for_status()\n", - " except requests.HTTPError as exc:\n", - " status = exc.response.status_code\n", - " if status == 401:\n", - " raise RuntimeError(\n", - " \"Authentification refusée (401). Vérifiez votre GITHUB_TOKEN.\"\n", - " ) from exc\n", - " if status == 403:\n", - " raise RuntimeError(\n", - " \"Accès refusé (403). Vous avez peut-être atteint la limite de l'API \"\n", - " \"GitHub (60 requêtes/h sans token). Définissez GITHUB_TOKEN.\"\n", - " ) from exc\n", - " if status == 404:\n", - " raise RuntimeError(\n", - " f\"Dépôt introuvable (404) : {owner}/{repo}. Vérifiez OWNER et REPO.\"\n", - " ) from exc\n", - " raise\n", - " prs = response.json()\n", - "\n", - " if not prs:\n", - " break\n", - "\n", - " stop = False\n", - " for pr in prs:\n", - " merged_at = pr.get(\"merged_at\")\n", - " if not merged_at:\n", - " continue\n", - " merged_dt = datetime.datetime.fromisoformat(merged_at.replace(\"Z\", \"+00:00\"))\n", - " if merged_dt <= cutoff:\n", - " stop = True\n", - " break\n", - " author = (pr.get(\"user\") or {}).get(\"login\", \"unknown\")\n", - " results.append({\"author\": author, \"merged_at\": merged_dt, \"repo\": f\"{owner}/{repo}\"})\n", - "\n", - " if stop:\n", - " break\n", - "\n", - " page += 1\n", - "\n", - " return results\n", - "\n", - "\n", - "def load_prs_with_cache(\n", - " owner: str, repo: str, token: str = \"\", cache_dir: pathlib.Path = pathlib.Path(\".\")\n", - ") -> pd.DataFrame:\n", - " \"\"\"Charge les PR fusionnées pour un dépôt en utilisant le cache local.\n", - "\n", - " * Si le cache existe, seules les PR plus récentes que la dernière entrée\n", - " mise en cache sont récupérées via l'API.\n", - " * Le cache est élagué pour ne conserver que les 365 derniers jours.\n", - " * Le cache mis à jour est sauvegardé sur disque.\n", - "\n", - " :return: DataFrame avec les colonnes ``author``, ``merged_at``, ``repo``\n", - " \"\"\"\n", - " now = datetime.datetime.now(datetime.timezone.utc)\n", - " cutoff_365 = now - datetime.timedelta(days=365)\n", - "\n", - " cached_df = load_cache(cache_dir, owner, repo)\n", - "\n", - " if cached_df.empty:\n", - " fetch_since = None # récupérer toute l'année\n", - " print(f\" {owner}/{repo} : cache vide, récupération complète…\")\n", - " else:\n", - " # Relancer depuis le début de la journée du dernier enregistrement\n", - " # pour ne pas manquer de PR fusionnées en cours de journée.\n", - " latest = cached_df[\"merged_at\"].max()\n", - " fetch_since = latest.replace(hour=0, minute=0, second=0, microsecond=0)\n", - " print(\n", - " f\" {owner}/{repo} : cache chargé ({len(cached_df)} entrées), \"\n", - " f\"récupération des PR depuis {fetch_since.date()}…\"\n", - " )\n", - "\n", - " new_prs = fetch_merged_prs(owner, repo, token, fetch_since=fetch_since)\n", - " print(f\" → {len(new_prs)} nouvelle(s) PR(s) récupérée(s) via l'API.\")\n", - "\n", - " if new_prs:\n", - " new_df = pd.DataFrame(new_prs)\n", - " combined = pd.concat([cached_df, new_df], ignore_index=True)\n", - " else:\n", - " combined = cached_df.copy()\n", - "\n", - " # Dédoublonnage et élagage\n", - " combined.drop_duplicates(subset=[\"repo\", \"author\", \"merged_at\"], inplace=True)\n", - " combined = combined[combined[\"merged_at\"] >= cutoff_365].copy()\n", - " combined.sort_values(\"merged_at\", inplace=True)\n", - " combined.reset_index(drop=True, inplace=True)\n", - "\n", - " save_cache(cache_dir, owner, repo, combined)\n", - " print(f\" → cache mis à jour ({len(combined)} entrées au total).\")\n", - "\n", - " return combined\n", - "\n", - "\n", - "merged_prs_frames = []\n", - "for owner, repo in REPOS:\n", - " repo_df = load_prs_with_cache(owner, repo, GITHUB_TOKEN, CACHE_DIR)\n", - " merged_prs_frames.append(repo_df)\n", - "\n", - "merged_prs = pd.concat(merged_prs_frames, ignore_index=True) if merged_prs_frames else pd.DataFrame()\n", - "print(f\"\\nTotal : {len(merged_prs)} PR(s) fusionnée(s) sur l'ensemble des dépôts.\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Agrégation par auteur et par semaine" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "df = merged_prs.copy() if not merged_prs.empty else pd.DataFrame(columns=[\"author\", \"merged_at\", \"repo\"])\n", - "\n", - "if df.empty:\n", - " print(\"Aucune donnée à afficher.\")\n", - "else:\n", - " # Filtre par liste blanche d'auteurs (si définie)\n", - " if AUTHOR_WHITELIST:\n", - " df = df[df[\"author\"].isin(AUTHOR_WHITELIST)].copy()\n", - " if df.empty:\n", - " print(\"Aucun auteur de la liste blanche trouvé dans les données.\")\n", - "\n", - "if not df.empty:\n", - " # Tronque la date au lundi de la semaine\n", - " df[\"week\"] = df[\"merged_at\"].dt.to_period(\"W\").dt.start_time\n", - "\n", - " weekly = (\n", - " df.groupby([\"repo\", \"author\", \"week\"])\n", - " .size()\n", - " .reset_index(name=\"pr_count\")\n", - " )\n", - " print(weekly.head(10))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Tableau croisé (auteur × semaine, agrégé sur tous les dépôts)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if not df.empty:\n", - " # Agrégation sur tous les dépôts\n", - " pivot = weekly.pivot_table(\n", - " index=\"author\", columns=\"week\", values=\"pr_count\", aggfunc=\"sum\", fill_value=0\n", - " )\n", - " # Tri par nombre total de PR décroissant\n", - " pivot = pivot.loc[pivot.sum(axis=1).sort_values(ascending=False).index]\n", - " pivot" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Tableau croisé par dépôt" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if not df.empty and len(REPOS) > 1:\n", - " for repo_name, grp in weekly.groupby(\"repo\"):\n", - " pvt = grp.pivot_table(\n", - " index=\"author\", columns=\"week\", values=\"pr_count\", aggfunc=\"sum\", fill_value=0\n", - " )\n", - " pvt = pvt.loc[pvt.sum(axis=1).sort_values(ascending=False).index]\n", - " print(f\"\\n=== {repo_name} ===\")\n", - " display(pvt)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Visualisation : nombre de PR fusionnées par semaine (empilé par auteur)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if not df.empty:\n", - " fig, ax = plt.subplots(figsize=(14, 5))\n", - "\n", - " stacked_height = None\n", - " weeks = pivot.columns # DatetimeIndex\n", - " week_nums = mdates.date2num(weeks.to_pydatetime())\n", - "\n", - " for author in pivot.index:\n", - " values = pivot.loc[author].values\n", - " if stacked_height is None:\n", - " ax.bar(week_nums, values, width=5, label=author)\n", - " stacked_height = values.copy()\n", - " else:\n", - " ax.bar(week_nums, values, width=5, bottom=stacked_height, label=author)\n", - " stacked_height += values\n", - "\n", - " ax.xaxis.set_major_formatter(mdates.DateFormatter(\"%Y-%m-%d\"))\n", - " ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO, interval=4))\n", - " plt.xticks(rotation=45, ha=\"right\")\n", - " ax.set_xlabel(\"Semaine\")\n", - " ax.set_ylabel(\"Nombre de PR fusionnées\")\n", - " ax.set_title(\"PR fusionnées par semaine\")\n", - " ax.legend(loc=\"upper left\", bbox_to_anchor=(1, 1), title=\"Auteur\")\n", - " plt.tight_layout()\n", - " plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Visualisation : carte de chaleur (heatmap auteur × semaine)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if not df.empty:\n", - " fig, ax = plt.subplots(figsize=(14, max(3, len(pivot) * 0.5)))\n", - "\n", - " im = ax.imshow(pivot.values, aspect=\"auto\", cmap=\"YlOrRd\")\n", - " plt.colorbar(im, ax=ax, label=\"Nombre de PR\")\n", - "\n", - " ax.set_yticks(range(len(pivot.index)))\n", - " ax.set_yticklabels(pivot.index)\n", - "\n", - " # Affiche une étiquette de semaine sur 4\n", - " step = max(1, len(pivot.columns) // 12)\n", - " ax.set_xticks(range(0, len(pivot.columns), step))\n", - " ax.set_xticklabels(\n", - " [str(d)[:10] for d in pivot.columns[::step]], rotation=45, ha=\"right\"\n", - " )\n", - "\n", - " ax.set_title(\"Heatmap des PR fusionnées\")\n", - " ax.set_xlabel(\"Semaine\")\n", - " ax.set_ylabel(\"Auteur\")\n", - " plt.tight_layout()\n", - " plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "", - "metadata": {}, - "source": [ - "## Visualisation par dépôt\n", - "\n", - "Les graphiques suivants (diagramme empilé + heatmap) sont produits pour chaque dépôt\n", - "lorsque plusieurs dépôts sont analysés." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "", - "metadata": {}, - "outputs": [], - "source": [ - "def _plot_repo(pvt: pd.DataFrame, repo_name: str) -> None:\n", - " \"\"\"Trace le diagramme empilé et la heatmap pour un dépôt.\"\"\"\n", - " if pvt.empty:\n", - " return\n", - "\n", - " # --- Diagramme empilé ---\n", - " fig, ax = plt.subplots(figsize=(14, 5))\n", - " stacked_height = None\n", - " week_nums = mdates.date2num(pvt.columns.to_pydatetime())\n", - " for author in pvt.index:\n", - " values = pvt.loc[author].values\n", - " if stacked_height is None:\n", - " ax.bar(week_nums, values, width=5, label=author)\n", - " stacked_height = values.copy()\n", - " else:\n", - " ax.bar(week_nums, values, width=5, bottom=stacked_height, label=author)\n", - " stacked_height += values\n", - " ax.xaxis.set_major_formatter(mdates.DateFormatter(\"%Y-%m-%d\"))\n", - " ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO, interval=4))\n", - " plt.xticks(rotation=45, ha=\"right\")\n", - " ax.set_xlabel(\"Semaine\")\n", - " ax.set_ylabel(\"Nombre de PR fusionnées\")\n", - " ax.set_title(f\"PR fusionnées par semaine — {repo_name}\")\n", - " ax.legend(loc=\"upper left\", bbox_to_anchor=(1, 1), title=\"Auteur\")\n", - " plt.tight_layout()\n", - " plt.show()\n", - "\n", - " # --- Heatmap ---\n", - " fig, ax = plt.subplots(figsize=(14, max(3, len(pvt) * 0.5)))\n", - " im = ax.imshow(pvt.values, aspect=\"auto\", cmap=\"YlOrRd\")\n", - " plt.colorbar(im, ax=ax, label=\"Nombre de PR\")\n", - " ax.set_yticks(range(len(pvt.index)))\n", - " ax.set_yticklabels(pvt.index)\n", - " step = max(1, len(pvt.columns) // 12)\n", - " ax.set_xticks(range(0, len(pvt.columns), step))\n", - " ax.set_xticklabels(\n", - " [str(d)[:10] for d in pvt.columns[::step]], rotation=45, ha=\"right\"\n", - " )\n", - " ax.set_title(f\"Heatmap des PR fusionnées — {repo_name}\")\n", - " ax.set_xlabel(\"Semaine\")\n", - " ax.set_ylabel(\"Auteur\")\n", - " plt.tight_layout()\n", - " plt.show()\n", - "\n", - "\n", - "if not df.empty and len(REPOS) > 1:\n", - " for repo_name, grp in weekly.groupby(\"repo\"):\n", - " pvt = grp.pivot_table(\n", - " index=\"author\", columns=\"week\", values=\"pr_count\", aggfunc=\"sum\", fill_value=0\n", - " )\n", - " pvt = pvt.loc[pvt.sum(axis=1).sort_values(ascending=False).index]\n", - " _plot_repo(pvt, repo_name)\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.12.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/_doc/practice/years/2026/github_stat_pr.py b/_doc/practice/years/2026/github_stat_pr.py new file mode 100644 index 0000000..e2f8022 --- /dev/null +++ b/_doc/practice/years/2026/github_stat_pr.py @@ -0,0 +1,379 @@ +# coding: utf-8 +""" +Script : statistiques de PR fusionnées par auteur et par semaine +================================================================ + +Ce script récupère, via l'API GitHub, le nombre de *pull requests* (PR) fusionnées +pour **un ou plusieurs dépôts**, les regroupe par auteur et par semaine sur l'année +écoulée, puis enregistre les graphiques sous forme d'images PNG. + +Les données récupérées sont **mises en cache** localement (un fichier CSV par dépôt). +Lors des exécutions suivantes, seules les PR plus récentes que la dernière date mise +en cache sont requêtées, ce qui réduit le nombre d'appels à l'API. + +**Dépendances :** ``requests``, ``pandas``, ``matplotlib``. + +**Token GitHub :** définissez la variable d'environnement ``GITHUB_TOKEN`` avec un +*Personal Access Token* (PAT) GitHub pour dépasser la limite de 60 requêtes/heure : + +.. code-block:: bash + + export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx + +**Usage :** + +.. code-block:: bash + + python github_stat_pr.py + +Les images sont enregistrées dans le répertoire courant : + +* ``github_stat_pr_bar.png`` — diagramme empilé (toutes repos confondues) +* ``github_stat_pr_heatmap.png`` — heatmap (toutes repos confondues) +* ``github_stat_pr_bar_{owner}_{repo}.png`` — diagramme empilé par dépôt +* ``github_stat_pr_heatmap_{owner}_{repo}.png`` — heatmap par dépôt +""" + +import datetime +import os +import pathlib + +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import pandas as pd +import requests + +# --------------------------------------------------------------------------- +# Paramètres +# --------------------------------------------------------------------------- + +REPOS = [ + ("sdpython", "teachpyx"), + # ("sdpython", "onnx-extended"), # ajoutez d'autres dépôts ici +] + +# Répertoire de cache (créé automatiquement si nécessaire) +CACHE_DIR = pathlib.Path(".") + +# Liste blanche d'auteurs : seuls ces auteurs seront inclus dans l'analyse. +# Laissez vide ([]) pour inclure tous les auteurs. +AUTHOR_WHITELIST: list[str] = [] + +# Répertoire de sortie pour les images PNG +OUTPUT_DIR = pathlib.Path(".") + +# Jeton d'authentification GitHub (optionnel mais recommandé) +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") + +# --------------------------------------------------------------------------- +# Cache +# --------------------------------------------------------------------------- + +CACHE_DATE_FMT = "%Y-%m-%dT%H:%M:%S%z" + + +def _cache_path(cache_dir: pathlib.Path, owner: str, repo: str) -> pathlib.Path: + safe = f"{owner}_{repo}".replace("/", "_") + return cache_dir / f"prs_cache_{safe}.csv" + + +def load_cache(cache_dir: pathlib.Path, owner: str, repo: str) -> pd.DataFrame: + path = _cache_path(cache_dir, owner, repo) + if not path.exists(): + return pd.DataFrame(columns=["author", "merged_at", "repo"]) + df = pd.read_csv(path, parse_dates=["merged_at"]) + if df["merged_at"].dt.tz is None: + df["merged_at"] = df["merged_at"].dt.tz_localize("UTC") + else: + df["merged_at"] = df["merged_at"].dt.tz_convert("UTC") + return df + + +def save_cache( + cache_dir: pathlib.Path, owner: str, repo: str, df: pd.DataFrame +) -> None: + cache_dir.mkdir(parents=True, exist_ok=True) + path = _cache_path(cache_dir, owner, repo) + df.to_csv(path, index=False, date_format=CACHE_DATE_FMT) + + +# --------------------------------------------------------------------------- +# Récupération des PR via l'API GitHub +# --------------------------------------------------------------------------- + + +def fetch_merged_prs( + owner: str, + repo: str, + token: str = "", + fetch_since: datetime.datetime | None = None, +) -> list[dict]: + """Récupère les PR fusionnées pour un dépôt à partir d'une date donnée. + + :param owner: propriétaire du dépôt GitHub + :param repo: nom du dépôt GitHub + :param token: jeton d'authentification GitHub (optionnel) + :param fetch_since: date de départ de la recherche ; si ``None``, remonte + jusqu'à 365 jours en arrière. + :return: liste de dictionnaires ``{author, merged_at, repo}`` + """ + headers = {"Accept": "application/vnd.github+json"} + if token: + headers["Authorization"] = f"Bearer {token}" + + cutoff = fetch_since if fetch_since is not None else ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365) + ) + + results = [] + page = 1 + per_page = 100 + + while True: + url = ( + f"https://api.github.com/repos/{owner}/{repo}/pulls" + f"?state=closed&per_page={per_page}&page={page}&sort=updated&direction=desc" + ) + response = requests.get(url, headers=headers, timeout=30) + try: + response.raise_for_status() + except requests.HTTPError as exc: + status = exc.response.status_code + if status == 401: + raise RuntimeError( + "Authentification refusée (401). Vérifiez votre GITHUB_TOKEN." + ) from exc + if status == 403: + raise RuntimeError( + "Accès refusé (403). Vous avez peut-être atteint la limite de " + "l'API GitHub (60 requêtes/h sans token). Définissez GITHUB_TOKEN." + ) from exc + if status == 404: + raise RuntimeError( + f"Dépôt introuvable (404) : {owner}/{repo}. " + "Vérifiez OWNER et REPO." + ) from exc + raise + prs = response.json() + + if not prs: + break + + stop = False + for pr in prs: + merged_at = pr.get("merged_at") + if not merged_at: + continue + merged_dt = datetime.datetime.fromisoformat( + merged_at.replace("Z", "+00:00") + ) + if merged_dt <= cutoff: + stop = True + break + author = (pr.get("user") or {}).get("login", "unknown") + results.append( + {"author": author, "merged_at": merged_dt, "repo": f"{owner}/{repo}"} + ) + + if stop: + break + + page += 1 + + return results + + +def load_prs_with_cache( + owner: str, + repo: str, + token: str = "", + cache_dir: pathlib.Path = pathlib.Path("."), +) -> pd.DataFrame: + """Charge les PR fusionnées en utilisant le cache local. + + :return: DataFrame avec les colonnes ``author``, ``merged_at``, ``repo`` + """ + now = datetime.datetime.now(datetime.timezone.utc) + cutoff_365 = now - datetime.timedelta(days=365) + + cached_df = load_cache(cache_dir, owner, repo) + + if cached_df.empty: + fetch_since = None + print(f" {owner}/{repo} : cache vide, récupération complète…") + else: + latest = cached_df["merged_at"].max() + fetch_since = latest.replace(hour=0, minute=0, second=0, microsecond=0) + print( + f" {owner}/{repo} : cache chargé ({len(cached_df)} entrées), " + f"récupération des PR depuis {fetch_since.date()}…" + ) + + new_prs = fetch_merged_prs(owner, repo, token, fetch_since=fetch_since) + print(f" → {len(new_prs)} nouvelle(s) PR(s) récupérée(s) via l'API.") + + if new_prs: + new_df = pd.DataFrame(new_prs) + combined = pd.concat([cached_df, new_df], ignore_index=True) + else: + combined = cached_df.copy() + + combined.drop_duplicates(subset=["repo", "author", "merged_at"], inplace=True) + combined = combined[combined["merged_at"] >= cutoff_365].copy() + combined.sort_values("merged_at", inplace=True) + combined.reset_index(drop=True, inplace=True) + + save_cache(cache_dir, owner, repo, combined) + print(f" → cache mis à jour ({len(combined)} entrées au total).") + + return combined + + +# --------------------------------------------------------------------------- +# Agrégation +# --------------------------------------------------------------------------- + + +def aggregate_weekly(df: pd.DataFrame) -> pd.DataFrame: + """Regroupe les PR par (repo, author, week) et retourne un DataFrame long.""" + df = df.copy() + df["week"] = df["merged_at"].dt.to_period("W").dt.start_time + return ( + df.groupby(["repo", "author", "week"]) + .size() + .reset_index(name="pr_count") + ) + + +def make_pivot(weekly: pd.DataFrame) -> pd.DataFrame: + """Construit le tableau croisé auteur × semaine trié par total décroissant.""" + pivot = weekly.pivot_table( + index="author", columns="week", values="pr_count", aggfunc="sum", fill_value=0 + ) + return pivot.loc[pivot.sum(axis=1).sort_values(ascending=False).index] + + +# --------------------------------------------------------------------------- +# Visualisation +# --------------------------------------------------------------------------- + + +def plot_bar(pivot: pd.DataFrame, title: str, output_path: pathlib.Path) -> None: + """Diagramme à barres empilées (auteur × semaine) enregistré en PNG.""" + fig, ax = plt.subplots(figsize=(14, 5)) + stacked_height = None + week_nums = mdates.date2num(pivot.columns.to_pydatetime()) + + for author in pivot.index: + values = pivot.loc[author].values + if stacked_height is None: + ax.bar(week_nums, values, width=5, label=author) + stacked_height = values.copy() + else: + ax.bar(week_nums, values, width=5, bottom=stacked_height, label=author) + stacked_height += values + + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d")) + ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO, interval=4)) + plt.xticks(rotation=45, ha="right") + ax.set_xlabel("Semaine") + ax.set_ylabel("Nombre de PR fusionnées") + ax.set_title(title) + ax.legend(loc="upper left", bbox_to_anchor=(1, 1), title="Auteur") + plt.tight_layout() + plt.savefig(output_path, dpi=150) + plt.close(fig) + print(f" → {output_path}") + + +def plot_heatmap(pivot: pd.DataFrame, title: str, output_path: pathlib.Path) -> None: + """Heatmap auteur × semaine enregistrée en PNG.""" + fig, ax = plt.subplots(figsize=(14, max(3, len(pivot) * 0.5))) + im = ax.imshow(pivot.values, aspect="auto", cmap="YlOrRd") + plt.colorbar(im, ax=ax, label="Nombre de PR") + + ax.set_yticks(range(len(pivot.index))) + ax.set_yticklabels(pivot.index) + + step = max(1, len(pivot.columns) // 12) + ax.set_xticks(range(0, len(pivot.columns), step)) + ax.set_xticklabels( + [str(d)[:10] for d in pivot.columns[::step]], rotation=45, ha="right" + ) + + ax.set_title(title) + ax.set_xlabel("Semaine") + ax.set_ylabel("Auteur") + plt.tight_layout() + plt.savefig(output_path, dpi=150) + plt.close(fig) + print(f" → {output_path}") + + +# --------------------------------------------------------------------------- +# Point d'entrée +# --------------------------------------------------------------------------- + + +def main() -> None: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + # 1. Récupération des données + print("Récupération des PR fusionnées…") + frames = [] + for owner, repo in REPOS: + frames.append(load_prs_with_cache(owner, repo, GITHUB_TOKEN, CACHE_DIR)) + if not frames: + print("Aucune donnée.") + return + + merged_prs = pd.concat(frames, ignore_index=True) + print(f"\nTotal : {len(merged_prs)} PR(s) fusionnée(s).") + + # 2. Filtre par liste blanche + df = merged_prs.copy() + if AUTHOR_WHITELIST: + df = df[df["author"].isin(AUTHOR_WHITELIST)].copy() + if df.empty: + print("Aucun auteur de la liste blanche trouvé dans les données.") + return + + # 3. Agrégation + weekly = aggregate_weekly(df) + pivot_all = make_pivot(weekly) + + # 4. Graphiques combinés (toutes repos) + print("\nGénération des graphiques combinés…") + plot_bar( + pivot_all, + "PR fusionnées par semaine", + OUTPUT_DIR / "github_stat_pr_bar.png", + ) + plot_heatmap( + pivot_all, + "Heatmap des PR fusionnées", + OUTPUT_DIR / "github_stat_pr_heatmap.png", + ) + + # 5. Graphiques par dépôt (si plusieurs dépôts) + if len(REPOS) > 1: + print("\nGénération des graphiques par dépôt…") + for repo_name, grp in weekly.groupby("repo"): + pvt = make_pivot(grp) + safe = repo_name.replace("/", "_") + plot_bar( + pvt, + f"PR fusionnées par semaine — {repo_name}", + OUTPUT_DIR / f"github_stat_pr_bar_{safe}.png", + ) + plot_heatmap( + pvt, + f"Heatmap des PR fusionnées — {repo_name}", + OUTPUT_DIR / f"github_stat_pr_heatmap_{safe}.png", + ) + + print("\nTerminé.") + + +if __name__ == "__main__": + main() diff --git a/_doc/practice/years/2026/github_stat_pr.rst b/_doc/practice/years/2026/github_stat_pr.rst new file mode 100644 index 0000000..55e82f6 --- /dev/null +++ b/_doc/practice/years/2026/github_stat_pr.rst @@ -0,0 +1,31 @@ +.. _l-github-stat-pr-2026: + +Statistiques de PR fusionnées par auteur et par semaine +======================================================= + +Ce script récupère, via l'API GitHub, le nombre de *pull requests* (PR) fusionnées +pour **un ou plusieurs dépôts**, les regroupe par auteur et par semaine sur l'année +écoulée, puis enregistre les graphiques sous forme d'images PNG. + +Les données récupérées sont **mises en cache** localement (un fichier CSV par dépôt). +Lors des exécutions suivantes, seules les PR plus récentes que la dernière date mise +en cache sont requêtées. + +**Dépendances :** ``requests``, ``pandas``, ``matplotlib``. + +**Usage :** + +.. code-block:: bash + + export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx # optionnel mais recommandé + python github_stat_pr.py + +Images générées : + +* ``github_stat_pr_bar.png`` — diagramme empilé (toutes repos confondues) +* ``github_stat_pr_heatmap.png`` — heatmap (toutes repos confondues) +* ``github_stat_pr_bar_{owner}_{repo}.png`` — diagramme empilé par dépôt +* ``github_stat_pr_heatmap_{owner}_{repo}.png`` — heatmap par dépôt + +.. literalinclude:: github_stat_pr.py + :language: python From 6a88116f60c2d122a8127f7134db563e1e7de46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Dupr=C3=A9?= Date: Fri, 24 Apr 2026 10:42:53 +0200 Subject: [PATCH 08/14] fix --- .gitignore | 1 + _doc/practice/years/2026/github_stat_pr.py | 32 ++++++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index dfa03ff..c4986c1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ lisezmoi* dummy* exemple.txt examen*.csv +prs*.csv fleurs.csv *dbase* .eggs/* diff --git a/_doc/practice/years/2026/github_stat_pr.py b/_doc/practice/years/2026/github_stat_pr.py index e2f8022..0f94ac7 100644 --- a/_doc/practice/years/2026/github_stat_pr.py +++ b/_doc/practice/years/2026/github_stat_pr.py @@ -49,6 +49,13 @@ REPOS = [ ("sdpython", "teachpyx"), + ("sdpython", "teachcompute"), + ("sdpython", "onnx-extended"), + ("sdpython", "experimental-experiment"), + ("xadupre", "yet-another-onnx-builder"), + ("xadupre", "mbext"), + ("onnx", "sklearn-onnx"), + ("onnx", "onnxmltools"), # ("sdpython", "onnx-extended"), # ajoutez d'autres dépôts ici ] @@ -121,8 +128,12 @@ def fetch_merged_prs( if token: headers["Authorization"] = f"Bearer {token}" - cutoff = fetch_since if fetch_since is not None else ( - datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365) + cutoff = ( + fetch_since + if fetch_since is not None + else ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365) + ) ) results = [] @@ -214,7 +225,10 @@ def load_prs_with_cache( if new_prs: new_df = pd.DataFrame(new_prs) - combined = pd.concat([cached_df, new_df], ignore_index=True) + if cached_df.shape[0]: + combined = pd.concat([cached_df, new_df], ignore_index=True) + else: + combined = new_df else: combined = cached_df.copy() @@ -238,15 +252,11 @@ def aggregate_weekly(df: pd.DataFrame) -> pd.DataFrame: """Regroupe les PR par (repo, author, week) et retourne un DataFrame long.""" df = df.copy() df["week"] = df["merged_at"].dt.to_period("W").dt.start_time - return ( - df.groupby(["repo", "author", "week"]) - .size() - .reset_index(name="pr_count") - ) + return df.groupby(["repo", "author", "week"]).size().reset_index(name="pr_count") def make_pivot(weekly: pd.DataFrame) -> pd.DataFrame: - """Construit le tableau croisé auteur × semaine trié par total décroissant.""" + """Construit le tableau croisé auteur x semaine trié par total décroissant.""" pivot = weekly.pivot_table( index="author", columns="week", values="pr_count", aggfunc="sum", fill_value=0 ) @@ -259,7 +269,7 @@ def make_pivot(weekly: pd.DataFrame) -> pd.DataFrame: def plot_bar(pivot: pd.DataFrame, title: str, output_path: pathlib.Path) -> None: - """Diagramme à barres empilées (auteur × semaine) enregistré en PNG.""" + """Diagramme à barres empilées (auteur x semaine) enregistré en PNG.""" fig, ax = plt.subplots(figsize=(14, 5)) stacked_height = None week_nums = mdates.date2num(pivot.columns.to_pydatetime()) @@ -287,7 +297,7 @@ def plot_bar(pivot: pd.DataFrame, title: str, output_path: pathlib.Path) -> None def plot_heatmap(pivot: pd.DataFrame, title: str, output_path: pathlib.Path) -> None: - """Heatmap auteur × semaine enregistrée en PNG.""" + """Heatmap auteur x semaine enregistrée en PNG.""" fig, ax = plt.subplots(figsize=(14, max(3, len(pivot) * 0.5))) im = ax.imshow(pivot.values, aspect="auto", cmap="YlOrRd") plt.colorbar(im, ax=ax, label="Nombre de PR") From 75689aef21e4df6efcb7780b69b4a44b416b9339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Dupr=C3=A9?= Date: Fri, 24 Apr 2026 10:45:41 +0200 Subject: [PATCH 09/14] author --- _doc/practice/years/2026/github_stat_pr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_doc/practice/years/2026/github_stat_pr.py b/_doc/practice/years/2026/github_stat_pr.py index 0f94ac7..933d559 100644 --- a/_doc/practice/years/2026/github_stat_pr.py +++ b/_doc/practice/years/2026/github_stat_pr.py @@ -64,7 +64,7 @@ # Liste blanche d'auteurs : seuls ces auteurs seront inclus dans l'analyse. # Laissez vide ([]) pour inclure tous les auteurs. -AUTHOR_WHITELIST: list[str] = [] +AUTHOR_WHITELIST: list[str] = ["xadupre", "sdpython", "Copilot", "dependabot[bot]"] # Répertoire de sortie pour les images PNG OUTPUT_DIR = pathlib.Path(".") From 0e9c2aad042c45218ae839007393187d39c9002a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:48:32 +0000 Subject: [PATCH 10/14] Add line chart comparing weekly PR counts across repos Agent-Logs-Url: https://github.com/sdpython/teachpyx/sessions/78df380d-d74e-4801-8672-455b68860a6a Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --- _doc/practice/years/2026/github_stat_pr.py | 49 +++++++++++++++++++++ _doc/practice/years/2026/github_stat_pr.rst | 1 + 2 files changed, 50 insertions(+) diff --git a/_doc/practice/years/2026/github_stat_pr.py b/_doc/practice/years/2026/github_stat_pr.py index 933d559..60697c0 100644 --- a/_doc/practice/years/2026/github_stat_pr.py +++ b/_doc/practice/years/2026/github_stat_pr.py @@ -30,6 +30,7 @@ * ``github_stat_pr_bar.png`` — diagramme empilé (toutes repos confondues) * ``github_stat_pr_heatmap.png`` — heatmap (toutes repos confondues) +* ``github_stat_pr_lines.png`` — graphe en lignes comparant les dépôts * ``github_stat_pr_bar_{owner}_{repo}.png`` — diagramme empilé par dépôt * ``github_stat_pr_heatmap_{owner}_{repo}.png`` — heatmap par dépôt """ @@ -320,6 +321,46 @@ def plot_heatmap(pivot: pd.DataFrame, title: str, output_path: pathlib.Path) -> print(f" → {output_path}") +def plot_lines_by_repo( + weekly: pd.DataFrame, title: str, output_path: pathlib.Path +) -> None: + """Graphe en lignes : total de PR fusionnées par semaine pour chaque dépôt. + + Chaque dépôt est représenté par une ligne, ce qui permet de comparer + visuellement l'activité entre dépôts. + """ + repo_weekly = ( + weekly.groupby(["repo", "week"])["pr_count"].sum().reset_index() + ) + all_weeks = sorted(repo_weekly["week"].unique()) + + fig, ax = plt.subplots(figsize=(14, 5)) + for repo_name, grp in repo_weekly.groupby("repo"): + grp_indexed = grp.set_index("week").reindex(all_weeks, fill_value=0) + week_nums = mdates.date2num( + pd.to_datetime(grp_indexed.index).to_pydatetime() + ) + ax.plot( + week_nums, + grp_indexed["pr_count"].values, + marker="o", + markersize=3, + label=repo_name, + ) + + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d")) + ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO, interval=4)) + plt.xticks(rotation=45, ha="right") + ax.set_xlabel("Semaine") + ax.set_ylabel("Nombre de PR fusionnées") + ax.set_title(title) + ax.legend(loc="upper left", bbox_to_anchor=(1, 1), title="Dépôt") + plt.tight_layout() + plt.savefig(output_path, dpi=150) + plt.close(fig) + print(f" → {output_path}") + + # --------------------------------------------------------------------------- # Point d'entrée # --------------------------------------------------------------------------- @@ -365,6 +406,14 @@ def main() -> None: OUTPUT_DIR / "github_stat_pr_heatmap.png", ) + # 4b. Graphe en lignes comparant les dépôts (toujours affiché si plusieurs repos) + if len(REPOS) > 1: + plot_lines_by_repo( + weekly, + "PR fusionnées par semaine — comparaison entre dépôts", + OUTPUT_DIR / "github_stat_pr_lines.png", + ) + # 5. Graphiques par dépôt (si plusieurs dépôts) if len(REPOS) > 1: print("\nGénération des graphiques par dépôt…") diff --git a/_doc/practice/years/2026/github_stat_pr.rst b/_doc/practice/years/2026/github_stat_pr.rst index 55e82f6..9604946 100644 --- a/_doc/practice/years/2026/github_stat_pr.rst +++ b/_doc/practice/years/2026/github_stat_pr.rst @@ -24,6 +24,7 @@ Images générées : * ``github_stat_pr_bar.png`` — diagramme empilé (toutes repos confondues) * ``github_stat_pr_heatmap.png`` — heatmap (toutes repos confondues) +* ``github_stat_pr_lines.png`` — graphe en lignes comparant les dépôts * ``github_stat_pr_bar_{owner}_{repo}.png`` — diagramme empilé par dépôt * ``github_stat_pr_heatmap_{owner}_{repo}.png`` — heatmap par dépôt From 79fa356e21bd31775de4d44bd5d51dc36317d578 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:12:13 +0000 Subject: [PATCH 11/14] Fix lines chart to aggregate all authors per repo (unfiltered by whitelist) Agent-Logs-Url: https://github.com/sdpython/teachpyx/sessions/f5034d42-d080-4395-b703-b6b441ef8a95 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --- _doc/practice/years/2026/github_stat_pr.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/_doc/practice/years/2026/github_stat_pr.py b/_doc/practice/years/2026/github_stat_pr.py index 60697c0..c47757a 100644 --- a/_doc/practice/years/2026/github_stat_pr.py +++ b/_doc/practice/years/2026/github_stat_pr.py @@ -393,6 +393,9 @@ def main() -> None: weekly = aggregate_weekly(df) pivot_all = make_pivot(weekly) + # Agrégation non filtrée pour le graphe de comparaison entre dépôts + weekly_all = aggregate_weekly(merged_prs) + # 4. Graphiques combinés (toutes repos) print("\nGénération des graphiques combinés…") plot_bar( @@ -406,10 +409,10 @@ def main() -> None: OUTPUT_DIR / "github_stat_pr_heatmap.png", ) - # 4b. Graphe en lignes comparant les dépôts (toujours affiché si plusieurs repos) + # 4b. Graphe en lignes : une ligne par dépôt, auteurs agrégés (données non filtrées) if len(REPOS) > 1: plot_lines_by_repo( - weekly, + weekly_all, "PR fusionnées par semaine — comparaison entre dépôts", OUTPUT_DIR / "github_stat_pr_lines.png", ) From 7b2811b7de288613f0846c61743324c6e3f96c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Dupr=C3=A9?= Date: Fri, 24 Apr 2026 11:15:42 +0200 Subject: [PATCH 12/14] clean --- _doc/practice/years/2026/github_stat_pr.py | 25 ++++------------------ 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/_doc/practice/years/2026/github_stat_pr.py b/_doc/practice/years/2026/github_stat_pr.py index 60697c0..4e73d5c 100644 --- a/_doc/practice/years/2026/github_stat_pr.py +++ b/_doc/practice/years/2026/github_stat_pr.py @@ -49,9 +49,10 @@ # --------------------------------------------------------------------------- REPOS = [ - ("sdpython", "teachpyx"), - ("sdpython", "teachcompute"), - ("sdpython", "onnx-extended"), + # ("sdpython", "teachpyx"), + # ("sdpython", "teachcompute"), + # ("sdpython", "onnx-extended"), + ("sdpython", "onnx-diagnostic"), ("sdpython", "experimental-experiment"), ("xadupre", "yet-another-onnx-builder"), ("xadupre", "mbext"), @@ -414,25 +415,7 @@ def main() -> None: OUTPUT_DIR / "github_stat_pr_lines.png", ) - # 5. Graphiques par dépôt (si plusieurs dépôts) - if len(REPOS) > 1: - print("\nGénération des graphiques par dépôt…") - for repo_name, grp in weekly.groupby("repo"): - pvt = make_pivot(grp) - safe = repo_name.replace("/", "_") - plot_bar( - pvt, - f"PR fusionnées par semaine — {repo_name}", - OUTPUT_DIR / f"github_stat_pr_bar_{safe}.png", - ) - plot_heatmap( - pvt, - f"Heatmap des PR fusionnées — {repo_name}", - OUTPUT_DIR / f"github_stat_pr_heatmap_{safe}.png", - ) - print("\nTerminé.") - if __name__ == "__main__": main() From 7a3b1eecdc9df028b59ce62c8ba44a91544a1236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Dupr=C3=A9?= Date: Fri, 24 Apr 2026 11:30:16 +0200 Subject: [PATCH 13/14] fix --- _doc/practice/years/2026/github_stat_pr.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/_doc/practice/years/2026/github_stat_pr.rst b/_doc/practice/years/2026/github_stat_pr.rst index 9604946..5339c2a 100644 --- a/_doc/practice/years/2026/github_stat_pr.rst +++ b/_doc/practice/years/2026/github_stat_pr.rst @@ -25,8 +25,6 @@ Images générées : * ``github_stat_pr_bar.png`` — diagramme empilé (toutes repos confondues) * ``github_stat_pr_heatmap.png`` — heatmap (toutes repos confondues) * ``github_stat_pr_lines.png`` — graphe en lignes comparant les dépôts -* ``github_stat_pr_bar_{owner}_{repo}.png`` — diagramme empilé par dépôt -* ``github_stat_pr_heatmap_{owner}_{repo}.png`` — heatmap par dépôt .. literalinclude:: github_stat_pr.py :language: python From cff5e7e50815490c12d79643935b3677c00e9e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Dupr=C3=A9?= Date: Fri, 24 Apr 2026 11:40:13 +0200 Subject: [PATCH 14/14] fix --- _doc/practice/years/2026/github_stat_pr.py | 47 ++++++++++++---------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/_doc/practice/years/2026/github_stat_pr.py b/_doc/practice/years/2026/github_stat_pr.py index b7f60f4..9015062 100644 --- a/_doc/practice/years/2026/github_stat_pr.py +++ b/_doc/practice/years/2026/github_stat_pr.py @@ -41,6 +41,7 @@ import matplotlib.dates as mdates import matplotlib.pyplot as plt +import matplotlib.ticker as ticker import pandas as pd import requests @@ -157,10 +158,11 @@ def fetch_merged_prs( "Authentification refusée (401). Vérifiez votre GITHUB_TOKEN." ) from exc if status == 403: - raise RuntimeError( + print( "Accès refusé (403). Vous avez peut-être atteint la limite de " "l'API GitHub (60 requêtes/h sans token). Définissez GITHUB_TOKEN." - ) from exc + ) + break if status == 404: raise RuntimeError( f"Dépôt introuvable (404) : {owner}/{repo}. " @@ -288,9 +290,13 @@ def plot_bar(pivot: pd.DataFrame, title: str, output_path: pathlib.Path) -> None ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d")) ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO, interval=4)) plt.xticks(rotation=45, ha="right") - ax.set_xlabel("Semaine") - ax.set_ylabel("Nombre de PR fusionnées") + ax.set_xlabel("Week") + ax.set_ylabel("PR merged per week") ax.set_title(title) + ax.yaxis.set_major_locator(ticker.MultipleLocator(50)) + ax.yaxis.set_minor_locator(ticker.MultipleLocator(10)) + ax.grid(which="major") + ax.grid(which="minor") ax.legend(loc="upper left", bbox_to_anchor=(1, 1), title="Auteur") plt.tight_layout() plt.savefig(output_path, dpi=150) @@ -316,6 +322,10 @@ def plot_heatmap(pivot: pd.DataFrame, title: str, output_path: pathlib.Path) -> ax.set_title(title) ax.set_xlabel("Semaine") ax.set_ylabel("Auteur") + ax.yaxis.set_major_locator(ticker.MultipleLocator(50)) + ax.yaxis.set_minor_locator(ticker.MultipleLocator(10)) + ax.grid(which="major") + ax.grid(which="minor") plt.tight_layout() plt.savefig(output_path, dpi=150) plt.close(fig) @@ -330,17 +340,13 @@ def plot_lines_by_repo( Chaque dépôt est représenté par une ligne, ce qui permet de comparer visuellement l'activité entre dépôts. """ - repo_weekly = ( - weekly.groupby(["repo", "week"])["pr_count"].sum().reset_index() - ) + repo_weekly = weekly.groupby(["repo", "week"])["pr_count"].sum().reset_index() all_weeks = sorted(repo_weekly["week"].unique()) fig, ax = plt.subplots(figsize=(14, 5)) for repo_name, grp in repo_weekly.groupby("repo"): grp_indexed = grp.set_index("week").reindex(all_weeks, fill_value=0) - week_nums = mdates.date2num( - pd.to_datetime(grp_indexed.index).to_pydatetime() - ) + week_nums = mdates.date2num(pd.to_datetime(grp_indexed.index).to_pydatetime()) ax.plot( week_nums, grp_indexed["pr_count"].values, @@ -352,10 +358,14 @@ def plot_lines_by_repo( ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d")) ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO, interval=4)) plt.xticks(rotation=45, ha="right") - ax.set_xlabel("Semaine") - ax.set_ylabel("Nombre de PR fusionnées") + ax.set_xlabel("Week") + ax.set_ylabel("PR merged per week") ax.set_title(title) ax.legend(loc="upper left", bbox_to_anchor=(1, 1), title="Dépôt") + ax.yaxis.set_major_locator(ticker.MultipleLocator(50)) + ax.yaxis.set_minor_locator(ticker.MultipleLocator(10)) + ax.grid(which="major") + ax.grid(which="minor") plt.tight_layout() plt.savefig(output_path, dpi=150) plt.close(fig) @@ -399,26 +409,21 @@ def main() -> None: # 4. Graphiques combinés (toutes repos) print("\nGénération des graphiques combinés…") - plot_bar( - pivot_all, - "PR fusionnées par semaine", - OUTPUT_DIR / "github_stat_pr_bar.png", - ) + plot_bar(pivot_all, "PR merged per week", OUTPUT_DIR / "github_stat_pr_bar.png") plot_heatmap( - pivot_all, - "Heatmap des PR fusionnées", - OUTPUT_DIR / "github_stat_pr_heatmap.png", + pivot_all, "Heatmap of merged PR", OUTPUT_DIR / "github_stat_pr_heatmap.png" ) # 4b. Graphe en lignes : une ligne par dépôt, auteurs agrégés (données non filtrées) if len(REPOS) > 1: plot_lines_by_repo( weekly_all, - "PR fusionnées par semaine — comparaison entre dépôts", + "PR merged per week / repositories", OUTPUT_DIR / "github_stat_pr_lines.png", ) print("\nTerminé.") + if __name__ == "__main__": main()