diff --git a/scripts/notebook_tools/generate_catalog.py b/scripts/notebook_tools/generate_catalog.py index 5f4c44ba5b8..e4c3c19689f 100644 --- a/scripts/notebook_tools/generate_catalog.py +++ b/scripts/notebook_tools/generate_catalog.py @@ -258,6 +258,37 @@ def classify_creation_cost(git_meta_entry: dict | None) -> dict: # publier un catalogue faux en concluant `success`. GIT_LOG_TIMEOUT_SECONDS = 180 +# Detection de renommages : EXACTEMENT identique au contenu. +# +# Le checkout de `catalog-cron.yml` est un clone PARTIEL (`actions/checkout@v4` +# avec `filter: blob:none`) : le contenu des blobs est absent du runner et +# recupere a la demande depuis le remote promisor. Or la detection de +# renommages par defaut compare le CONTENU des blobs (similarite) -- la passe +# `git log --name-only` devenait donc une commande liee au RESEAU, chaque blob +# candidat etant recupere sur le fil. Le cout suivait la bande passante du +# runner, pas son CPU, son disque ni son overlayfs : d'ou le facteur 50 mesure +# entre runners (3,6 s -> 149 s) et le depassement de la borne de 180 s du +# 2026-11-02 (#24230). +# +# Mesure du 2026-11-02, meme conteneur, meme overlayfs, meme disque, reseau +# injoignable : +# defaut -> rc=128 en 44 s ("fatal: could not fetch from promisor remote") +# -M100% -> rc=0 en 2 s +# --no-renames -> rc=0 en 2 s +# Cote clone local, `GIT_NO_LAZY_FETCH=1` : defaut 6 s / -M100% 1 s / --no-renames 2 s. +# +# `-M100%` garde la detection de renommages mais la restreint aux renommages +# EXACTS (OID de blob identique), que les objets d'arbre repondent sans aucun +# contenu de blob : la commande est donc hors-ligne et deterministe. +# +# Le choix de la variante est fait sur la SEMANTIQUE PUBLIEE, pas sur la +# vitesse : comparees a travers le parseur de production sur les 1746 carnets +# suivis, les deux variantes sans renommages different du comportement +# actuel sur 10 carnets (`--no-renames`) et sur 5 (famille `--diff-filter=AM`), +# la ou `-M100%` n'en differt qu'UN -- et cette difference est une +# CORRECTION (voir le body de la PR). +GIT_LOG_RENAME_FLAG = "-M100%" + # Restreindre l'historique aux notebooks : c'est le seul sous-arbre que le # parser ci-dessous retient, et 40 % de la sortie de `git log` n'en releve pas. # Equivalence verifiee en passant les deux sorties par ce meme parser @@ -353,7 +384,8 @@ def build_git_metadata() -> dict[str, dict]: try: result = subprocess.run( [ - "git", "log", "--name-only", "--format=COMMIT:%H|%ai|%ae|%s", + "git", "log", GIT_LOG_RENAME_FLAG, + "--name-only", "--format=COMMIT:%H|%ai|%ae|%s", "--", GIT_LOG_PATHSPEC, ], capture_output=True, text=True, encoding="utf-8", errors="replace", diff --git a/scripts/notebook_tools/tests/test_generate_catalog.py b/scripts/notebook_tools/tests/test_generate_catalog.py index 50eaf3edaaf..c987f6e04b7 100644 --- a/scripts/notebook_tools/tests/test_generate_catalog.py +++ b/scripts/notebook_tools/tests/test_generate_catalog.py @@ -2490,5 +2490,146 @@ def test_controle_positif_le_chemin_nominal_rend_ses_entrees(self): assert meta["Serie/m.ipynb"]["authors"] == 1 +# --- build_git_metadata : la passe ne doit dependre d'aucun blob (#24230) --- + + +class TestBuildGitMetadataOfflinePartialClone: + """La passe de metadonnees ne doit pas exiger le CONTENU des blobs. + + Le checkout de `catalog-cron.yml` est un clone partiel (`filter: blob:none`) + : le contenu des blobs est absent du runner. La detection de renommages par + defaut compare le contenu des blobs (similarite), donc la passe + `git log --name-only` allait chercher chaque blob candidat sur le RESEAU -- + d'ou le cout variable d'un runner a l'autre et le depassement de la borne + de 180 s du 2026-11-02 (#24230). `-M100%` restreint la detection aux + renommages EXACTS (OID identique), que les objets d'arbre repondent sans + contenu : la passe redevient hors-ligne et deterministe. + + Ces tests verifient la propriete, pas la constante : le controle est un + clone PARTIEL reel, clone sans reseau (`GIT_NO_LAZY_FETCH=1`), ou la forme + par defaut echoue (rc != 0) tandis que la forme de production reussit. + """ + + @staticmethod + def _git(args, cwd=None, env=None): + return subprocess.run( + ["git"] + args, cwd=cwd, env=env, + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + + def test_passe_reussit_sur_un_clone_partiel_ou_la_forme_par_defaut_echoue(self, tmp_path, monkeypatch): + import generate_catalog as gc + + src = tmp_path / "src" + (src / "MyIA.AI.Notebooks" / "Serie").mkdir(parents=True) + self._git(["init", "-q"], cwd=src) + self._git(["config", "user.email", "test@example.org"], cwd=src) + self._git(["config", "user.name", "Test"], cwd=src) + # Sans cela, le serveur local refuse le filtre et rend un clone COMPLET : + # le test ne reproduirait plus rien. + self._git(["config", "uploadpack.allowfilter", "true"], cwd=src) + self._git(["config", "uploadpack.allowanysha1inwant", "true"], cwd=src) + + # Un carnet renomme ET modifie de facon SIMILAIRE (meme taille, deux + # caracteres changes) : c'est la condition pour que git compare + # effectivement le contenu des deux blobs. Un renommage dont le contenu + # change beaucoup est ecarte par l'heuristique de taille, sans jamais + # lire un blob -- le controle serait alors muet. + ancien = src / "MyIA.AI.Notebooks" / "Serie" / "ancien.ipynb" + corps = ( + '{"cells": [{"cell_type": "code", "source": ["import math\\n' + 'x = math.pi\\ny = math.e\\nz = math.tau\\nw = math.inf"], ' + '"metadata": {}, "outputs": [], "execution_count": 1}], ' + '"metadata": {}, "nbformat": 4, "nbformat_minor": 5}\n' + ) + ancien.write_text(corps, encoding="utf-8") + self._git(["add", "-A"], cwd=src) + self._git(["commit", "-q", "-m", "feat: carnet initial"], cwd=src) + self._git(["mv", "MyIA.AI.Notebooks/Serie/ancien.ipynb", + "MyIA.AI.Notebooks/Serie/neuf.ipynb"], cwd=src) + (src / "MyIA.AI.Notebooks" / "Serie" / "neuf.ipynb").write_text( + corps.replace("math.pi", "math.tau").replace("math.inf", "math.gamma"), + encoding="utf-8") + self._git(["add", "-A"], cwd=src) + self._git(["commit", "-q", "-m", "feat: renomme et complete"], cwd=src) + + # Clone PARTIEL : le contenu des blobs n'est pas present. + dst = tmp_path / "partial" + clone = self._git(["clone", "-q", "--filter=blob:none", "--no-checkout", + f"file://{src.as_posix()}", str(dst)]) + assert clone.returncode == 0, clone.stderr + filtre = self._git(["config", "remote.origin.partialclonefilter"], cwd=dst) + assert filtre.stdout.strip() == "blob:none", ( + "le clone n'est pas partiel : le test ne reproduirait plus rien" + ) + + # Le remote promisor devient INJOIGNABLE : c'est l'etat du runner du + # 2026-11-02, ou la recuperation du blob manquant est un echec DUR. + # (`GIT_NO_LAZY_FETCH=1` ne conviendrait pas : git saute alors le + # fetch en silence, degrade la sortie et ne reproduit pas l'echec.) + injoignable = self._git(["remote", "set-url", "origin", + "file:///nonexistent-repo-24230/repo.git"], cwd=dst) + assert injoignable.returncode == 0, injoignable.stderr + + # CONTROLE : la forme par defaut echoue -- exactement l'echec du + # runner. Un detecteur se valide par son faux negatif : si cette forme + # reussissait, le test ne prouverait rien. + defaut = self._git(["log", "--name-only", "--format=COMMIT:%H", + "--", "MyIA.AI.Notebooks"], cwd=dst) + assert defaut.returncode != 0, ( + "la forme par defaut devrait echouer sans contenu de blob ; " + "le controle ne prouve plus rien" + ) + + # La forme de production, elle, passe : meme clone, meme remote + # injoignable, memes objets manquants. + reussi = self._git(["log", gc.GIT_LOG_RENAME_FLAG, "--name-only", + "--format=COMMIT:%H", "--", "MyIA.AI.Notebooks"], + cwd=dst) + assert reussi.returncode == 0, reussi.stderr + + monkeypatch.setattr(gc, "REPO_ROOT", dst) + meta = gc.build_git_metadata() + + assert meta, "la passe doit dater les carnets du clone partiel" + assert set(meta) == {"Serie/ancien.ipynb", "Serie/neuf.ipynb"} + # Le renommage n'est pas exact (le contenu a change) : `-M100%` le + # rend donc explicite -- une suppression ET un ajout, chacun compte. + assert meta["Serie/neuf.ipynb"]["revisions"] == 1 + assert meta["Serie/ancien.ipynb"]["revisions"] == 2 + + def test_le_plafond_bruyant_de_14831_est_conserve(self): + """La correction du reseau ne doit pas debrayer la garde de #14831. + + #14831 a rendu l'echec BRUYANT (lever) la ou le script rendait `{}` en + silence et publiait un catalogue vert et faux. Une passe devenue + silencieuse -- ou un plafond devenu muet -- rouvrirait exactement ce + defaut : le garde-fou reste, au meme niveau. + """ + import generate_catalog as gc + + with patch("generate_catalog.subprocess.run") as mock_run: + mock_run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="") + gc.build_git_metadata() + + # `build_git_metadata` lance aussi `git rev-parse` (frontieres de + # greffe) : on cible l'appel `git log`, pas le dernier appel. + logs = [c for c in mock_run.call_args_list + if c.args and c.args[0][:2] == ["git", "log"]] + assert len(logs) == 1, f"un seul 'git log' attendu, trouve {len(logs)}" + argv = logs[0].args[0] + assert gc.GIT_LOG_RENAME_FLAG in argv + assert "--name-only" in argv + assert logs[0].kwargs["timeout"] == gc.GIT_LOG_TIMEOUT_SECONDS + + with patch("generate_catalog.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired( + cmd="git log", timeout=gc.GIT_LOG_TIMEOUT_SECONDS, + ) + with pytest.raises(gc.GitMetadataUnavailable) as exc: + gc.build_git_metadata() + assert str(gc.GIT_LOG_TIMEOUT_SECONDS) in str(exc.value) + + if __name__ == "__main__": pytest.main([__file__, "-v"])