|
| 1 | +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +from __future__ import annotations |
| 5 | + |
| 6 | +import csv |
| 7 | +import hashlib |
| 8 | +import importlib.util |
| 9 | +import io |
| 10 | +from importlib.metadata import PathDistribution |
| 11 | +from pathlib import Path |
| 12 | +from types import ModuleType |
| 13 | +from zipfile import ZipFile |
| 14 | + |
| 15 | +import pytest |
| 16 | + |
| 17 | +SCRIPT_PATH = Path(__file__).resolve().parents[4] / "docs" / "scripts" / "prepare_nbconvert_css.py" |
| 18 | + |
| 19 | + |
| 20 | +@pytest.fixture |
| 21 | +def css_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[ModuleType, Path, Path]: |
| 22 | + spec = importlib.util.spec_from_file_location("prepare_nbconvert_css", SCRIPT_PATH) |
| 23 | + assert spec is not None and spec.loader is not None |
| 24 | + module = importlib.util.module_from_spec(spec) |
| 25 | + spec.loader.exec_module(module) |
| 26 | + |
| 27 | + prefix = tmp_path / "venv" |
| 28 | + info = prefix / "lib" / "site-packages" / "nbconvert-7.17.2.dist-info" |
| 29 | + info.mkdir(parents=True) |
| 30 | + (info / "METADATA").write_text("Name: nbconvert\nVersion: 7.17.2\n") |
| 31 | + record = info / "RECORD" |
| 32 | + record.write_text("nbconvert-7.17.2.dist-info/RECORD,,\n") |
| 33 | + installed = PathDistribution(info) |
| 34 | + monkeypatch.setattr(module, "distribution", lambda name: installed) |
| 35 | + monkeypatch.setattr(module.sys, "prefix", str(prefix)) |
| 36 | + |
| 37 | + stylesheets = {name: f"/* {name} */".encode() for name in module.CSS_SHA256} |
| 38 | + payload = io.BytesIO() |
| 39 | + with ZipFile(payload, "w") as wheel: |
| 40 | + for name, content in stylesheets.items(): |
| 41 | + wheel.writestr(f"{module.WHEEL_TEMPLATES}/{name}", content) |
| 42 | + wheel.writestr("nbconvert/__init__.py", "raise RuntimeError('must not install wheel code')") |
| 43 | + wheel_bytes = payload.getvalue() |
| 44 | + monkeypatch.setattr(module, "WHEEL_SHA256", hashlib.sha256(wheel_bytes).hexdigest()) |
| 45 | + monkeypatch.setattr( |
| 46 | + module, "CSS_SHA256", {name: hashlib.sha256(data).hexdigest() for name, data in stylesheets.items()} |
| 47 | + ) |
| 48 | + monkeypatch.setattr(module, "urlopen", lambda *args, **kwargs: io.BytesIO(wheel_bytes)) |
| 49 | + return module, prefix, record |
| 50 | + |
| 51 | + |
| 52 | +def test_restores_only_css_and_tracks_assets_for_uninstall(css_environment: tuple[ModuleType, Path, Path]) -> None: |
| 53 | + module, prefix, record = css_environment |
| 54 | + |
| 55 | + module.prepare_nbconvert_css() |
| 56 | + |
| 57 | + templates = prefix / "share" / "jupyter" / "nbconvert" / "templates" |
| 58 | + assert {str(path.relative_to(templates)) for path in templates.rglob("*.css")} == module.CSS_SHA256.keys() |
| 59 | + assert not (record.parent.parent / "nbconvert").exists() |
| 60 | + with record.open(newline="") as stream: |
| 61 | + records = list(csv.reader(stream)) |
| 62 | + assert len(records) == 5 |
| 63 | + for name, digest, size in records[1:]: |
| 64 | + restored = record.parent.parent / name |
| 65 | + assert restored.is_file() |
| 66 | + assert digest.startswith("sha256=") |
| 67 | + assert len(restored.read_bytes()) == int(size) |
| 68 | + |
| 69 | + |
| 70 | +def test_complete_installation_needs_no_network( |
| 71 | + css_environment: tuple[ModuleType, Path, Path], monkeypatch: pytest.MonkeyPatch |
| 72 | +) -> None: |
| 73 | + module, _, record = css_environment |
| 74 | + module.prepare_nbconvert_css() |
| 75 | + previous = record.read_bytes() |
| 76 | + |
| 77 | + def offline(*args: object, **kwargs: object) -> None: |
| 78 | + raise AssertionError("must not download existing assets") |
| 79 | + |
| 80 | + monkeypatch.setattr(module, "urlopen", offline) |
| 81 | + module.prepare_nbconvert_css() |
| 82 | + |
| 83 | + assert record.read_bytes() == previous |
| 84 | + |
| 85 | + |
| 86 | +@pytest.mark.parametrize("corruption", ["wheel", "stylesheet", "missing-member"]) |
| 87 | +def test_rejects_invalid_assets_before_writing( |
| 88 | + css_environment: tuple[ModuleType, Path, Path], monkeypatch: pytest.MonkeyPatch, corruption: str |
| 89 | +) -> None: |
| 90 | + module, prefix, record = css_environment |
| 91 | + previous = record.read_bytes() |
| 92 | + if corruption == "wheel": |
| 93 | + monkeypatch.setattr(module, "WHEEL_SHA256", "0" * 64) |
| 94 | + elif corruption == "stylesheet": |
| 95 | + monkeypatch.setitem(module.CSS_SHA256, "classic/static/style.css", "0" * 64) |
| 96 | + else: |
| 97 | + monkeypatch.setitem(module.CSS_SHA256, "classic/static/missing.css", "0" * 64) |
| 98 | + |
| 99 | + with pytest.raises((ValueError, KeyError), match="checksum mismatch|missing.css"): |
| 100 | + module.prepare_nbconvert_css() |
| 101 | + |
| 102 | + assert not (prefix / "share").exists() |
| 103 | + assert record.read_bytes() == previous |
| 104 | + |
| 105 | + |
| 106 | +@pytest.mark.parametrize("corruption", ["missing", "modified"]) |
| 107 | +def test_repairs_incomplete_installation_without_duplicate_records( |
| 108 | + css_environment: tuple[ModuleType, Path, Path], corruption: str |
| 109 | +) -> None: |
| 110 | + module, prefix, record = css_environment |
| 111 | + module.prepare_nbconvert_css() |
| 112 | + stylesheet = prefix / "share" / "jupyter" / "nbconvert" / "templates" / "classic" / "static" / "style.css" |
| 113 | + expected = stylesheet.read_bytes() |
| 114 | + if corruption == "missing": |
| 115 | + stylesheet.unlink() |
| 116 | + else: |
| 117 | + stylesheet.write_bytes(b"modified") |
| 118 | + |
| 119 | + module.prepare_nbconvert_css() |
| 120 | + |
| 121 | + assert stylesheet.read_bytes() == expected |
| 122 | + with record.open(newline="") as stream: |
| 123 | + assert len(list(csv.reader(stream))) == 5 |
0 commit comments