diff --git a/.gitignore b/.gitignore index 820c39d59e..1cc023159e 100644 --- a/.gitignore +++ b/.gitignore @@ -1127,6 +1127,12 @@ MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/_sl14_runs/ scripts/notebook_tools/probes/*_out.ipynb scripts/notebook_tools/probes/_deps/ +# #17361 — le helper `scripts/ci/dotnet_preload_packages.py` depose son mirror +# d'assemblies A COTE du notebook qui les reference (`#r "./_deps/X.dll"`). +# Tout dossier `_deps/`, a n'importe quelle profondeur, est un artefact +# regenerable (copie du cache NuGet) — aucun n'est suivi dans le depot. +_deps/ + # Livecoding video pipeline (#15604) : artefacts capture/PoC -- media regenerable, local only out/ diff --git a/docs/reference/dotnet-restore-rfc-17361.md b/docs/reference/dotnet-restore-rfc-17361.md index 85f3380dd4..2f5ba81683 100644 --- a/docs/reference/dotnet-restore-rfc-17361.md +++ b/docs/reference/dotnet-restore-rfc-17361.md @@ -34,13 +34,21 @@ L'hypothèse « cache chaud explique la non-reproduction » est **réfutée** pa **Précharger** les packages NuGet en assemblies locales (résolution + copie par helper Python), puis référencer par `#r "file.dll"` dans les notebooks : -```python -# scripts/ci/dotnet_preload_packages.py — TODO: à implémenter -# - Lit une liste de packages (ex: ikvm, quikgraph, csvhelper) -# - Pour chaque : nuget restore -> copie .dll dans .dotnet_packages/// -# - Émet un manifest .NET-packages.json avec paths résolus +```bash +# scripts/ci/dotnet_preload_packages.py — LIVRÉ (livrable 2) +# Lit une liste de packages, résout le cache NuGet global (ou le peuple par +# `dotnet restore` s'il est absent), copie les .dll dans `_deps/` et émet +# `_deps/.NET-packages.json` avec les lignes `#r` prêtes à coller. +python scripts/ci/dotnet_preload_packages.py QuikGraph==2.5.0 CsvHelper==33.0.1 IKVM ``` +La copie est **à plat** dans `_deps/` — et non dans `.dotnet_packages///` +comme l'esquissait la première rédaction de ce RFC : `#r` est résolu au parse-time +et exige un littéral relatif au notebook, dont la forme mesurée (c.790) est +`./_deps/.dll`. Les dépendances transitives ne sont **pas** résolues — un +package qui en déclare (IKVM par exemple) doit les lister lui-même sur la ligne +de commande. + ```csharp // Dans un notebook .NET, au lieu de : // #r "nuget: QuikGraph, 2.5.0" ← a planté au 2ᵉ restore dans la session (c.760 ; non déterministe) @@ -52,7 +60,28 @@ L'hypothèse « cache chaud explique la non-reproduction » est **réfutée** pa // (DLL copiée au préalable depuis le cache NuGet vers _deps/, gitignore) ``` -La mesure discriminante c.760 (probe E) **réfute** l'hypothèse initiale « `file.dll` réinitialise le `PackageRestoreContext` » : un `#r "nuget:"` après un `#r "file.dll"` a levé `ArgumentException` à chacune des 3 exécutions de c.760. **Mais** la re-production a échoué sur les 2 re-tentatives du 2026-09-23 (c.803 cache chaud, c.807 cache froid) : le bug est **non déterministe**. La recommandation reste néanmoins **univoque et défensive** : **préchargement complet seul** (tous les packages NuGet en `.dll` locaux, via `./_deps/` relatif en attendant le helper `dotnet_preload_packages.py`), pas de mix `file.dll` + `nuget` dans la même session kernel — quand l'exception se produit, elle tue la cellule sans contournement runtime. +La mesure discriminante c.760 (probe E) **réfute** l'hypothèse initiale « `file.dll` réinitialise le `PackageRestoreContext` » : un `#r "nuget:"` après un `#r "file.dll"` a levé `ArgumentException` à chacune des 3 exécutions de c.760. **Mais** la re-production a échoué sur les 2 re-tentatives du 2026-09-23 (c.803 cache chaud, c.807 cache froid) : le bug est **non déterministe**. La recommandation reste néanmoins **univoque et défensive** : **préchargement complet seul** (tous les packages NuGet en `.dll` locaux, via `./_deps/` relatif et le helper `scripts/ci/dotnet_preload_packages.py`), pas de mix `file.dll` + `nuget` dans la même session kernel — quand l'exception se produit, elle tue la cellule sans contournement runtime. + +## Pilot du livrable 3 : le remplacement n'est PAS équivalent (mesuré 2026-09-25) + +Le livrable 3 (« convertir `#r "nuget:"` en `#r` local ») suppose que les deux formes sont interchangeables. **Elles ne le sont pas.** Mesure sur `MyIA.AI.Notebooks/Search/Part2-CSP/CSP-1-Fundamentals-CSharp.ipynb` — le notebook du dépôt le plus exposé au bug (3 restores NuGet dans une seule cellule, cellule 3 : `IKVM`, `IKVM.Image`, `IKVM.Image.runtime.win-x64`), exécuté sur po-2024 avec le kernel `.net-csharp` et le pin cluster `1.0.617701` : + +| Bras | Cellule 3 (les `#r`) | Résultat | Diagnostic | +|---|---|---|---| +| **0 — original** | 3 × `#r "nuget:"` | **19/19 OK**, 0 erreur, 14,1 s | — | +| **A — converti** | 3 × `#r "./_deps/…"` (helper) | **16/19**, 3 erreurs (cellules 32, 34, 37) | `IKVM.Runtime.InternalException: Could not locate ikvm home path` | +| **C — mixte** | `IKVM` local + les 2 packages image en `nuget:` | **15/19**, 4 erreurs (cellule 3 *et* 32-37) | `IKVM.Image.targets(45,9): error MSB4036: Tâche "IkvmResolveNearestRuntimeIdentifier" introuvable` | + +Le bras C est celui qui explique les deux autres : les trois `#r` ne sont pas de simples **références**, ce sont eux qui font restaurer à `Microsoft.DotNet.Interactive.PackageManagement` l'**arbre IKVM complet** (`IKVM.MSBuild` fournit la tâche MSBuild que `IKVM.Image.targets` invoque ; l'image `any/any` + `win-x64` fournit le home que `IKVM.Runtime` cherche au premier type `java.*`). Retirer ou scinder les `#r` casse ce graphe : le bras C échoue **dès la cellule 3** sur la tâche MSBuild manquante, le bras A va plus loin mais échoue au premier appel Java faute de home. + +Deux conséquences pour le livrable 3, dans cet ordre : + +1. **Il est réfuté pour la famille IKVM** — et cette famille est justement celle qui porte le plus de restores par cellule (Choco, Tweety, RDF.Net). +2. **`IKVM.Image` et `IKVM.Image.runtime.win-x64` ne sont pas exprimables en `#r` local du tout** : leurs dossiers `lib//` ne contiennent qu'un `_._`, la convention NuGet « ce TFM est compatible, mais ce package n'apporte aucune assembly ». Le helper le dit correctement (`aucune assembly dans …/lib`), ce qui n'est pas un défaut de l'outil mais la limite du remplacement. + +S'y ajoute un coût de distribution, indépendant du bug : `_deps/` est **gitignore**, donc un notebook committé en `#r "./_deps/X.dll"` casse pour quiconque clone — un échec **déterministe** (« fichier introuvable ») en échange d'un bug **non déterministe**. + +**Décision qui en découle** : le helper reste un **outil de réparation à la demande** — à invoquer quand l'exception se produit, sur le notebook concerné — et **non** une convention à généraliser aux notebooks pédagogiques. Le bras 0 montre au passage une 4ᵉ non-reproduction du bug : 3 restores dans une cellule, tous réussis (corrobore c.803 et c.807, après les 3 repros de c.760). ## Cause racine (out-of-scope) @@ -72,6 +101,6 @@ Bug interne dans `Microsoft.DotNet.Interactive.PackageManagement.PackageRestoreR ## Livrables possibles (par ordre de coût) 1. **MAINTENU** : ce RFC documente le bug et la workaround pour les pairs. -2. **COURT TERME** : `scripts/ci/dotnet_preload_packages.py` (helper ~50 lignes) + convention `.net-csharp` notebooks. -3. **MOYEN TERME** : audit complet de tous les notebooks `.net-csharp` pour convertir les `#r "nuget:"` en `#r "file.dll"`. +2. **LIVRÉ (outillage)** : `scripts/ci/dotnet_preload_packages.py` + `scripts/tests/test_dotnet_preload_packages.py` (41 cas) + entrée `.gitignore` de `_deps/`. Usage retenu (cf pilot) : **réparation à la demande** sur un notebook qui a effectivement levé l'exception, **pas** une convention à généraliser. +3. **MOYEN TERME — RÉFUTÉ par la mesure** (pilot du 2026-09-25, section dédiée) : convertir tous les `.net-csharp` en `#r` local n'est pas un remplacement équivalent (les `#r "nuget:"` portent le graphe de restauration dont dépendent `IKVM.MSBuild` et le home JVM), et `_deps/` étant gitignore, la conversion rendrait le notebook déterministiquement cassé pour tout clone. **Ne pas lancer cet audit comme conversion de masse.** Le geste utile restant : convertir au cas par cas, **quand** l'exception s'est effectivement produite sur ce notebook. 4. **LONG TERME** : fix upstream + bump version cluster. diff --git a/scripts/ci/dotnet_preload_packages.py b/scripts/ci/dotnet_preload_packages.py new file mode 100644 index 0000000000..aaa57f18e3 --- /dev/null +++ b/scripts/ci/dotnet_preload_packages.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""Helper de prechargement NuGet -> assemblies locales (#17361). + +POURQUOI CE SCRIPT EXISTE +------------------------- +Sur dotnet-interactive 1.0.617701 (pin cluster), le 2e `#r "nuget:"` d'une +session kernel leve par intermittence + + System.ArgumentException: Must provide errors when succeeded is false + +dans `PackageRestoreResult..ctor` (mesure c.760 : repro 3 fois sur 3 le +2026-09-22, puis NON reproduit sur les 2 re-tentatives du 2026-09-23, a cache +chaud comme a cache froid). Le bug est donc **non deterministe** et sa cause +racine est interne a `Microsoft.DotNet.Interactive.PackageManagement`. + +Quand l'exception tombe, elle tue la cellule sans contournement runtime : le +seul chemin defensif est de ne plus appeler `#r "nuget:"` du tout, et de +precharger chaque package en assembly locale referencee par chemin. + + #r "nuget: QuikGraph, 2.5.0" -> #r "./_deps/QuikGraph.dll" + +Le `#r` est resolu au PARSE-TIME et n'accepte ni variable ni interpolation : +le chemin doit etre un LITTERAL relatif au dossier du notebook (forme mesuree +c.790/c.803). D'ou ce helper : il resout le cache NuGet, copie les DLL a plat +dans `_deps/` et emet un manifest donnant les lignes `#r` pretes a coller. + +Contexte et mesures : `docs/reference/dotnet-restore-rfc-17361.md` (livrable 2). + +PORTEE -- ce que ce helper ne fait PAS +-------------------------------------- +- **Pas de dependances transitives.** Un package qui en declare d'autres (IKVM + par exemple) doit les lister lui-meme dans la ligne de commande. Ce choix est + delibere : copier l'arbre de dependances entier noierait `_deps/` et + masquerait ce qui est reellement reference. +- **Pas de devinette de TFM.** Seuls les dossiers `lib//` sont lus ; un + package sans `lib` (meta-package, `ref-only`) est signale comme tel, pas + approxime. +- **`dotnet restore` seulement en dernier recours** : il n'est invoque que si + le package est absent du cache global NuGet. Un cache deja peuple se lit hors + ligne, sans SDK installe. + +USAGE +----- + python scripts/ci/dotnet_preload_packages.py QuikGraph==2.5.0 CsvHelper==33.0.1 + python scripts/ci/dotnet_preload_packages.py IKVM --dest _deps --json + +Code de sortie : 0 si TOUS les packages ont ete resolus, 1 sinon (le detail par +package est dans le manifest et sur stdout). +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# Version du format de manifest -- incrementee si les cles changent. +SCHEMA = 1 + +# Ordre de preference des Target Frameworks : le premier trouve dans le package +# gagne. .NET 9 est le TFM du cluster (CLAUDE.md section E) ; les `netstandard` +# couvrent les packages anciens qui n'ont pas de build moderne. +TFM_PREFERENCE = ( + "net9.0", + "net8.0", + "net7.0", + "net6.0", + "netstandard2.1", + "netstandard2.0", +) + +# TFM du projet jetable utilise par `dotnet restore`. Sans importance pour le +# telechargement (le projet ne compile pas), mais NuGet exige un TFM valide. +RESTORE_TFM = "net9.0" + +# `Name`, `Name==1.2.3` ou `Name@1.2.3`. Noms et versions NuGet s'ecrivent en +# [A-Za-z0-9_.-] ; le `+` des metadonnees de build est volontairement exclu +# (ambigu avec une future syntaxe de selection). +_SPEC_RE = re.compile( + r"^(?P[A-Za-z0-9_.-]+)(?:(?:==|@)(?P[A-Za-z0-9_.-]+))?$" +) + + +def parse_spec(spec: str) -> tuple[str, str | None]: + """`Name[==version|@version]` -> `(nom, version ou None)`. + + Leve `ValueError` si la specification est vide ou mal formee -- l'appelant + transforme cela en entree d'erreur du manifest, jamais en crash. + """ + match = _SPEC_RE.match(spec.strip()) + if match is None: + raise ValueError( + f"specification invalide {spec!r} : attendu `Nom` ou `Nom==1.2.3`" + ) + return match.group("name"), match.group("version") + + +def nuget_root() -> Path: + """Racine du cache global NuGet (`NUGET_PACKAGES` sinon `~/.nuget/packages`).""" + override = os.environ.get("NUGET_PACKAGES") + if override: + return Path(override) + return Path.home() / ".nuget" / "packages" + + +def _version_key(version: str) -> tuple[tuple[int, ...], int, str]: + """Cle de tri SemVer approximative, suffisante pour choisir le plus recent. + + Deux pieges que le tri lexicographique rate : + - `2.10.0` doit trier AU-DESSUS de `2.9.0` (comparaison numerique) ; + - `1.0.0-rc1` doit trier SOUS `1.0.0` (une prerelease precede sa release). + """ + core, _, pre = version.partition("-") + parts: list[int] = [] + for chunk in core.split("."): + parts.append(int(chunk) if chunk.isdigit() else 0) + while len(parts) < 4: + parts.append(0) + # 0 = prerelease, 1 = release : la release gagne a coeur egal. + return (tuple(parts), 0 if pre else 1, pre) + + +def resolve_version(root: Path, package: str, requested: str | None) -> Path | None: + """Dossier de version dans le cache, ou `None` si le package n'y est pas. + + NuGet ecrit ses dossiers en minuscules : la recherche l'est aussi. Si + `requested` est fourni, la correspondance est EXACTE (pas de repli + silencieux sur une autre version -- un pin non satisfait doit se voir). + """ + package_dir = root / package.lower() + if not package_dir.is_dir(): + return None + if requested is not None: + candidate = package_dir / requested.lower() + return candidate if candidate.is_dir() else None + versions = [child for child in package_dir.iterdir() if child.is_dir()] + if not versions: + return None + return max(versions, key=lambda child: _version_key(child.name)) + + +def pick_assemblies(version_dir: Path) -> list[Path]: + """DLL du meilleur TFM disponible dans `/lib/`, sinon `[]`. + + Repli sur le premier dossier `lib/*` en ordre alphabetique quand aucun TFM + prefere n'est present : mieux qu'un echec, et le manifest dit lequel a ete + pris (le chemin des assemblies copiees). + """ + lib_dir = version_dir / "lib" + if not lib_dir.is_dir(): + return [] + by_tfm = {child.name.lower(): child for child in lib_dir.iterdir() if child.is_dir()} + for tfm in TFM_PREFERENCE: + if tfm in by_tfm: + return sorted(by_tfm[tfm].glob("*.dll")) + if by_tfm: + return sorted(by_tfm[sorted(by_tfm)[0]].glob("*.dll")) + return [] + + +def restore_via_dotnet( + package: str, version: str | None, timeout: int = 300 +) -> tuple[bool, str]: + """Peuple le cache global NuGet par un `dotnet restore` de projet jetable. + + Le projet utilise `PackageDownload` et non `PackageReference` : c'est + l'item NuGet prevu pour *telecharger* un package sans contrainte de + compatibilite de framework. Un `PackageReference` echouerait en NU1202 sur + tout package qui ne cible pas le TFM du projet -- ce qui n'a aucun sens ici, + puisque rien ne sera compile. + + Rend `(ok, message)`. Aucune exception ne remonte : un `dotnet` absent ou un + reseau coupe sont des echecs ORDINAIRES du helper, pas un crash -- le helper + reste utilisable hors ligne sur un cache deja peuple. + """ + if version is None: + return False, ( + f"{package} absent du cache et aucune version epinglee : " + "`dotnet restore` exige `Nom==x.y.z`" + ) + + dotnet = shutil.which("dotnet") + if dotnet is None: + return False, "dotnet introuvable dans le PATH" + + project = ( + '\n' + " \n" + f" {RESTORE_TFM}\n" + " false\n" + " \n" + " \n" + f' \n' + " \n" + "\n" + ) + + try: + with tempfile.TemporaryDirectory(prefix="dotnet-preload-") as tmp: + project_path = Path(tmp) / "preload.csproj" + project_path.write_text(project, encoding="utf-8", newline="\n") + proc = subprocess.run( + [dotnet, "restore", str(project_path), "--nologo", "-v", "quiet"], + capture_output=True, + text=True, + # `dotnet` ecrit ses erreurs en francais accentue ("Aucun package + # associe...") : sans encoding explicite, un hote cp1252 leve + # UnicodeDecodeError sur ce payload UTF-8 (#13140 / #12811). + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return False, f"dotnet restore timeout apres {timeout}s" + except OSError as exc: # binaire present mais non executable + return False, f"dotnet restore impossible a lancer : {exc}" + + if proc.returncode != 0: + # `dotnet restore` ecrit ses erreurs sur STDOUT en mode quiet (mesure + # 2026-09-25 : un package inexistant rend stderr vide) -- lire les deux + # flux, sinon le diagnostic se reduit a "(stderr vide)". + raw = (proc.stdout or "") + "\n" + (proc.stderr or "") + lines = [line.strip() for line in raw.splitlines() if line.strip()] + tail = " | ".join(lines[-3:]) if lines else "(aucune sortie)" + return False, f"dotnet restore rc={proc.returncode} : {tail}" + return True, f"dotnet restore rc=0 ({package} {version})" + + +def _reference_line(dest: Path, dll_name: str) -> str: + """Ligne `#r` a coller dans le notebook, forme relative mesuree c.790. + + Un chemin relatif est prefixe de `./` (la forme litterale verifiee) ; un + chemin absolu est rendu tel quel, faute de pouvoir etre relatif au notebook. + """ + if dest.is_absolute(): + return f'#r "{dest.as_posix()}/{dll_name}"' + return f'#r "./{dest.as_posix()}/{dll_name}"' + + +def main(argv: list[str] | None = None, restorer=restore_via_dotnet) -> int: + """Point d'entree. `restorer` est injectable pour les tests (pas de reseau).""" + parser = argparse.ArgumentParser( + prog="dotnet_preload_packages", + description="Precharge des packages NuGet en assemblies locales (#17361).", + ) + parser.add_argument( + "packages", + nargs="+", + help="packages a precharger : `Nom` ou `Nom==1.2.3`", + ) + parser.add_argument( + "--dest", + default="_deps", + help="dossier de destination des DLL (defaut : _deps, gitignore)", + ) + parser.add_argument( + "--manifest", + default=None, + help="chemin du manifest (defaut : /.NET-packages.json)", + ) + parser.add_argument( + "--json", + action="store_true", + help="ecrire le manifest complet sur stdout au lieu du resume", + ) + args = parser.parse_args(argv) + + root = nuget_root() + dest = Path(args.dest) + dest.mkdir(parents=True, exist_ok=True) + manifest_path = Path(args.manifest) if args.manifest else dest / ".NET-packages.json" + + entries: list[dict] = [] + for spec in args.packages: + entry: dict = {"spec": spec} + try: + name, requested = parse_spec(spec) + except ValueError as exc: + entries.append({**entry, "error": str(exc)}) + continue + + entry.update({"package": name, "requested_version": requested}) + version_dir = resolve_version(root, name, requested) + + if version_dir is None: + ok, message = restorer(name, requested) + entry["restore"] = message + if not ok: + entries.append({**entry, "error": message}) + continue + version_dir = resolve_version(root, name, requested) + if version_dir is None: + entries.append( + { + **entry, + "error": "restore annonce rc=0 mais le package reste " + "introuvable dans le cache", + } + ) + continue + + entry["version"] = version_dir.name + dlls = pick_assemblies(version_dir) + if not dlls: + entries.append( + { + **entry, + "error": f"aucune assembly dans {(version_dir / 'lib').as_posix()} " + "(meta-package ou ref-only ?)", + } + ) + continue + + copied: list[str] = [] + for dll in dlls: + target = dest / dll.name + shutil.copy2(dll, target) + copied.append(target.as_posix()) + entry["assemblies"] = copied + entry["reference"] = [_reference_line(dest, Path(path).name) for path in copied] + entries.append(entry) + + payload = { + "schema": SCHEMA, + "issue": 17361, + "dest": dest.as_posix(), + "nuget_root": root.as_posix(), + "packages": entries, + } + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + newline="\n", + ) + + failed = [entry for entry in entries if "error" in entry] + if args.json: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + else: + for entry in entries: + if "error" in entry: + print(f"[KO] {entry['spec']} : {entry['error']}") + continue + print(f"[OK] {entry['spec']} -> {entry['version']} " + f"({len(entry['assemblies'])} assembly(ies))") + for line in entry["reference"]: + print(f" {line}") + print(f"manifest : {manifest_path.as_posix()}") + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_dotnet_preload_packages.py b/scripts/tests/test_dotnet_preload_packages.py new file mode 100644 index 0000000000..80557e7ced --- /dev/null +++ b/scripts/tests/test_dotnet_preload_packages.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Tests du helper de prechargement NuGet `scripts/ci/dotnet_preload_packages.py`. + +Le helper existe parce que le 2e `#r "nuget:"` d'une session dotnet-interactive +peut lever `PackageRestoreResult..ctor ArgumentException` (#17361, non +deterministe), ce qui tue la cellule sans contournement runtime. La parade est +de ne plus appeler `#r "nuget:"` du tout et de referencer des assembly locales. + +Ce que ces tests couvrent -- la resolution de cache et la forme des sorties, qui +sont la partie ou une erreur silencieuse coute une re-execution de notebook : + - `parse_spec` : `Nom`, `Nom==1.2.3`, `Nom@1.2.3`, spec vide/mal formee ; + - `_version_key` : tri numerique (`2.10.0` > `2.9.0`) et prerelease < release ; + - `resolve_version` : version exacte, plus haute, absente, casse du dossier ; + - `pick_assemblies` : TFM prefere, repli alphabetique, package sans `lib` ; + - `main` : copie a plat, manifest, ligne `#r` relative, rc=1 sur echec. + +`dotnet` n'est JAMAIS invoque : le `restorer` est injecte (aucun reseau, aucun +SDK requis en CI). +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ci")) + +import dotnet_preload_packages as dpp # noqa: E402 + + +def _make_package(root: Path, package: str, version: str, tfms: dict[str, list[str]]) -> Path: + """Fabrique un package dans un cache NuGet factice et rend son dossier.""" + version_dir = root / package.lower() / version.lower() + for tfm, dlls in tfms.items(): + lib = version_dir / "lib" / tfm + lib.mkdir(parents=True, exist_ok=True) + for dll in dlls: + (lib / dll).write_text("fake assembly", encoding="utf-8") + return version_dir + + +def _never_called(package: str, version: str | None) -> tuple[bool, str]: + raise AssertionError(f"le restorer ne devait pas etre appele ({package} {version})") + + +# --- parse_spec ------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("spec", "expected"), + [ + ("QuikGraph", ("QuikGraph", None)), + ("QuikGraph==2.5.0", ("QuikGraph", "2.5.0")), + ("QuikGraph@2.5.0", ("QuikGraph", "2.5.0")), + (" CsvHelper==33.0.1 ", ("CsvHelper", "33.0.1")), + ("IKVM.Runtime==8.15.0", ("IKVM.Runtime", "8.15.0")), + ("A.B-C_D==1.0.0-rc1", ("A.B-C_D", "1.0.0-rc1")), + ], +) +def test_parse_spec_valid(spec, expected): + assert dpp.parse_spec(spec) == expected + + +@pytest.mark.parametrize("spec", ["", " ", "==1.0.0", "Nom==", "Nom+meta==1.0.0", "a b"]) +def test_parse_spec_invalid(spec): + with pytest.raises(ValueError): + dpp.parse_spec(spec) + + +# --- _version_key ----------------------------------------------------------- + + +def test_version_key_is_numeric_not_lexicographic(): + assert dpp._version_key("2.10.0") > dpp._version_key("2.9.0") + + +def test_version_key_prerelease_sorts_below_release(): + assert dpp._version_key("1.0.0-rc1") < dpp._version_key("1.0.0") + + +def test_version_key_pads_missing_components(): + assert dpp._version_key("2.0") == dpp._version_key("2.0.0.0") + + +# --- resolve_version -------------------------------------------------------- + + +def test_resolve_version_exact(tmp_path): + _make_package(tmp_path, "QuikGraph", "2.5.0", {"netstandard2.0": ["QuikGraph.dll"]}) + resolved = dpp.resolve_version(tmp_path, "QuikGraph", "2.5.0") + assert resolved is not None and resolved.name == "2.5.0" + + +def test_resolve_version_exact_missing_does_not_fall_back(tmp_path): + """Un pin non satisfait doit se VOIR, pas deriver silencieusement.""" + _make_package(tmp_path, "QuikGraph", "2.5.0", {"netstandard2.0": ["QuikGraph.dll"]}) + assert dpp.resolve_version(tmp_path, "QuikGraph", "9.9.9") is None + + +def test_resolve_version_picks_highest(tmp_path): + for version in ("2.5.0", "2.9.0", "2.10.0"): + _make_package(tmp_path, "QuikGraph", version, {"netstandard2.0": ["QuikGraph.dll"]}) + resolved = dpp.resolve_version(tmp_path, "QuikGraph", None) + assert resolved is not None and resolved.name == "2.10.0" + + +def test_resolve_version_is_case_insensitive_on_disk(tmp_path): + _make_package(tmp_path, "QuikGraph", "2.5.0", {"netstandard2.0": ["QuikGraph.dll"]}) + assert dpp.resolve_version(tmp_path, "QUIKGRAPH", "2.5.0") is not None + + +def test_resolve_version_absent_package(tmp_path): + assert dpp.resolve_version(tmp_path, "QuikGraph", None) is None + + +# --- pick_assemblies -------------------------------------------------------- + + +def test_pick_assemblies_prefers_net9(tmp_path): + version_dir = _make_package( + tmp_path, + "Pkg", + "1.0.0", + {"netstandard2.0": ["Pkg.dll"], "net9.0": ["Pkg.dll"]}, + ) + picked = dpp.pick_assemblies(version_dir) + assert [path.parent.name for path in picked] == ["net9.0"] + + +def test_pick_assemblies_falls_back_to_first_tfm(tmp_path): + version_dir = _make_package(tmp_path, "Pkg", "1.0.0", {"weird-tfm": ["Pkg.dll"]}) + picked = dpp.pick_assemblies(version_dir) + assert [path.name for path in picked] == ["Pkg.dll"] + + +def test_pick_assemblies_without_lib_dir(tmp_path): + version_dir = tmp_path / "pkg" / "1.0.0" + version_dir.mkdir(parents=True) + assert dpp.pick_assemblies(version_dir) == [] + + +# --- _reference_line -------------------------------------------------------- + + +def test_reference_line_relative_uses_measured_form(): + """Forme mesuree c.790 : `#r "./_deps/X.dll"` (litteral, parse-time).""" + assert dpp._reference_line(Path("_deps"), "QuikGraph.dll") == '#r "./_deps/QuikGraph.dll"' + + +def test_reference_line_absolute_is_kept_as_is(tmp_path): + """Un chemin absolu ne peut pas etre relatif au notebook : il est rendu tel quel.""" + assert dpp._reference_line(tmp_path, "Pkg.dll") == f'#r "{tmp_path.as_posix()}/Pkg.dll"' + + +# --- main : chemin nominal -------------------------------------------------- + + +def test_main_copies_flat_and_emits_manifest(tmp_path, monkeypatch): + cache = tmp_path / "cache" + _make_package( + cache, + "QuikGraph", + "2.5.0", + {"netstandard2.0": ["QuikGraph.dll", "QuikGraph.Contracts.dll"]}, + ) + monkeypatch.setenv("NUGET_PACKAGES", str(cache)) + # Dest RELATIF (la forme d'usage : le notebook et `_deps/` sont voisins). + monkeypatch.chdir(tmp_path) + dest = Path("_deps") + + rc = dpp.main(["--dest", str(dest), "QuikGraph==2.5.0"], restorer=_never_called) + + assert rc == 0 + # Copie A PLAT : c'est la forme verifiee `#r "./_deps/X.dll"`. + assert sorted(path.name for path in dest.glob("*.dll")) == [ + "QuikGraph.Contracts.dll", + "QuikGraph.dll", + ] + + manifest = json.loads((dest / ".NET-packages.json").read_text(encoding="utf-8")) + assert manifest["schema"] == dpp.SCHEMA + assert manifest["issue"] == 17361 + entry = manifest["packages"][0] + assert entry["package"] == "QuikGraph" + assert entry["version"] == "2.5.0" + assert "error" not in entry + assert entry["reference"] == [ + '#r "./_deps/QuikGraph.Contracts.dll"', + '#r "./_deps/QuikGraph.dll"', + ] + + +def test_main_pins_exact_version_over_higher_cached(tmp_path, monkeypatch): + cache = tmp_path / "cache" + _make_package(cache, "QuikGraph", "2.5.0", {"netstandard2.0": ["QuikGraph.dll"]}) + _make_package(cache, "QuikGraph", "2.6.0", {"netstandard2.0": ["QuikGraph.dll"]}) + monkeypatch.setenv("NUGET_PACKAGES", str(cache)) + dest = tmp_path / "_deps" + + assert dpp.main(["--dest", str(dest), "QuikGraph==2.5.0"], restorer=_never_called) == 0 + manifest = json.loads((dest / ".NET-packages.json").read_text(encoding="utf-8")) + assert manifest["packages"][0]["version"] == "2.5.0" + + +def test_main_json_writes_manifest_and_stdout(tmp_path, monkeypatch, capsys): + cache = tmp_path / "cache" + _make_package(cache, "QuikGraph", "2.5.0", {"netstandard2.0": ["QuikGraph.dll"]}) + monkeypatch.setenv("NUGET_PACKAGES", str(cache)) + dest = tmp_path / "_deps" + + assert dpp.main(["--dest", str(dest), "--json", "QuikGraph"], restorer=_never_called) == 0 + assert json.loads(capsys.readouterr().out)["packages"][0]["version"] == "2.5.0" + + +# --- main : chemin de restauration ------------------------------------------ + + +def test_main_restores_then_resolves(tmp_path, monkeypatch): + """Package absent du cache -> le restorer est appele, puis la resolution reussit.""" + cache = tmp_path / "cache" + monkeypatch.setenv("NUGET_PACKAGES", str(cache)) + dest = tmp_path / "_deps" + calls: list[tuple[str, str | None]] = [] + + def restorer(package: str, version: str | None) -> tuple[bool, str]: + calls.append((package, version)) + _make_package(cache, package, version or "0.0.1", {"net9.0": [f"{package}.dll"]}) + return True, f"dotnet restore rc=0 ({package} {version})" + + rc = dpp.main(["--dest", str(dest), "IKVM==8.15.0"], restorer=restorer) + + assert rc == 0 + assert calls == [("IKVM", "8.15.0")] + manifest = json.loads((dest / ".NET-packages.json").read_text(encoding="utf-8")) + assert manifest["packages"][0]["restore"].startswith("dotnet restore rc=0") + assert (dest / "IKVM.dll").is_file() + + +def test_main_reports_failure_and_returns_1(tmp_path, monkeypatch): + monkeypatch.setenv("NUGET_PACKAGES", str(tmp_path / "vide")) + dest = tmp_path / "_deps" + + def failing(package: str, version: str | None) -> tuple[bool, str]: + return False, "dotnet introuvable dans le PATH" + + rc = dpp.main(["--dest", str(dest), "QuikGraph==2.5.0"], restorer=failing) + + assert rc == 1 + manifest = json.loads((dest / ".NET-packages.json").read_text(encoding="utf-8")) + assert manifest["packages"][0]["error"] == "dotnet introuvable dans le PATH" + + +def test_main_restore_without_version_refuses_early(tmp_path, monkeypatch): + """Sans version epinglee, `dotnet restore` est refuse AVANT d'etre tente.""" + monkeypatch.setenv("NUGET_PACKAGES", str(tmp_path / "vide")) + dest = tmp_path / "_deps" + + def failing(package: str, version: str | None) -> tuple[bool, str]: + return False, f"{package} absent du cache et aucune version epinglee : " \ + "`dotnet restore` exige `Nom==x.y.z`" + + rc = dpp.main(["--dest", str(dest), "QuikGraph"], restorer=failing) + + assert rc == 1 + manifest = json.loads((dest / ".NET-packages.json").read_text(encoding="utf-8")) + assert "aucune version epinglee" in manifest["packages"][0]["error"] + + +# --- main : entrees malformees ---------------------------------------------- + + +def test_main_bad_spec_is_reported_not_raised(tmp_path, monkeypatch): + monkeypatch.setenv("NUGET_PACKAGES", str(tmp_path / "vide")) + dest = tmp_path / "_deps" + + rc = dpp.main(["--dest", str(dest), "Nom avec espaces"], restorer=_never_called) + + assert rc == 1 + manifest = json.loads((dest / ".NET-packages.json").read_text(encoding="utf-8")) + assert "specification invalide" in manifest["packages"][0]["error"] + + +def test_main_mixed_batch_keeps_good_entries(tmp_path, monkeypatch): + """Un echec ne doit pas emporter les packages resolus du meme lot.""" + cache = tmp_path / "cache" + _make_package(cache, "QuikGraph", "2.5.0", {"netstandard2.0": ["QuikGraph.dll"]}) + monkeypatch.setenv("NUGET_PACKAGES", str(cache)) + dest = tmp_path / "_deps" + + def failing(package: str, version: str | None) -> tuple[bool, str]: + return False, f"{package} absent du cache" + + rc = dpp.main(["--dest", str(dest), "QuikGraph", "CsvHelper"], restorer=failing) + + assert rc == 1 + manifest = json.loads((dest / ".NET-packages.json").read_text(encoding="utf-8")) + by_package = {entry.get("package"): entry for entry in manifest["packages"]} + assert "error" not in by_package["QuikGraph"] + assert "error" in by_package["CsvHelper"] + assert (dest / "QuikGraph.dll").is_file() + + +def test_manifest_ends_with_single_newline(tmp_path, monkeypatch): + """Pas de CRLF : les manifests sont lus par des outils, pas par un shell Windows.""" + cache = tmp_path / "cache" + _make_package(cache, "QuikGraph", "2.5.0", {"netstandard2.0": ["QuikGraph.dll"]}) + monkeypatch.setenv("NUGET_PACKAGES", str(cache)) + dest = tmp_path / "_deps" + + assert dpp.main(["--dest", str(dest), "QuikGraph"], restorer=_never_called) == 0 + raw = (dest / ".NET-packages.json").read_bytes() + assert b"\r\n" not in raw + assert raw.endswith(b"\n") + + +# --- restore_via_dotnet (subprocess simule) --------------------------------- + + +class _FakeProc: + def __init__(self, returncode: int, stdout: str = "", stderr: str = "") -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def test_restore_refuses_without_pinned_version(monkeypatch): + """Sans version, on n'appelle meme pas `dotnet` : le refus est explicite.""" + monkeypatch.setattr(dpp.shutil, "which", lambda _: "/usr/bin/dotnet") + monkeypatch.setattr( + dpp.subprocess, "run", lambda *a, **k: pytest.fail("dotnet ne devait pas etre lance") + ) + ok, message = dpp.restore_via_dotnet("QuikGraph", None) + assert ok is False and "aucune version epinglee" in message + + +def test_restore_reports_stdout_errors(monkeypatch): + """`dotnet restore -v quiet` ecrit ses erreurs sur STDOUT : ne pas les perdre.""" + monkeypatch.setattr(dpp.shutil, "which", lambda _: "/usr/bin/dotnet") + monkeypatch.setattr( + dpp.subprocess, + "run", + lambda *a, **k: _FakeProc(1, stdout="error NU1101: Unable to find package\n"), + ) + ok, message = dpp.restore_via_dotnet("Inexistant", "1.0.0") + assert ok is False + assert "NU1101" in message + assert "(aucune sortie)" not in message + + +def test_restore_missing_dotnet_is_ordinary_failure(monkeypatch): + monkeypatch.setattr(dpp.shutil, "which", lambda _: None) + ok, message = dpp.restore_via_dotnet("QuikGraph", "2.5.0") + assert ok is False and "introuvable dans le PATH" in message + + +def test_restore_timeout_is_ordinary_failure(monkeypatch): + monkeypatch.setattr(dpp.shutil, "which", lambda _: "/usr/bin/dotnet") + + def boom(*args, **kwargs): + raise dpp.subprocess.TimeoutExpired(cmd="dotnet", timeout=1) + + monkeypatch.setattr(dpp.subprocess, "run", boom) + ok, message = dpp.restore_via_dotnet("QuikGraph", "2.5.0", timeout=1) + assert ok is False and "timeout" in message + + +def test_restore_success_message(monkeypatch): + monkeypatch.setattr(dpp.shutil, "which", lambda _: "/usr/bin/dotnet") + monkeypatch.setattr(dpp.subprocess, "run", lambda *a, **k: _FakeProc(0)) + ok, message = dpp.restore_via_dotnet("QuikGraph", "2.5.0") + assert ok is True and "rc=0" in message + + +# --- nuget_root ------------------------------------------------------------- + + +def test_nuget_root_honours_env(tmp_path, monkeypatch): + monkeypatch.setenv("NUGET_PACKAGES", str(tmp_path / "perso")) + assert dpp.nuget_root() == tmp_path / "perso" + + +def test_nuget_root_defaults_to_home(monkeypatch): + monkeypatch.delenv("NUGET_PACKAGES", raising=False) + assert dpp.nuget_root().name == "packages"