Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion scripts/notebook_tools/generate_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sha> 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
Expand Down Expand Up @@ -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",
Expand Down
141 changes: 141 additions & 0 deletions scripts/notebook_tools/tests/test_generate_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Loading