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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ clean:
@. venv/bin/activate && \
pre-commit uninstall && \
rm -rf venv
rm -rf *.egg-info

builddocs:
@echo "📚 Building documentation..."
Expand Down
2 changes: 2 additions & 0 deletions make.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
109 changes: 64 additions & 45 deletions tests/test_ci_pr_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,82 +3,101 @@
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:
"""class to verify scripts/ci-pr-paths.sh output."""

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)
138 changes: 45 additions & 93 deletions tests/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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)
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading