From 1f4a4ec1a9d9a1c9d6a31260cd02b42ee6929844 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 12:40:32 +0000 Subject: [PATCH 1/4] Add :project: sentinel for automatic autodoc wheel builds Introduce pyrepl_autodoc_packages = ':project:' to build a PEP 427 wheel from the documented project during Sphinx HTML builds. Wheels are written to _static/wheels with --no-deps, reused when sources are unchanged, and copied into HTML output alongside other pyrepl assets. Closes #22. Co-authored-by: chrizzftd --- .gitignore | 1 + README.md | 19 +- docs/conf.py | 3 +- docs/development.rst | 5 + docs/example.rst | 3 +- sphinx_pyrepl_web/__init__.py | 27 ++- sphinx_pyrepl_web/wheel.py | 235 ++++++++++++++++++++++++ tests/integration/test_project_wheel.py | 42 +++++ tests/support.py | 16 ++ tests/unit/test_config.py | 7 + tests/unit/test_wheel.py | 176 ++++++++++++++++++ 11 files changed, 528 insertions(+), 6 deletions(-) create mode 100644 sphinx_pyrepl_web/wheel.py create mode 100644 tests/integration/test_project_wheel.py create mode 100644 tests/unit/test_wheel.py diff --git a/.gitignore b/.gitignore index e707d28..5d09aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ .venv-build-test/ .venv-testpypi/ docs/_build/ +docs/_static/wheels/*.whl dist/ build/ *.egg diff --git a/README.md b/README.md index d0c13f4..28c33f2 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,19 @@ Enable [doctest style examples](https://docs.python.org/3/library/doctest.html) | Value | Outcome | |-------------------------|--------------------------------------------------------------------| | `None` (default) | Replay doctest input without preloading packages | +| `:project:` | Build a wheel from the documented project and preload it | | Wheel path / PyPI names | Install the package and import the documented object before replay | +Optional when using `:project:`: + +| Value | Default | Outcome | +|-------|---------|---------| +| `pyrepl_project_root` | auto-detect | Project root containing `pyproject.toml` (relative to `conf.py`) | +| `pyrepl_wheel_dir` | `_static/wheels` | Directory for the built wheel, relative to `conf.py` | + ### Local wheels -Unreleased package wheels can be available in the REPL by building them under Sphinx's `html_static_path`. +Unreleased package wheels can be available in the REPL by building them under Sphinx's `html_static_path`, or by using `:project:` to build automatically at doc-build time. All options combined: @@ -93,5 +101,12 @@ extensions = [ html_static_path = ["_static"] pyrepl_doctest_blocks = "autodoc" -pyrepl_autodoc_packages = "_static/wheels/my_package-1.0.0-py3-none-any.whl" +pyrepl_autodoc_packages = ":project:" +``` + +For a monorepo or non-default layout, point at the package root explicitly: + +```python +pyrepl_autodoc_packages = ":project:" +pyrepl_project_root = ".." ``` diff --git a/docs/conf.py b/docs/conf.py index 9980427..a51e2ca 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,8 @@ "sphinx_pyrepl_web", ] pyrepl_doctest_blocks = "autodoc" -pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl" +pyrepl_autodoc_packages = ":project:" +pyrepl_project_root = "../tests/fixtures/pyrepl_test_pkg" html_static_path = ["_static"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "example.rst"] diff --git a/docs/development.rst b/docs/development.rst index 61d8152..c291c86 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -56,6 +56,11 @@ Static wheels Wheel packages must be `Pyodide `_ compatible (pure-python packages work out of the box). For ``CPython`` extensions, visit `pyodide-build `_. +Set ``pyrepl_autodoc_packages = ":project:"`` to build a wheel from the documented +project automatically during the Sphinx HTML build. The wheel is written to +``_static/wheels/`` (configurable via ``pyrepl_wheel_dir``) and reused on +incremental builds until project sources change. + Wheels under ``_static/`` are copied into the HTML output when ``_static`` is listed in ``html_static_path``. At runtime, `pyrepl-web `_ resolves site-relative wheel paths to absolute URLs before calling diff --git a/docs/example.rst b/docs/example.rst index bfc7464..5199ec1 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -156,7 +156,8 @@ Set ``pyrepl_autodoc_packages`` to install the documented package and automatica # conf.py html_static_path = ["_static"] pyrepl_doctest_blocks = "autodoc" - pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl" + pyrepl_autodoc_packages = ":project:" + pyrepl_project_root = "../tests/fixtures/pyrepl_test_pkg" Source module: diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 9997be9..04acb7f 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -1,6 +1,6 @@ """A Sphinx extension for embedding pyrepl-web Python REPLs in documentation.""" -__version__ = "0.3.0" +__version__ = "0.4.0" import json from doctest import DocTestParser @@ -16,6 +16,8 @@ from sphinx.util.fileutil import copy_asset_file from sphinx.util.osutil import relative_uri +from sphinx_pyrepl_web.wheel import ensure_project_wheel_on_init + PYREPL_DIR = Path(__file__).parent / "pyrepl" STARTUP_FILES_KEY = "pyrepl-startup-files" REPLAY_FILES_KEY = "pyrepl-replay-files" @@ -55,7 +57,10 @@ def setup(app: Sphinx): app.add_config_value("pyrepl_js", "../pyrepl.js", "env") app.add_config_value("pyrepl_doctest_blocks", False, "env", types=(bool, str)) app.add_config_value("pyrepl_autodoc_packages", None, "env") + app.add_config_value("pyrepl_project_root", None, "env") + app.add_config_value("pyrepl_wheel_dir", "_static/wheels", "env") app.add_directive("py-repl", PyRepl) + app.connect("builder-inited", ensure_project_wheel_on_init) app.connect("doctree-read", doctree_read) app.connect("doctree-read", transform_doctest_blocks) app.connect("html-page-context", add_html_context) @@ -167,7 +172,14 @@ def _find_autodoc_desc(node: nodes.Node) -> addnodes.desc | None: def _autodoc_packages(app: Sphinx) -> str | None: """Return configured package preload for autodoc doctest REPLs.""" - return app.config.pyrepl_autodoc_packages or None + try: + resolved = object.__getattribute__(app, "_pyrepl_resolved_autodoc_packages") + except AttributeError: + resolved = None + if resolved is not None: + return resolved + raw = app.config.pyrepl_autodoc_packages + return raw or None def _inside_autodoc_desc(node: nodes.Node) -> bool: @@ -356,3 +368,14 @@ def copy_asset_files(app, _): dest = outdir / rel dest.parent.mkdir(parents=True, exist_ok=True) copy_asset_file(str(path.resolve()), str(dest.resolve())) + + wheel_dir_setting = app.config.pyrepl_wheel_dir + src_wheel_dir = Path(app.confdir) / wheel_dir_setting + if src_wheel_dir.is_dir(): + dest_wheel_dir = outdir / wheel_dir_setting + dest_wheel_dir.mkdir(parents=True, exist_ok=True) + for wheel in src_wheel_dir.glob("*.whl"): + copy_asset_file( + str(wheel.resolve()), + str((dest_wheel_dir / wheel.name).resolve()), + ) diff --git a/sphinx_pyrepl_web/wheel.py b/sphinx_pyrepl_web/wheel.py new file mode 100644 index 0000000..195197a --- /dev/null +++ b/sphinx_pyrepl_web/wheel.py @@ -0,0 +1,235 @@ +"""Build and resolve project wheels for autodoc REPL package preload.""" + +from __future__ import annotations + +import re +import subprocess +import sys +import tomllib +from configparser import ConfigParser +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.errors import ConfigError +from sphinx.util import logging + +PROJECT_SENTINEL = ":project:" +_PROJECT_MARKERS = ("pyproject.toml", "setup.cfg", "setup.py") +_SKIP_DIRS = frozenset( + { + ".git", + ".hg", + ".svn", + ".tox", + ".venv", + "__pycache__", + "build", + "dist", + "doc", + "docs", + "node_modules", + "venv", + } +) +logger = logging.getLogger(__name__) + + +def is_project_sentinel(value: object) -> bool: + """Return True if *value* requests automatic project wheel resolution.""" + return value == PROJECT_SENTINEL + + +def _is_project_root(path: Path) -> bool: + return any((path / marker).is_file() for marker in _PROJECT_MARKERS) + + +def find_project_root(confdir: Path, override: str | None = None) -> Path: + """Locate the Python project root for wheel builds.""" + if override is not None: + root = (confdir / override).resolve() + if not _is_project_root(root): + raise ConfigError( + f"pyrepl_project_root {override!r} does not contain " + f"pyproject.toml, setup.cfg, or setup.py " + f"(resolved to {root})" + ) + return root + + for candidate in (confdir, *confdir.parents): + if _is_project_root(candidate): + return candidate + + raise ConfigError( + "pyrepl_autodoc_packages is ':project:' but no project root was found " + f"searching upward from {confdir}" + ) + + +def read_distribution_name(project_root: Path) -> str: + """Return the distribution name declared by the project.""" + pyproject = project_root / "pyproject.toml" + if pyproject.is_file(): + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + name = data.get("project", {}).get("name") + if isinstance(name, str) and name: + return name + + setup_cfg = project_root / "setup.cfg" + if setup_cfg.is_file(): + parser = ConfigParser() + parser.read(setup_cfg, encoding="utf-8") + if parser.has_option("metadata", "name"): + name = parser.get("metadata", "name") + if name: + return name + + raise ConfigError( + f"could not determine distribution name from {project_root}" + ) + + +def normalize_distribution_name(name: str) -> str: + """Normalize a distribution name for wheel filename matching.""" + return re.sub(r"[-_.]+", "_", name).lower() + + +def wheel_dir_path(confdir: Path, wheel_dir: str) -> Path: + """Return the absolute wheel output directory.""" + return (confdir / wheel_dir).resolve() + + +def resolved_wheel_href(wheel_dir: str, wheel_name: str) -> str: + """Return the Sphinx site path for a built wheel.""" + return f"{wheel_dir.rstrip('/')}/{wheel_name}" + + +def find_newest_wheel(wheel_dir: Path, distribution_name: str) -> Path | None: + """Return the newest wheel matching *distribution_name*, if any.""" + normalized = normalize_distribution_name(distribution_name) + matches = [ + wheel + for wheel in wheel_dir.glob("*.whl") + if normalize_distribution_name(wheel.name.split("-", 1)[0]) == normalized + ] + if not matches: + return None + return max(matches, key=lambda path: path.stat().st_mtime) + + +def project_latest_mtime(project_root: Path, *, wheel_dir: Path) -> float: + """Return the latest mtime among project sources that affect wheels.""" + latest = 0.0 + for marker in _PROJECT_MARKERS: + path = project_root / marker + if path.is_file(): + latest = max(latest, path.stat().st_mtime) + + for path in project_root.rglob("*.py"): + if wheel_dir in path.parents or path.parents[0] == wheel_dir: + continue + if _SKIP_DIRS.intersection(path.relative_to(project_root).parts): + continue + latest = max(latest, path.stat().st_mtime) + + return latest + + +def wheel_is_fresh(wheel_path: Path, project_root: Path, *, wheel_dir: Path) -> bool: + """Return True if *wheel_path* is newer than relevant project sources.""" + if not wheel_path.is_file(): + return False + wheel_mtime = wheel_path.stat().st_mtime + return wheel_mtime >= project_latest_mtime(project_root, wheel_dir=wheel_dir) + + +def build_wheel(project_root: Path, wheel_dir: Path) -> None: + """Build a wheel for *project_root* into *wheel_dir*.""" + wheel_dir.mkdir(parents=True, exist_ok=True) + try: + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + str(project_root), + "--no-deps", + "-w", + str(wheel_dir), + ], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or exc.stdout or "").strip() + raise ConfigError( + f"failed to build project wheel for {project_root}: {detail}" + ) from exc + + +def warn_if_wheel_dir_not_static(app: Sphinx, wheel_dir: str) -> None: + """Warn when wheels will not be copied into HTML output.""" + static_paths = list(getattr(app.config, "html_static_path", None) or []) + covered = any( + wheel_dir == static_path + or wheel_dir.startswith(f"{static_path.rstrip('/')}/") + for static_path in static_paths + ) + if covered: + return + logger.warning( + "pyrepl wheel directory %r is not covered by html_static_path; " + "add its parent directory so wheels are copied into HTML output", + wheel_dir, + type="pyrepl", + subtype="wheel", + ) + + +def ensure_project_wheel(app: Sphinx) -> str: + """Build or reuse a project wheel and return its Sphinx path.""" + confdir = Path(app.confdir) + wheel_dir_setting = app.config.pyrepl_wheel_dir + wheel_dir = wheel_dir_path(confdir, wheel_dir_setting) + project_root = find_project_root(confdir, app.config.pyrepl_project_root) + distribution_name = read_distribution_name(project_root) + + warn_if_wheel_dir_not_static(app, wheel_dir_setting) + + wheel_path = find_newest_wheel(wheel_dir, distribution_name) + if wheel_path is None or not wheel_is_fresh( + wheel_path, project_root, wheel_dir=wheel_dir + ): + logger.info( + "building project wheel for %r into %s", + distribution_name, + wheel_dir, + type="pyrepl", + subtype="wheel", + ) + build_wheel(project_root, wheel_dir) + wheel_path = find_newest_wheel(wheel_dir, distribution_name) + + if wheel_path is None: + raise ConfigError( + f"project wheel for {distribution_name!r} was not found in {wheel_dir} " + "after build" + ) + + logger.info( + "using project wheel %s", + wheel_path.name, + type="pyrepl", + subtype="wheel", + ) + return resolved_wheel_href(wheel_dir_setting, wheel_path.name) + + +def ensure_project_wheel_on_init(app: Sphinx) -> None: + """Resolve ``:project:`` before doctrees are read.""" + if not is_project_sentinel(app.config.pyrepl_autodoc_packages): + return + if app.builder.format != "html": + return + app._pyrepl_resolved_autodoc_packages = ensure_project_wheel(app) diff --git a/tests/integration/test_project_wheel.py b/tests/integration/test_project_wheel.py new file mode 100644 index 0000000..d7e7aff --- /dev/null +++ b/tests/integration/test_project_wheel.py @@ -0,0 +1,42 @@ +from tests.helpers import load_bootstrap_files, pyrepl_tag +from tests.support import ( + FIXTURES, + autodoc_conf_header, + build_sphinx, + project_wheel_conf_extra, +) + + +def test_autodoc_with_project_sentinel_builds_wheel(tmp_path): + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + autodoc_conf_header( + sys_path=str(FIXTURES / "pyrepl_test_pkg"), + extra=project_wheel_conf_extra(srcdir=srcdir), + ), + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + ".. autofunction:: pyrepl_test_pkg.demo.example_generator\n", + encoding="utf-8", + ) + + app = build_sphinx(srcdir, outdir, doctreedir) + + built_wheel = next((srcdir / "_static" / "wheels").glob("pyrepl_test_pkg-*.whl")) + wheel_path = f"_static/wheels/{built_wheel.name}" + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{wheel_path}"' in html + assert 'replay-src="_static/pyrepl/index-1.py"' in html + tag = pyrepl_tag(html) + assert 'src="_static/pyrepl/index-1-bootstrap.py"' in tag + assert (outdir / "_static" / "wheels" / built_wheel.name).is_file() + assert built_wheel.is_file() + assert (outdir / "_static" / "pyrepl" / "index-1.py").is_file() + assert (outdir / "_static" / "pyrepl" / "index-1-bootstrap.py").is_file() + assert app.env.get_doctree("index").get("pyrepl") + assert list(load_bootstrap_files(app, "index")) == ["index-1-bootstrap.py"] diff --git a/tests/support.py b/tests/support.py index 7742620..8383832 100644 --- a/tests/support.py +++ b/tests/support.py @@ -54,6 +54,22 @@ def wheel_conf_extra() -> str: """ +def project_wheel_conf_extra(*, srcdir: Path | None = None) -> str: + """Return conf.py snippet enabling automatic project wheel builds.""" + fixture = FIXTURES / "pyrepl_test_pkg" + if srcdir is not None: + import os + + project_root = os.path.relpath(fixture.resolve(), srcdir.resolve()) + else: + project_root = str(fixture.resolve()) + return f""" +html_static_path = ["_static"] +pyrepl_autodoc_packages = ":project:" +pyrepl_project_root = {project_root!r} +""" + + def autodoc_conf_header(*, sys_path: str, extra: str = "") -> str: """Return a minimal autodoc-enabled conf.py header.""" return f""" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index de0668f..ba695fd 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -18,3 +18,10 @@ def test_autodoc_packages(configured, expected): app = MagicMock() app.config.pyrepl_autodoc_packages = configured assert _autodoc_packages(app) == expected + + +def test_autodoc_packages_uses_resolved_project_wheel(): + app = MagicMock() + app.config.pyrepl_autodoc_packages = ":project:" + app._pyrepl_resolved_autodoc_packages = WHEEL_PATH + assert _autodoc_packages(app) == WHEEL_PATH diff --git a/tests/unit/test_wheel.py b/tests/unit/test_wheel.py new file mode 100644 index 0000000..70ad905 --- /dev/null +++ b/tests/unit/test_wheel.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from sphinx.errors import ConfigError + +from sphinx_pyrepl_web.wheel import ( + PROJECT_SENTINEL, + build_wheel, + ensure_project_wheel, + find_newest_wheel, + find_project_root, + is_project_sentinel, + normalize_distribution_name, + read_distribution_name, + resolved_wheel_href, + wheel_is_fresh, +) +from tests.support import FIXTURES + +PKG_ROOT = FIXTURES / "pyrepl_test_pkg" + + +def test_is_project_sentinel(): + assert is_project_sentinel(PROJECT_SENTINEL) + assert not is_project_sentinel("_static/wheels/foo.whl") + assert not is_project_sentinel(None) + + +def test_find_project_root_from_nested_confdir(tmp_path): + project = tmp_path / "project" + docs = project / "docs" + docs.mkdir(parents=True) + (project / "pyproject.toml").write_text( + '[project]\nname = "demo"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + assert find_project_root(docs) == project + + +def test_find_project_root_honors_override(tmp_path): + pkg = tmp_path / "pkg" + docs = tmp_path / "docs" + docs.mkdir() + pkg.mkdir() + (pkg / "pyproject.toml").write_text( + '[project]\nname = "demo"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + assert find_project_root(docs, "../pkg") == pkg.resolve() + + +def test_find_project_root_invalid_override(tmp_path): + docs = tmp_path / "docs" + docs.mkdir() + with pytest.raises(ConfigError, match="pyrepl_project_root"): + find_project_root(docs, "../missing") + + +def test_read_distribution_name_from_fixture(): + assert read_distribution_name(PKG_ROOT) == "pyrepl_test_pkg" + + +def test_normalize_distribution_name(): + assert normalize_distribution_name("my-package") == "my_package" + assert normalize_distribution_name("PyRepl.Test") == "pyrepl_test" + + +def test_find_newest_wheel_picks_latest(tmp_path): + wheel_dir = tmp_path / "wheels" + wheel_dir.mkdir() + older = wheel_dir / "pyrepl_test_pkg-1.0.0-py3-none-any.whl" + newer = wheel_dir / "pyrepl_test_pkg-2.0.0-py3-none-any.whl" + older.write_bytes(b"old") + newer.write_bytes(b"new") + now = os.path.getmtime(newer) + os.utime(older, (now - 10, now - 10)) + os.utime(newer, (now, now)) + assert find_newest_wheel(wheel_dir, "pyrepl_test_pkg") == newer + + +def test_resolved_wheel_href(): + assert ( + resolved_wheel_href("_static/wheels", "demo-1.0.0-py3-none-any.whl") + == "_static/wheels/demo-1.0.0-py3-none-any.whl" + ) + + +def test_wheel_is_fresh_when_source_unchanged(tmp_path): + project = tmp_path / "project" + wheel_dir = tmp_path / "wheels" + project.mkdir() + wheel_dir.mkdir() + (project / "pyproject.toml").write_text( + '[project]\nname = "demo"\nversion = "1.0.0"\n', + encoding="utf-8", + ) + pkg = project / "demo_pkg" + pkg.mkdir() + source = pkg / "__init__.py" + source.write_text("x = 1\n", encoding="utf-8") + wheel = wheel_dir / "demo-1.0.0-py3-none-any.whl" + wheel.write_bytes(b"wheel") + assert wheel_is_fresh(wheel, project, wheel_dir=wheel_dir) + + +def test_wheel_is_stale_when_source_changes(tmp_path): + project = tmp_path / "project" + wheel_dir = tmp_path / "wheels" + project.mkdir() + wheel_dir.mkdir() + (project / "pyproject.toml").write_text( + '[project]\nname = "demo"\nversion = "1.0.0"\n', + encoding="utf-8", + ) + pkg = project / "demo_pkg" + pkg.mkdir() + source = pkg / "__init__.py" + source.write_text("x = 1\n", encoding="utf-8") + wheel = wheel_dir / "demo-1.0.0-py3-none-any.whl" + wheel.write_bytes(b"wheel") + source.write_text("x = 2\n", encoding="utf-8") + now = os.path.getmtime(source) + os.utime(wheel, (now - 10, now - 10)) + assert not wheel_is_fresh(wheel, project, wheel_dir=wheel_dir) + + +def test_build_wheel_creates_fixture_wheel(tmp_path): + wheel_dir = tmp_path / "wheels" + build_wheel(PKG_ROOT, wheel_dir) + wheel = find_newest_wheel(wheel_dir, "pyrepl_test_pkg") + assert wheel is not None + assert wheel.name.startswith("pyrepl_test_pkg-1.0.0-") + assert wheel.name.endswith("-none-any.whl") + + +def test_ensure_project_wheel_builds_and_returns_path(tmp_path): + docs = tmp_path / "docs" + docs.mkdir() + app = MagicMock() + app.confdir = str(docs) + app.config.pyrepl_project_root = str(PKG_ROOT) + app.config.pyrepl_wheel_dir = "_static/wheels" + app.config.html_static_path = ["_static"] + + path = ensure_project_wheel(app) + + assert path.startswith("_static/wheels/pyrepl_test_pkg-1.0.0-") + assert path.endswith("-none-any.whl") + assert (docs / "_static" / "wheels" / path.rsplit("/", 1)[-1]).is_file() + + +def test_ensure_project_wheel_reuses_fresh_wheel(tmp_path): + docs = tmp_path / "docs" + wheel_dir = docs / "_static" / "wheels" + wheel_dir.mkdir(parents=True) + wheel = wheel_dir / "pyrepl_test_pkg-1.0.0-py2.py3-none-any.whl" + wheel.write_bytes(b"wheel") + now = os.path.getmtime(PKG_ROOT / "pyproject.toml") + os.utime(wheel, (now, now)) + + app = MagicMock() + app.confdir = str(docs) + app.config.pyrepl_project_root = str(PKG_ROOT) + app.config.pyrepl_wheel_dir = "_static/wheels" + app.config.html_static_path = ["_static"] + + with patch("sphinx_pyrepl_web.wheel.build_wheel") as build_mock: + first = ensure_project_wheel(app) + second = ensure_project_wheel(app) + + assert first == second + build_mock.assert_not_called() From 111ebdadf691f786e241067634a0c37108a7b11d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 12:48:02 +0000 Subject: [PATCH 2/4] Fix pip wheel subprocess under PYTHONWARNINGS=error in CI CI sets PYTHONWARNINGS=error globally. The pip wheel subprocess inherited that and failed on pip's internal pkg_resources DeprecationWarning. Clear PYTHONWARNINGS in the wheel build subprocess environment. Co-authored-by: chrizzftd --- sphinx_pyrepl_web/wheel.py | 10 ++++++++++ tests/unit/test_wheel.py | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/sphinx_pyrepl_web/wheel.py b/sphinx_pyrepl_web/wheel.py index 195197a..3db5197 100644 --- a/sphinx_pyrepl_web/wheel.py +++ b/sphinx_pyrepl_web/wheel.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import re import subprocess import sys @@ -142,6 +143,14 @@ def wheel_is_fresh(wheel_path: Path, project_root: Path, *, wheel_dir: Path) -> return wheel_mtime >= project_latest_mtime(project_root, wheel_dir=wheel_dir) +def _wheel_build_env() -> dict[str, str]: + """Return a subprocess environment safe for ``pip wheel`` under strict warnings.""" + env = os.environ.copy() + # CI sets PYTHONWARNINGS=error; pip's pkg_resources import emits DeprecationWarning. + env.pop("PYTHONWARNINGS", None) + return env + + def build_wheel(project_root: Path, wheel_dir: Path) -> None: """Build a wheel for *project_root* into *wheel_dir*.""" wheel_dir.mkdir(parents=True, exist_ok=True) @@ -160,6 +169,7 @@ def build_wheel(project_root: Path, wheel_dir: Path) -> None: check=True, capture_output=True, text=True, + env=_wheel_build_env(), ) except subprocess.CalledProcessError as exc: detail = (exc.stderr or exc.stdout or "").strip() diff --git a/tests/unit/test_wheel.py b/tests/unit/test_wheel.py index 70ad905..5c10655 100644 --- a/tests/unit/test_wheel.py +++ b/tests/unit/test_wheel.py @@ -128,7 +128,8 @@ def test_wheel_is_stale_when_source_changes(tmp_path): assert not wheel_is_fresh(wheel, project, wheel_dir=wheel_dir) -def test_build_wheel_creates_fixture_wheel(tmp_path): +def test_build_wheel_creates_fixture_wheel(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHONWARNINGS", "error") wheel_dir = tmp_path / "wheels" build_wheel(PKG_ROOT, wheel_dir) wheel = find_newest_wheel(wheel_dir, "pyrepl_test_pkg") From f57541305c4150e4928f952c1c2f3faac8a917c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 08:17:50 +0000 Subject: [PATCH 3/4] Fix wheel freshness test flakiness on Python 3.14 CI The reuse test only synced the wheel mtime to pyproject.toml, but freshness checks all project .py files too. On CI those can be newer. Compare mtimes at whole-second precision and set the test wheel mtime from project_latest_mtime so reuse is asserted reliably. Co-authored-by: chrizzftd --- sphinx_pyrepl_web/wheel.py | 6 ++++-- tests/unit/test_wheel.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/sphinx_pyrepl_web/wheel.py b/sphinx_pyrepl_web/wheel.py index 3db5197..81b4fde 100644 --- a/sphinx_pyrepl_web/wheel.py +++ b/sphinx_pyrepl_web/wheel.py @@ -139,8 +139,10 @@ def wheel_is_fresh(wheel_path: Path, project_root: Path, *, wheel_dir: Path) -> """Return True if *wheel_path* is newer than relevant project sources.""" if not wheel_path.is_file(): return False - wheel_mtime = wheel_path.stat().st_mtime - return wheel_mtime >= project_latest_mtime(project_root, wheel_dir=wheel_dir) + # Compare whole seconds; subsecond utime precision varies across platforms. + wheel_mtime = int(wheel_path.stat().st_mtime) + project_mtime = int(project_latest_mtime(project_root, wheel_dir=wheel_dir)) + return wheel_mtime >= project_mtime def _wheel_build_env() -> dict[str, str]: diff --git a/tests/unit/test_wheel.py b/tests/unit/test_wheel.py index 5c10655..65a0531 100644 --- a/tests/unit/test_wheel.py +++ b/tests/unit/test_wheel.py @@ -15,6 +15,7 @@ find_project_root, is_project_sentinel, normalize_distribution_name, + project_latest_mtime, read_distribution_name, resolved_wheel_href, wheel_is_fresh, @@ -160,8 +161,8 @@ def test_ensure_project_wheel_reuses_fresh_wheel(tmp_path): wheel_dir.mkdir(parents=True) wheel = wheel_dir / "pyrepl_test_pkg-1.0.0-py2.py3-none-any.whl" wheel.write_bytes(b"wheel") - now = os.path.getmtime(PKG_ROOT / "pyproject.toml") - os.utime(wheel, (now, now)) + latest = project_latest_mtime(PKG_ROOT, wheel_dir=wheel_dir) + os.utime(wheel, (latest + 10, latest + 10)) app = MagicMock() app.confdir = str(docs) From d1f87c0f449c12bc73af3d06341c4be2b18f66ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Tue, 7 Jul 2026 22:44:16 +1000 Subject: [PATCH 4/4] Fix cross-drive relpath failure in project wheel test helper os.path.relpath raises ValueError on Windows when the fixture and the temporary srcdir live on different drives. Fall back to an absolute pyrepl_project_root in that case; find_project_root accepts absolute paths. Co-authored-by: Cursor --- tests/support.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/support.py b/tests/support.py index 8383832..fcccec8 100644 --- a/tests/support.py +++ b/tests/support.py @@ -60,7 +60,11 @@ def project_wheel_conf_extra(*, srcdir: Path | None = None) -> str: if srcdir is not None: import os - project_root = os.path.relpath(fixture.resolve(), srcdir.resolve()) + try: + project_root = os.path.relpath(fixture.resolve(), srcdir.resolve()) + except ValueError: + # Windows: fixture and srcdir may live on different drives. + project_root = str(fixture.resolve()) else: project_root = str(fixture.resolve()) return f"""