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
44 changes: 35 additions & 9 deletions .github/workflows/notebook-link-render-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,30 +72,52 @@ jobs:
mapfile -t PR_READMES < <(git diff --name-only --diff-filter=d "$MERGE_BASE" HEAD -- 'MyIA.AI.Notebooks/**/README.md' | sort -u)
n_readmes=${#PR_READMES[@]}
pr_brut=0; main_brut=0
pr_manque=0; main_manque=0
scan_errors=0
for readme in "${PR_READMES[@]}"; do
[ -z "$readme" ] && continue
# Le parent du README dans le repo (le checker a besoin du VRAI chemin
# pour resoudre les liens .ipynb relatifs -- le dump dans /tmp casse
# la resolution par defaut, d'ou --link-root).
readme_dir="$(dirname "$readme")"
# Un echec du checker ne compte PAS comme 0 lien : il incremente
# scan_errors, sinon le delta peut sous-estimer un +BRUT reel
# (#14435 rem. 1 -- zero fabrique par organe muet).
# Version PR (HEAD).
if git show "HEAD:$readme" > /tmp/_pr_readme.md 2>/dev/null; then
p=$(cd /tmp && python "$GITHUB_WORKSPACE/scripts/notebook_tools/check_notebook_link_render.py" --tracked-only --json /tmp/_pr_readme.md --link-root "$GITHUB_WORKSPACE/$readme_dir" 2>/dev/null \
| python -c "import json,sys; print(json.load(sys.stdin)['summary']['totals']['BRUT'])" 2>/dev/null || echo 0)
pr_brut=$((pr_brut + p))
if line=$(cd /tmp && python "$GITHUB_WORKSPACE/scripts/notebook_tools/check_notebook_link_render.py" --tracked-only --json /tmp/_pr_readme.md --link-root "$GITHUB_WORKSPACE/$readme_dir" 2>/dev/null \
| python -c "import json,sys; t=json.load(sys.stdin)['summary']['totals']; print(t['BRUT'], t['MANQUE'])" 2>/dev/null) && [ -n "$line" ]; then
read -r p_brut p_manque <<< "$line"
pr_brut=$((pr_brut + p_brut))
pr_manque=$((pr_manque + p_manque))
else
scan_errors=$((scan_errors + 1))
fi
fi
# Version main (merge-base).
if git show "$MERGE_BASE:$readme" > /tmp/_main_readme.md 2>/dev/null; then
m=$(cd /tmp && python "$GITHUB_WORKSPACE/scripts/notebook_tools/check_notebook_link_render.py" --tracked-only --json /tmp/_main_readme.md --link-root "$GITHUB_WORKSPACE/$readme_dir" 2>/dev/null \
| python -c "import json,sys; print(json.load(sys.stdin)['summary']['totals']['BRUT'])" 2>/dev/null || echo 0)
main_brut=$((main_brut + m))
if line=$(cd /tmp && python "$GITHUB_WORKSPACE/scripts/notebook_tools/check_notebook_link_render.py" --tracked-only --json /tmp/_main_readme.md --link-root "$GITHUB_WORKSPACE/$readme_dir" 2>/dev/null \
| python -c "import json,sys; t=json.load(sys.stdin)['summary']['totals']; print(t['BRUT'], t['MANQUE'])" 2>/dev/null) && [ -n "$line" ]; then
read -r m_brut m_manque <<< "$line"
main_brut=$((main_brut + m_brut))
main_manque=$((main_manque + m_manque))
else
scan_errors=$((scan_errors + 1))
fi
fi
done
delta=$((pr_brut - main_brut))
if [ "$delta" -gt 0 ]; then delta_sign="+"; else delta_sign=""; fi
echo "PR READMEs scanned: $n_readmes | PR BRUT: $pr_brut | main BRUT: $main_brut | delta: ${delta_sign}${delta}"
manque_delta=$((pr_manque - main_manque))
if [ "$manque_delta" -gt 0 ]; then manque_sign="+"; else manque_sign=""; fi
echo "PR READMEs scanned: $n_readmes | PR BRUT: $pr_brut | main BRUT: $main_brut | delta: ${delta_sign}${delta} | MANQUE: ${pr_manque} vs ${main_manque} (${manque_sign}${manque_delta}) | scan-errors: $scan_errors"
echo "delta_brut=$delta" >> "$GITHUB_OUTPUT"
echo "delta_sign=$delta_sign" >> "$GITHUB_OUTPUT"
# Le delta trace aussi MANQUE : un README qui perd un notebook (lien
# dangling) ne reste plus a delta BRUT 0 (#14435 rem. 2).
echo "delta_manque=$manque_delta" >> "$GITHUB_OUTPUT"
echo "pr_manque=$pr_manque" >> "$GITHUB_OUTPUT"
echo "scan_errors=$scan_errors" >> "$GITHUB_OUTPUT"
echo "pr_readmes=$n_readmes" >> "$GITHUB_OUTPUT"
set -e

Expand All @@ -104,11 +126,15 @@ jobs:
with:
script: |
// Label delta signe (jamais bloquant) sur la PR -- ce que la PR a
// reellement modifie (N readmes scannes, +/- BRUT).
// reellement modifie (N readmes scannes, +/- BRUT, delta MANQUE,
// et echecs de scan nommes : un organe muet rend la meme valeur
// qu'un organe qui a mesure et n'a rien trouve, #14435 rem. 1-2).
const delta = '${{ steps.scan.outputs.delta_brut }}';
const deltaSign = '${{ steps.scan.outputs.delta_sign }}';
const prReadmes = '${{ steps.scan.outputs.pr_readmes }}';
const deltaLabel = `notebook-link-render-delta: ${deltaSign}${delta} (${prReadmes} readmes)`;
const manque = '${{ steps.scan.outputs.delta_manque }}';
const scanErrors = '${{ steps.scan.outputs.scan_errors }}';
const deltaLabel = `notebook-link-render-delta: ${deltaSign}${delta} (${prReadmes} readmes, manque ${manque}, scan-err ${scanErrors})`;

// Cleanup des anciens labels notebook-link-render-* de runs precedents.
let existing = [];
Expand Down
8 changes: 6 additions & 2 deletions scripts/notebook_tools/scan_arxiv_citations.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@

ARXIV_RE = re.compile(r"\barXiv:\s*(\d{4}\.\d{4,5})\b")
# Legacy : arXiv:cs.LG/NNNNNNN ou arXiv:math.AG/NNNNNNN ou arXiv:hep-th/NNNNNNN
# Le préfixe d'archive FAIT partie de l'identifiant legacy : l'API arXiv
# rejette (400) un identifiant ancien réduit à ses 7 chiffres. La capture
# inclut donc le préfixe quand il est présent (#14435, rem. 3).
ARXIV_RE_LEGACY = re.compile(
r"\barXiv:\s*(?:[a-z\-]+(?:\.[A-Z]{2})?/)?(\d{7})\b"
r"\barXiv:\s*((?:[a-z\-]+(?:\.[A-Z]{2})?/)?\d{7})\b"
)


Expand Down Expand Up @@ -56,7 +59,8 @@ def scan_notebook(nb_path: Path):
for m in ARXIV_RE_LEGACY.finditer(src):
arxiv_id = m.group(1)
# éviter les faux positifs sur les modernes (7 chiffres != 9)
if len(arxiv_id) == 7:
digits = arxiv_id.rsplit("/", 1)[-1]
if len(digits) == 7:
found.append((idx, arxiv_id))
return found

Expand Down
6 changes: 4 additions & 2 deletions scripts/notebook_tools/scan_pr_arxiv_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
from pathlib import Path

ARXIV_RE = re.compile(r"\barXiv:\s*(\d{4}\.\d{4,5})\b")
ARXIV_RE_LEGACY = re.compile(r"\barXiv:\s*(?:[a-z\-]+(?:\.[A-Z]{2})?/)?(\d{7})\b")
# Le préfixe d'archive fait partie de l'ID legacy (bare 7 chiffres = 400 API,
# #14435 rem. 3) — la capture l'inclut quand il est présent.
ARXIV_RE_LEGACY = re.compile(r"\barXiv:\s*((?:[a-z\-]+(?:\.[A-Z]{2})?/)?\d{7})\b")


def run(cmd, cwd=None):
Expand Down Expand Up @@ -76,7 +78,7 @@ def extract_arxiv_from_text(text):
ids.add(m.group(1))
for m in ARXIV_RE_LEGACY.finditer(text):
aid = m.group(1)
if len(aid) == 7:
if len(aid.rsplit("/", 1)[-1]) == 7:
ids.add(aid)
return ids

Expand Down
64 changes: 64 additions & 0 deletions scripts/notebook_tools/tests/test_scan_arxiv_prefix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Tests du fix #14435 (rem. 3) : l'ID legacy arXiv garde son préfixe d'archive.

Un identifiant ancien réduit à ses 7 chiffres est rejeté par l'API arXiv
(400) — le préfixe (`cs/`, `quant-ph/`, `cat/`, `math.AG/`) FAIT partie de
l'identifiant. Ces tests échouent si un scanner se remet à le capturer nu.
"""
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from scan_arxiv_citations import scan_notebook # noqa: E402
from scan_pr_arxiv_diff import extract_arxiv_from_text # noqa: E402


def _nb(tmp_path, md_text):
"""Écrire un notebook minimal d'une cellule markdown, retourner son chemin."""
import nbformat
nb = nbformat.v4.new_notebook()
nb.cells = [nbformat.v4.new_markdown_cell(md_text)]
p = tmp_path / "nb.ipynb"
nbformat.write(nb, p)
return p


class TestPrefixedLegacyIds:
def test_prefixed_id_keeps_its_prefix_scan_citations(self, tmp_path):
p = _nb(tmp_path, "Voir arXiv:cs/0011047 pour le détail.")
assert scan_notebook(p) == [(0, "cs/0011047")]

def test_prefixed_subject_id_keeps_prefix_scan_citations(self, tmp_path):
p = _nb(tmp_path, "arXiv:math.AG/0309136 et arXiv:quant-ph/0604079.")
assert {aid for _, aid in scan_notebook(p)} == {"math.AG/0309136", "quant-ph/0604079"}

def test_cat_prefix_is_a_valid_archive_prefix(self, tmp_path):
# `cat` matche [a-z\-]+ -- le cas nommé par #14435 rem. 3.
p = _nb(tmp_path, "arXiv:cat/0703165.")
assert scan_notebook(p) == [(0, "cat/0703165")]

def test_prefixed_id_keeps_its_prefix_pr_diff(self):
assert extract_arxiv_from_text("arXiv:cs/0011047") == {"cs/0011047"}

def test_bare_seven_digit_id_unchanged(self):
# Un legacy SANS préfixe reste nu (l'auteur l'a écrit ainsi).
assert extract_arxiv_from_text("arXiv:0703123") == {"0703123"}
assert extract_arxiv_from_text("arXiv:0703123") == {"0703123"}

def test_modern_id_unchanged(self):
assert extract_arxiv_from_text("arXiv:2301.12345") == {"2301.12345"}
assert extract_arxiv_from_text("arXiv:2301.12345, arXiv:cs/0011047") == {
"2301.12345", "cs/0011047"
}

def test_eight_digit_sequence_is_not_legacy(self):
# 8 chiffres : ni moderne (pas de point) ni legacy (7 chiffres) -> rien.
assert extract_arxiv_from_text("arXiv:07031234") == set()


class TestGuardAgainstRegression:
def test_regex_capture_group_includes_prefix(self):
import scan_pr_arxiv_diff as spd
m = spd.ARXIV_RE_LEGACY.search("arXiv:quant-ph/0604079")
assert m is not None
assert m.group(1) == "quant-ph/0604079" # nu : "0604079" = le défaut
Loading