diff --git a/Makefile b/Makefile index 3d5d4c96..513bf7cd 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ clean: @. venv/bin/activate && \ pre-commit uninstall && \ rm -rf venv + rm -rf *.egg-info builddocs: @echo "๐Ÿ“š Building documentation..." diff --git a/make.ps1 b/make.ps1 index 55584d2b..ab421a3f 100644 --- a/make.ps1 +++ b/make.ps1 @@ -159,6 +159,8 @@ switch ($task) { Write-Host "๐Ÿงน Cleaning documentation artifacts..." -ForegroundColor Cyan if (Test-Path ".\docs\build") { Remove-Item ".\docs\build" -Recurse -Force } if (Test-Path ".\docs\source\api\generated") { Remove-Item ".\docs\source\api\generated" -Recurse -Force } + Get-ChildItem -Directory -Filter *.egg-info -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force . .\venv\Scripts\Activate.ps1 pre-commit uninstall diff --git a/pyproject.toml b/pyproject.toml index 23f9ec0b..105934ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,8 +70,9 @@ dev = [ "pytest-cov>=7.1.0", "pytest-xdist>=3.8.0", "ruff==0.16.5", - "types-openpyxl>=3.1.2", "scipy-stubs>=1.14.1.0", + "setuptools>=82", + "types-openpyxl>=3.1.2", "types-python-dateutil>=2.8.2", "types-requests>=2.20.0", ] diff --git a/tests/test_ci_pr_paths.py b/tests/test_ci_pr_paths.py index ae6319db..03b25de6 100644 --- a/tests/test_ci_pr_paths.py +++ b/tests/test_ci_pr_paths.py @@ -3,38 +3,21 @@ from __future__ import annotations import os -import shutil import subprocess from pathlib import Path ROOT = Path(__file__).parent.parent -SCRIPT = ROOT / "scripts" / "ci-pr-paths.sh" class CiPrPathsError(Exception): """Raised when ci-pr-paths.sh does not behave as expected.""" -def _run( - *patterns: str, - env: dict[str, str], -) -> str: - bash = shutil.which("bash") - if bash is None: - msg = "bash executable not found" - raise CiPrPathsError(msg) +def _env(extra: dict[str, str]) -> dict[str, str]: merged = os.environ.copy() merged.pop("GITHUB_OUTPUT", None) - merged.update(env) - completed = subprocess.run( # noqa: S603 - [bash, str(SCRIPT), *patterns], - check=True, - capture_output=True, - cwd=ROOT, - env=merged, - text=True, - ) - return completed.stdout.strip() + merged.update(extra) + return merged class TestCiPrPaths: @@ -42,43 +25,79 @@ class TestCiPrPaths: def test_non_pull_request_always_runs(self: TestCiPrPaths) -> None: """Test workflow_dispatch runs regardless of patterns.""" - output = _run("openseries/*", env={"GITHUB_EVENT_NAME": "workflow_dispatch"}) - if output != "run=true": - msg = f"expected run=true, got {output!r}" + completed = subprocess.run( + ["/usr/bin/env", "bash", "scripts/ci-pr-paths.sh", "openseries/*"], + check=True, + capture_output=True, + cwd=ROOT, + env=_env({"GITHUB_EVENT_NAME": "workflow_dispatch"}), + text=True, + ) + if completed.stdout.strip() != "run=true": + msg = f"expected run=true, got {completed.stdout.strip()!r}" raise CiPrPathsError(msg) def test_matching_python_path_runs(self: TestCiPrPaths) -> None: """Test a Python source change matches openseries/*.""" - output = _run( - "openseries/*", - "tests/*", - env={ - "GITHUB_EVENT_NAME": "pull_request", - "CI_PR_PATHS_FILES": "README.md\nopenseries/series.py", - }, + completed = subprocess.run( + [ + "/usr/bin/env", + "bash", + "scripts/ci-pr-paths.sh", + "openseries/*", + "tests/*", + ], + check=True, + capture_output=True, + cwd=ROOT, + env=_env( + { + "GITHUB_EVENT_NAME": "pull_request", + "CI_PR_PATHS_FILES": "README.md\nopenseries/series.py", + } + ), + text=True, ) - if output != "run=true": - msg = f"expected run=true, got {output!r}" + if completed.stdout.strip() != "run=true": + msg = f"expected run=true, got {completed.stdout.strip()!r}" raise CiPrPathsError(msg) def test_docs_only_change_skips(self: TestCiPrPaths) -> None: """Test a docs-only PR does not match Python test paths.""" - output = _run( - "openseries/*", - "tests/*", - "pyproject.toml", - env={ - "GITHUB_EVENT_NAME": "pull_request", - "CI_PR_PATHS_FILES": "docs/source/index.rst\ndocs/README.md", - }, + completed = subprocess.run( + [ + "/usr/bin/env", + "bash", + "scripts/ci-pr-paths.sh", + "openseries/*", + "tests/*", + "pyproject.toml", + ], + check=True, + capture_output=True, + cwd=ROOT, + env=_env( + { + "GITHUB_EVENT_NAME": "pull_request", + "CI_PR_PATHS_FILES": "docs/source/index.rst\ndocs/README.md", + } + ), + text=True, ) - if output != "run=false": - msg = f"expected run=false, got {output!r}" + if completed.stdout.strip() != "run=false": + msg = f"expected run=false, got {completed.stdout.strip()!r}" raise CiPrPathsError(msg) def test_missing_pr_context_runs(self: TestCiPrPaths) -> None: """Test a pull_request without API context fails open and runs.""" - output = _run("openseries/*", env={"GITHUB_EVENT_NAME": "pull_request"}) - if output != "run=true": - msg = f"expected run=true, got {output!r}" + completed = subprocess.run( + ["/usr/bin/env", "bash", "scripts/ci-pr-paths.sh", "openseries/*"], + check=True, + capture_output=True, + cwd=ROOT, + env=_env({"GITHUB_EVENT_NAME": "pull_request"}), + text=True, + ) + if completed.stdout.strip() != "run=true": + msg = f"expected run=true, got {completed.stdout.strip()!r}" raise CiPrPathsError(msg) diff --git a/tests/test_package.py b/tests/test_package.py index 1468db8f..ebe5ca0c 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -2,14 +2,15 @@ from __future__ import annotations -import os +import importlib +import importlib.util import shutil -import subprocess -import sys import zipfile +from contextlib import chdir from importlib.metadata import metadata from pathlib import Path from re import match +from unittest.mock import patch import pytest @@ -25,40 +26,30 @@ class PackageTestError(Exception): ) -def _venv_executable(venv_dir: Path, name: str) -> Path: - if sys.platform == "win32": - return venv_dir / "Scripts" / f"{name}.exe" - return venv_dir / "bin" / name - - def _prepare_build_tree(build_dir: Path, project_root: Path) -> None: shutil.copytree(project_root / "openseries", build_dir / "openseries") for filename in ("pyproject.toml", "README.md", "LICENSE.md"): shutil.copy2(project_root / filename, build_dir / filename) -def _uv_executable() -> str: - uv_path = shutil.which("uv") - if uv_path is None: - msg = "uv executable not found on PATH" +@pytest.fixture(scope="module") +def built_wheel(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Build a wheel once for packaging tests.""" + project_root = Path(__file__).parent.parent + tmp_path = tmp_path_factory.mktemp("packaging") + build_dir = tmp_path / "project" + dist_dir = tmp_path / "dist" + build_dir.mkdir() + dist_dir.mkdir() + _prepare_build_tree(build_dir, project_root) + + with chdir(build_dir): + build_meta = importlib.import_module("setuptools.build_meta") + wheel_name = build_meta.build_wheel(str(dist_dir)) + if not isinstance(wheel_name, str): + msg = f"Expected wheel filename string, got: {wheel_name!r}" raise PackageTestError(msg) - return uv_path - - -def _run_checked( - command: list[str], - *, - cwd: Path | None = None, - env: dict[str, str] | None = None, -) -> subprocess.CompletedProcess[str]: - return subprocess.run( # noqa: S603 - command, - cwd=cwd, - check=True, - capture_output=True, - text=True, - env=env, - ) + return dist_dir / wheel_name class TestPackage: @@ -121,26 +112,12 @@ def test_metadata(self: TestPackage) -> None: raise PackageTestError(msg) @pytest.mark.xdist_group(name="packaging") - def test_wheel_includes_package_data(self: TestPackage, tmp_path: Path) -> None: + def test_wheel_includes_package_data( + self: TestPackage, + built_wheel: Path, + ) -> None: """Test wheel includes package data files required at runtime.""" - project_root = Path(__file__).parent.parent - build_dir = tmp_path / "project" - dist_dir = tmp_path / "dist" - build_dir.mkdir() - dist_dir.mkdir() - _prepare_build_tree(build_dir, project_root) - - _run_checked( - [_uv_executable(), "build", "--out-dir", str(dist_dir)], - cwd=build_dir, - ) - - wheel_files = list(dist_dir.glob("*.whl")) - if len(wheel_files) != 1: - msg = f"Expected one wheel file, found: {wheel_files}" - raise PackageTestError(msg) - - with zipfile.ZipFile(wheel_files[0]) as wheel: + with zipfile.ZipFile(built_wheel) as wheel: wheel_names = set(wheel.namelist()) missing_files = [ filename @@ -154,53 +131,28 @@ def test_wheel_includes_package_data(self: TestPackage, tmp_path: Path) -> None: @pytest.mark.xdist_group(name="packaging") def test_load_plotly_dict_from_installed_wheel( self: TestPackage, + built_wheel: Path, tmp_path: Path, ) -> None: - """Test load_plotly_dict works from an installed wheel.""" - project_root = Path(__file__).parent.parent - build_dir = tmp_path / "project" - dist_dir = tmp_path / "dist" - venv_dir = tmp_path / "venv" - build_dir.mkdir() - dist_dir.mkdir() - _prepare_build_tree(build_dir, project_root) - - _run_checked( - [_uv_executable(), "build", "--out-dir", str(dist_dir)], - cwd=build_dir, + """Test load_plotly_dict works from packaged wheel contents.""" + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + with zipfile.ZipFile(built_wheel) as wheel: + wheel.extractall(path=extract_dir) + + module_path = extract_dir / "openseries" / "load_plotly.py" + spec = importlib.util.spec_from_file_location( + "openseries_wheel_load_plotly", + module_path, ) - - wheel_files = list(dist_dir.glob("*.whl")) - if len(wheel_files) != 1: - msg = f"Expected one wheel file, found: {wheel_files}" + if spec is None or spec.loader is None: + msg = f"Failed to load module from wheel path: {module_path}" raise PackageTestError(msg) - _run_checked([sys.executable, "-m", "venv", str(venv_dir)]) - - pip = _venv_executable(venv_dir, "pip") - python = _venv_executable(venv_dir, "python") - _run_checked([str(pip), "install", str(wheel_files[0])]) - - env = os.environ.copy() - env.pop("PYTHONPATH", None) - result = subprocess.run( # noqa: S603 - [ - str(python), - "-c", - ( - "from openseries.load_plotly import load_plotly_dict; " - "fig, _ = load_plotly_dict(); " - "assert 'config' in fig and 'layout' in fig" - ), - ], - check=False, - capture_output=True, - text=True, - env=env, - ) - if result.returncode != 0: - msg = ( - "load_plotly_dict failed from installed wheel: " - f"{result.stderr or result.stdout}" - ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + with patch.object(module, "_check_remote_file_existence", return_value=True): + fig, _ = module.load_plotly_dict() + if "config" not in fig or "layout" not in fig: + msg = "load_plotly_dict failed from installed wheel: missing config/layout" raise PackageTestError(msg) diff --git a/uv.lock b/uv.lock index 2a1780e6..843d315a 100644 --- a/uv.lock +++ b/uv.lock @@ -967,6 +967,7 @@ dev = [ { name = "ruff" }, { name = "scipy-stubs", version = "1.17.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy-stubs", version = "1.18.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "setuptools" }, { name = "types-openpyxl" }, { name = "types-python-dateutil" }, { name = "types-requests" }, @@ -1001,6 +1002,7 @@ requires-dist = [ { name = "scikit-learn", specifier = ">=1.4.0" }, { name = "scipy", specifier = ">=1.14.1" }, { name = "scipy-stubs", marker = "extra == 'dev'", specifier = ">=1.14.1.0" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = ">=82" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=9.0.4" }, { name = "sphinx-autobuild", marker = "extra == 'docs'", specifier = ">=2025.8.25" }, { name = "sphinx-autodoc-typehints", marker = "extra == 'docs'", specifier = ">=3.6.0" }, @@ -1719,6 +1721,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/11/263ced30149fbede00614a11f01de792842d3636b5a9e3abfee34e764a0e/scipy_stubs-1.18.1.0-py3-none-any.whl", hash = "sha256:fc1f00da3eb6bf1adf2138774b5e6a91dcf7c77039ff199577dc47929c10793e", size = 653731, upload-time = "2026-08-22T09:22:51.43Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + [[package]] name = "six" version = "1.17.0"