added preflight check
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""Shared pytest fixtures for the orisys test suite.
|
||||
|
||||
This module was accidentally deleted in commit edc6e42 while four test modules
|
||||
(``test_backends.py``, ``test_smoke_api.py``, ``test_errors_and_cleanup.py``,
|
||||
``test_longrun_stability.py``) still depend on its fixtures, which made the
|
||||
whole suite unrunnable. It is restored here with two fixes:
|
||||
|
||||
* the default test video now points at clips that are actually tracked in the
|
||||
repository (``data/sample/``), instead of the never-committed
|
||||
``tests/idle_1min.mp4``;
|
||||
* the stale ``"prototype"`` default config in :func:`make_sensor` is gone, so
|
||||
callers cannot silently bypass the ``stitcher.box_size`` validation.
|
||||
|
||||
``tests/test_harness_guards.py`` guards against a repeat deletion.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_ROOT = REPO_ROOT / "src"
|
||||
if str(SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_ROOT))
|
||||
|
||||
from orisys import Sensor
|
||||
from orisys import configs as orisys_configs
|
||||
from orisys.backend_availability import check_cupy_available, check_opencl_available
|
||||
|
||||
# Video clips that are tracked in git, in preference order.
|
||||
DEFAULT_VIDEO_CANDIDATES = (
|
||||
Path("tests") / "idle_1min.mp4",
|
||||
Path("data") / "sample" / "s1_480p.mp4",
|
||||
Path("data") / "sample" / "s1_720p.mp4",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_test_video() -> Path:
|
||||
env_video = os.getenv("ORISYS_TEST_VIDEO")
|
||||
if env_video:
|
||||
raw = Path(env_video)
|
||||
candidates = [
|
||||
raw, # as provided (absolute or cwd-relative)
|
||||
REPO_ROOT / raw, # repo-relative
|
||||
]
|
||||
# Common typo fallback: "test/..." -> "tests/..."
|
||||
if env_video.startswith("test/") or env_video.startswith("test\\"):
|
||||
fixed = Path("tests", *Path(env_video).parts[1:])
|
||||
candidates.append(REPO_ROOT / fixed)
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return raw
|
||||
|
||||
for relative in DEFAULT_VIDEO_CANDIDATES:
|
||||
candidate = REPO_ROOT / relative
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
# Keep the reported path informative when nothing is available; the
|
||||
# ``test_video_path`` fixture turns this into a skip.
|
||||
return REPO_ROOT / DEFAULT_VIDEO_CANDIDATES[-1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_video_path() -> Path:
|
||||
path = _resolve_test_video()
|
||||
if not path.exists():
|
||||
pytest.skip(
|
||||
"Test video not found. Set ORISYS_TEST_VIDEO to a valid video path."
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_config_name() -> str:
|
||||
requested = os.getenv("ORISYS_TEST_CONFIG")
|
||||
|
||||
def _is_usable_config(name: str) -> bool:
|
||||
cfg = orisys_configs.load_config(name)
|
||||
return "box_size" in cfg.get("stitcher", {})
|
||||
|
||||
if requested:
|
||||
if not _is_usable_config(requested):
|
||||
raise RuntimeError(
|
||||
f"ORISYS_TEST_CONFIG='{requested}' is missing required key "
|
||||
"'stitcher.box_size'. Use a config like 'ddjx01' or provide a full path."
|
||||
)
|
||||
return requested
|
||||
|
||||
# Auto-pick a stable config for tests (avoid prototype variants missing keys).
|
||||
for name in orisys_configs.list_config():
|
||||
try:
|
||||
if _is_usable_config(name):
|
||||
return name
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"No usable config found for tests (missing 'stitcher.box_size'). "
|
||||
"Set ORISYS_TEST_CONFIG to a valid config JSON/path."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def available_backends() -> list[str]:
|
||||
backends = ["cpu"]
|
||||
if check_cupy_available():
|
||||
backends.append("cupy")
|
||||
if check_opencl_available():
|
||||
backends.append("opencl")
|
||||
return backends
|
||||
|
||||
|
||||
def make_sensor(
|
||||
video_path: Path,
|
||||
config_name: str,
|
||||
backend: str = "cpu",
|
||||
verbose: bool = False,
|
||||
) -> Sensor:
|
||||
use_cuda = backend in ("cupy", "opencl")
|
||||
return Sensor(
|
||||
vid_src=str(video_path),
|
||||
cuda=use_cuda,
|
||||
isstitch=True,
|
||||
verbose=verbose,
|
||||
config_name=config_name,
|
||||
backend=backend,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sensor_factory(test_video_path, test_config_name):
|
||||
def _factory(backend: str = "cpu", verbose: bool = False):
|
||||
return make_sensor(
|
||||
test_video_path,
|
||||
config_name=test_config_name,
|
||||
backend=backend,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
return _factory
|
||||
+1
-1
@@ -8,7 +8,7 @@ Flow:
|
||||
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
|
||||
5. Compute deformation/contact and visualize
|
||||
|
||||
SDK version: 0.5.1
|
||||
SDK version: 0.5.2
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Guards for the pytest harness itself.
|
||||
|
||||
``tests/conftest.py`` was accidentally deleted in commit edc6e42 while four test
|
||||
modules still depended on its fixtures, which made the suite unrunnable without
|
||||
anything failing loudly in CI. These tests turn that class of regression into an
|
||||
explicit failure, and also catch a stale ``orisys`` install silently shadowing
|
||||
``src/orisys`` (a real hazard: base/py312 environments have older orisys copies
|
||||
in site-packages).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFTEST_PATH = Path(__file__).resolve().parent / "conftest.py"
|
||||
|
||||
# Names that must exist in tests/conftest.py.
|
||||
REQUIRED_CONFTEST_NAMES = (
|
||||
"DEFAULT_VIDEO_CANDIDATES",
|
||||
"make_sensor",
|
||||
"test_video_path",
|
||||
"test_config_name",
|
||||
"available_backends",
|
||||
"sensor_factory",
|
||||
)
|
||||
|
||||
# Fixtures that must be resolvable through pytest.
|
||||
REQUIRED_FIXTURES = (
|
||||
"test_video_path",
|
||||
"test_config_name",
|
||||
"available_backends",
|
||||
"sensor_factory",
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _load_conftest_module():
|
||||
"""Load tests/conftest.py by path so a deletion is observable here."""
|
||||
spec = importlib.util.spec_from_file_location("_orisys_conftest_probe", CONFTEST_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise AssertionError(f"Cannot load {CONFTEST_PATH}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_conftest_defines_shared_fixtures():
|
||||
assert CONFTEST_PATH.is_file(), (
|
||||
f"{CONFTEST_PATH} is missing. test_backends.py, test_smoke_api.py, "
|
||||
"test_errors_and_cleanup.py and test_longrun_stability.py depend on its "
|
||||
"fixtures; it was accidentally deleted in commit edc6e42. Restore it "
|
||||
"instead of deleting it."
|
||||
)
|
||||
|
||||
module = _load_conftest_module()
|
||||
missing = [
|
||||
name for name in REQUIRED_CONFTEST_NAMES if getattr(module, name, None) is None
|
||||
]
|
||||
assert not missing, (
|
||||
f"tests/conftest.py no longer defines: {', '.join(missing)}. "
|
||||
"The shared sensor-test fixtures are required by the suite."
|
||||
)
|
||||
|
||||
|
||||
def test_default_test_video_is_reachable():
|
||||
"""At least one default candidate must exist, or every sensor test skips."""
|
||||
module = _load_conftest_module()
|
||||
candidates = [REPO_ROOT / Path(rel) for rel in module.DEFAULT_VIDEO_CANDIDATES]
|
||||
existing = [path for path in candidates if path.is_file()]
|
||||
assert existing, (
|
||||
"None of the default test videos exist:\n "
|
||||
+ "\n ".join(str(path) for path in candidates)
|
||||
+ "\nCommit a sample clip under data/sample/ or set ORISYS_TEST_VIDEO."
|
||||
)
|
||||
|
||||
|
||||
def test_fixtures_are_registered_with_pytest(request):
|
||||
missing: list[str] = []
|
||||
for name in REQUIRED_FIXTURES:
|
||||
try:
|
||||
request.getfixturevalue(name)
|
||||
except pytest.FixtureLookupError:
|
||||
missing.append(name)
|
||||
assert not missing, (
|
||||
f"pytest did not register these fixtures: {', '.join(missing)}. "
|
||||
"Check that tests/conftest.py is present and the @pytest.fixture "
|
||||
"decorators are intact."
|
||||
)
|
||||
|
||||
|
||||
def test_tests_run_against_checkout_not_stale_install():
|
||||
"""Fail loudly when an old orisys in site-packages shadows src/orisys."""
|
||||
src_pkg = (REPO_ROOT / "src" / "orisys").resolve()
|
||||
if not src_pkg.is_dir():
|
||||
pytest.skip("src/orisys is not present in this checkout layout")
|
||||
|
||||
import orisys
|
||||
|
||||
module_file = getattr(orisys, "__file__", None)
|
||||
assert module_file, (
|
||||
"import orisys resolved to a namespace package (__file__ is None) "
|
||||
f"instead of {src_pkg}. A stale orisys in site-packages is shadowing "
|
||||
"the checkout; run `pip uninstall -y orisys` in this environment."
|
||||
)
|
||||
|
||||
resolved_parent = Path(module_file).resolve().parent
|
||||
assert resolved_parent == src_pkg, (
|
||||
f"import orisys resolved to {resolved_parent} instead of {src_pkg}. "
|
||||
"A stale orisys in site-packages is shadowing the checkout; run "
|
||||
"`pip uninstall -y orisys` in this environment."
|
||||
)
|
||||
@@ -70,17 +70,22 @@ def test_sensor_detect_motion_ignores_invalid_region():
|
||||
backend="cpu",
|
||||
)
|
||||
try:
|
||||
# get_img() resizes the frame to the config's camera.resolution_stitch
|
||||
# and stores valid_mask at that same resolution, so the flow buffer must
|
||||
# be sized from sensor.valid_mask rather than from the raw frame.
|
||||
frame = np.zeros((728, 672, 3), dtype=np.uint8)
|
||||
mask = np.zeros((728, 672), dtype=bool)
|
||||
mask[180:540, 160:512] = True
|
||||
sensor.get_img(frame=frame, already_processed=True, valid_mask=mask)
|
||||
|
||||
sensor.flow = np.zeros((728, 672, 2), dtype=np.float32)
|
||||
sensor.flow[~mask] = 10.0
|
||||
flow_shape = sensor.valid_mask.shape
|
||||
assert sensor.valid_mask.any() and not sensor.valid_mask.all()
|
||||
sensor.flow = np.zeros((*flow_shape, 2), dtype=np.float32)
|
||||
sensor.flow[~sensor.valid_mask] = 10.0
|
||||
assert not sensor.detect_motion(threshold=1.0)
|
||||
|
||||
sensor.flow[:] = 0.0
|
||||
sensor.flow[mask] = 10.0
|
||||
sensor.flow[sensor.valid_mask] = 10.0
|
||||
assert sensor.detect_motion(threshold=1.0)
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils):
|
||||
sys.path.insert(0, str(build_dir))
|
||||
from common.pyproject_utils import get_version
|
||||
|
||||
assert get_version(REPO_ROOT) == "0.5.1"
|
||||
assert get_version(REPO_ROOT) == "0.5.2"
|
||||
|
||||
|
||||
def test_pyproject_bundle_manifest(packaging_utils):
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Tests for build/release version guards, report writing and preflight steps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
BUILD_ROOT = REPO_ROOT / "build"
|
||||
if str(BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BUILD_ROOT))
|
||||
|
||||
from release import report as report_mod # noqa: E402
|
||||
from release import version as version_mod # noqa: E402
|
||||
from release.preflight import Preflight, PreflightOptions # noqa: E402
|
||||
|
||||
PYPROJECT = """
|
||||
[project]
|
||||
name = "orisys"
|
||||
description = "synthetic"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "orisys.__version__"}
|
||||
""".lstrip()
|
||||
|
||||
|
||||
def _make_repo(tmp_path: Path, *, version: str = "0.5.2", with_git: bool = False) -> Path:
|
||||
(tmp_path / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8")
|
||||
package = tmp_path / "src" / "orisys"
|
||||
package.mkdir(parents=True)
|
||||
(package / "__init__.py").write_text(f'__version__ = "{version}"\n', encoding="utf-8")
|
||||
if with_git:
|
||||
(tmp_path / ".git").mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _stage_wheel(repo: Path, version: str, name: str | None = None) -> Path:
|
||||
directory = repo / "SDK" / version
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
wheel = directory / (name or f"orisys-{version}-cp310-cp310-win_amd64.whl")
|
||||
wheel.write_bytes(b"not really a wheel")
|
||||
return wheel
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self._buffer = io.BytesIO(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
def read(self, *args):
|
||||
return self._buffer.read(*args)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def test_wheel_version_parsing():
|
||||
assert version_mod.wheel_version(Path("orisys-0.5.2-cp310-cp310-win_amd64.whl")) == "0.5.2"
|
||||
assert version_mod.wheel_version(Path("orisys-1.0.0-1-cp312-abi3-manylinux_2_28_x86_64.whl")) == "1.0.0"
|
||||
assert version_mod.wheel_version(Path("something-else.whl")) is None
|
||||
|
||||
|
||||
def test_sdk_versions_are_numerically_sorted(tmp_path):
|
||||
for name in ("0.9.0", "0.4.10", "0.5.2"):
|
||||
(tmp_path / "SDK" / name).mkdir(parents=True)
|
||||
(tmp_path / "SDK" / "not-a-version").mkdir()
|
||||
assert version_mod.sdk_versions(tmp_path)[:3] == ["0.4.10", "0.5.2", "0.9.0"]
|
||||
|
||||
|
||||
def test_sdk_versions_empty_without_sdk_dir(tmp_path):
|
||||
assert version_mod.sdk_versions(tmp_path) == []
|
||||
|
||||
|
||||
def test_sdk_wheels_lists_only_that_version(tmp_path):
|
||||
repo = _make_repo(tmp_path)
|
||||
_stage_wheel(repo, "0.5.2")
|
||||
_stage_wheel(repo, "0.4.3")
|
||||
wheels = version_mod.sdk_wheels(repo, "0.5.2")
|
||||
assert [w.name for w in wheels] == ["orisys-0.5.2-cp310-cp310-win_amd64.whl"]
|
||||
|
||||
|
||||
def test_dynamic_version_attr_read_from_pyproject(tmp_path):
|
||||
repo = _make_repo(tmp_path)
|
||||
assert version_mod.dynamic_version_attr(repo) == "orisys.__version__"
|
||||
|
||||
|
||||
def test_dynamic_version_attr_none_when_absent(tmp_path):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n", encoding="utf-8")
|
||||
assert version_mod.dynamic_version_attr(tmp_path) is None
|
||||
|
||||
|
||||
def test_pypi_released_versions_parses_response(tmp_path, monkeypatch):
|
||||
fake = _FakeResponse({"releases": {"0.4.2": [{"filename": "a"}], "0.5.2": [{"filename": "b"}], "0.0.0": []}})
|
||||
monkeypatch.setattr(version_mod.urllib.request, "urlopen", lambda *a, **k: fake)
|
||||
versions, error = version_mod.pypi_released_versions()
|
||||
assert error is None
|
||||
# An empty file list means nothing was ever uploaded for that version.
|
||||
assert versions == ["0.4.2", "0.5.2"]
|
||||
|
||||
|
||||
def test_pypi_released_versions_reports_http_404_as_empty(monkeypatch):
|
||||
def _raise(*args, **kwargs):
|
||||
raise urllib.error.HTTPError(version_mod.PYPI_JSON_URL, 404, "not found", {}, None)
|
||||
|
||||
monkeypatch.setattr(version_mod.urllib.request, "urlopen", _raise)
|
||||
assert version_mod.pypi_released_versions() == ([], None)
|
||||
|
||||
|
||||
def test_pypi_released_versions_returns_error_on_failure(monkeypatch):
|
||||
def _raise(*args, **kwargs):
|
||||
raise urllib.error.URLError("no network")
|
||||
|
||||
monkeypatch.setattr(version_mod.urllib.request, "urlopen", _raise)
|
||||
versions, error = version_mod.pypi_released_versions()
|
||||
assert versions is None
|
||||
assert error and "URLError" in error
|
||||
|
||||
|
||||
def test_python_for_tag_uses_envs_root(tmp_path):
|
||||
executable = tmp_path / "py310" / "python.exe"
|
||||
executable.parent.mkdir(parents=True)
|
||||
executable.write_bytes(b"")
|
||||
assert version_mod.python_for_tag("cp310", envs_root=tmp_path) == executable
|
||||
assert version_mod.python_for_tag("cp311", envs_root=tmp_path) is None
|
||||
|
||||
|
||||
# --- report -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_report_round_trips(tmp_path):
|
||||
payload = {
|
||||
"version": "0.5.2",
|
||||
"ok": False,
|
||||
"git": {"commit": "abc", "branch": "dev", "dirty": True},
|
||||
"interpreter": {"version": "3.10.19", "executable": "python"},
|
||||
"duration_s": 1.5,
|
||||
"options": {"skip_tests": True},
|
||||
"steps": [{"name": "tests", "ok": True, "detail": "skipped", "duration_s": 0.0, "data": {}}],
|
||||
"wheels": [{"name": "w.whl", "size": 12, "sha256": "deadbeef"}],
|
||||
}
|
||||
json_path, text_path = report_mod.write_report(payload, tmp_path / "reports")
|
||||
loaded = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
assert loaded["schema"] == report_mod.SCHEMA_VERSION
|
||||
assert loaded["wheels"][0]["sha256"] == "deadbeef"
|
||||
|
||||
text = text_path.read_text(encoding="utf-8")
|
||||
assert "FAIL" in text
|
||||
assert "deadbeef" in text
|
||||
assert "waived guards: skip_tests" in text
|
||||
|
||||
|
||||
# --- preflight steps --------------------------------------------------------
|
||||
|
||||
|
||||
def test_preflight_resolves_version_from_pyproject(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.6.0")
|
||||
preflight = Preflight(PreflightOptions(), repo_root=repo)
|
||||
assert preflight.version == "0.6.0"
|
||||
assert preflight.version_source == "pyproject.toml dynamic attr"
|
||||
|
||||
|
||||
def test_preflight_explicit_version_wins(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.6.0")
|
||||
preflight = Preflight(PreflightOptions(version="0.7.0"), repo_root=repo)
|
||||
assert preflight.version == "0.7.0"
|
||||
assert preflight.version_source == "explicit --version"
|
||||
|
||||
|
||||
def test_repo_state_fails_outside_git(tmp_path, capsys):
|
||||
repo = _make_repo(tmp_path)
|
||||
step = Preflight(PreflightOptions(), repo_root=repo)._repo_state()
|
||||
assert not step.ok
|
||||
assert "not a git repository" in step.detail
|
||||
|
||||
|
||||
def test_version_consistency_detects_mismatch(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
step = Preflight(PreflightOptions(version="9.9.9"), repo_root=repo)._version_consistency()
|
||||
assert not step.ok
|
||||
assert "0.5.2" in step.detail and "9.9.9" in step.detail
|
||||
|
||||
|
||||
def test_version_consistency_passes_on_match(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._version_consistency()
|
||||
assert step.ok, step.detail
|
||||
|
||||
|
||||
def test_duplicate_guard_blocks_existing_sdk_dir(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
_stage_wheel(repo, "0.5.2")
|
||||
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._duplicate_guard()
|
||||
assert not step.ok
|
||||
assert "--force" in step.detail
|
||||
|
||||
|
||||
def test_duplicate_guard_allows_force(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
_stage_wheel(repo, "0.5.2")
|
||||
step = Preflight(
|
||||
PreflightOptions(version="0.5.2", force=True), repo_root=repo
|
||||
)._duplicate_guard()
|
||||
assert step.ok, step.detail
|
||||
|
||||
|
||||
def test_duplicate_guard_flags_published_version(tmp_path, monkeypatch):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
monkeypatch.setattr(version_mod, "pypi_released_versions", lambda *a, **k: (["0.5.2"], None))
|
||||
step = Preflight(
|
||||
PreflightOptions(version="0.5.2", check_pypi=True), repo_root=repo
|
||||
)._duplicate_guard()
|
||||
assert not step.ok
|
||||
assert "already published on PyPI" in step.detail
|
||||
|
||||
|
||||
def test_bundle_precheck_fails_when_pattern_resolves_nothing(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
(repo / "pyproject.toml").write_text(
|
||||
PYPROJECT
|
||||
+ """
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "configs"
|
||||
source = "config"
|
||||
target = "configs"
|
||||
patterns = ["*.json"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._bundle_precheck()
|
||||
assert not step.ok
|
||||
assert "resolved 0 files" in step.detail
|
||||
|
||||
|
||||
def test_wheel_inspection_fails_without_wheels(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._wheel_inspection()
|
||||
assert not step.ok
|
||||
assert "does not exist" in step.detail
|
||||
|
||||
|
||||
def test_wheel_runtime_fails_without_video(tmp_path, monkeypatch):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
_stage_wheel(repo, "0.5.2")
|
||||
monkeypatch.setattr("release.preflight.default_verification_video", lambda root: None)
|
||||
preflight = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)
|
||||
preflight._discover_wheels()
|
||||
step = preflight._wheel_runtime()
|
||||
assert not step.ok
|
||||
assert "never silently skipped" in step.detail
|
||||
|
||||
|
||||
def test_wheel_runtime_skip_is_recorded(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
step = Preflight(
|
||||
PreflightOptions(version="0.5.2", no_runtime=True), repo_root=repo
|
||||
)._wheel_runtime()
|
||||
assert step.ok
|
||||
assert "SKIPPED" in step.detail
|
||||
|
||||
|
||||
def test_tests_skip_is_loud_and_recorded(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
step = Preflight(
|
||||
PreflightOptions(version="0.5.2", skip_tests=True), repo_root=repo
|
||||
)._tests()
|
||||
assert step.ok
|
||||
assert "SKIPPED" in step.detail
|
||||
assert step.data["status"] == "skipped"
|
||||
|
||||
|
||||
def test_execute_marks_failure_and_aborts(tmp_path, capsys):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
preflight = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)
|
||||
preflight._execute(preflight._repo_state)
|
||||
assert preflight.aborted
|
||||
assert preflight.steps[-1].name == "repo-state"
|
||||
assert not preflight.steps[-1].ok
|
||||
|
||||
|
||||
def test_execute_keep_going_does_not_abort(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="0.5.2")
|
||||
preflight = Preflight(PreflightOptions(version="0.5.2", keep_going=True), repo_root=repo)
|
||||
preflight._execute(preflight._repo_state)
|
||||
assert not preflight.aborted
|
||||
@@ -19,25 +19,25 @@ def wheel_package():
|
||||
|
||||
def test_compliant_manylinux_tags(wheel_package):
|
||||
assert wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.1-cp310-cp310-manylinux_2_17_x86_64.whl")
|
||||
Path("orisys-0.5.2-cp310-cp310-manylinux_2_17_x86_64.whl")
|
||||
)
|
||||
assert wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.1-cp310-cp310-manylinux2014_x86_64.whl")
|
||||
Path("orisys-0.5.2-cp310-cp310-manylinux2014_x86_64.whl")
|
||||
)
|
||||
assert wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.1-cp310-cp310-manylinux_2_28_x86_64.whl")
|
||||
Path("orisys-0.5.2-cp310-cp310-manylinux_2_28_x86_64.whl")
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_illegal_manylinux_tags(wheel_package):
|
||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.1-cp310-cp310-manylinux_x86_64.whl")
|
||||
Path("orisys-0.5.2-cp310-cp310-manylinux_x86_64.whl")
|
||||
)
|
||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.1-cp310-cp310-linux_x86_64.whl")
|
||||
Path("orisys-0.5.2-cp310-cp310-linux_x86_64.whl")
|
||||
)
|
||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.1-cp310-cp310-win_amd64.whl")
|
||||
Path("orisys-0.5.2-cp310-cp310-win_amd64.whl")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Tests for :mod:`common.verify.wheel_contents`.
|
||||
|
||||
Every check is exercised in both directions: a well-formed synthetic wheel must
|
||||
pass, and each specific defect must fail with the check named in the report.
|
||||
Synthetic wheels keep the tests fast and keep them independent of whatever
|
||||
happens to sit in ``SDK/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
BUILD_ROOT = REPO_ROOT / "build"
|
||||
if str(BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BUILD_ROOT))
|
||||
|
||||
from common.verify import wheel_contents # noqa: E402
|
||||
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
||||
|
||||
VERSION = "0.5.2"
|
||||
DIST_INFO = f"orisys-{VERSION}.dist-info"
|
||||
|
||||
CORE_DEPS = ("numpy>=1.24.4", "opencv-python>=4.10.0.84")
|
||||
EXTRAS = {"cuda12": ("cupy-cuda12x>=12.3.0",), "gui": ("open3d>=0.17.0",)}
|
||||
|
||||
PYPROJECT = f"""
|
||||
[project]
|
||||
name = "orisys"
|
||||
version = "{VERSION}"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [{", ".join(repr(dep) for dep in CORE_DEPS)}]
|
||||
|
||||
[project.optional-dependencies]
|
||||
cuda12 = ["cupy-cuda12x>=12.3.0"]
|
||||
gui = ["open3d>=0.17.0"]
|
||||
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "configs"
|
||||
source = "config"
|
||||
target = "configs"
|
||||
patterns = ["*.json"]
|
||||
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "sensors"
|
||||
source = "sensors"
|
||||
target = "sensors"
|
||||
patterns = ["**/*.json"]
|
||||
preserve_tree = true
|
||||
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "docs"
|
||||
source = "docs"
|
||||
target = "docs"
|
||||
patterns = ["*.md"]
|
||||
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "examples"
|
||||
source = "examples"
|
||||
target = "examples"
|
||||
patterns = ["*.py", "README.md"]
|
||||
preserve_tree = true
|
||||
"""
|
||||
|
||||
# Source tree files, keyed by path relative to the synthetic repo root.
|
||||
SOURCE_FILES = {
|
||||
"config/box.json": '{"name": "box"}',
|
||||
"sensors/box/sensor.json": '{"name": "box"}',
|
||||
"sensors/finger/manifest.json": '{"name": "finger"}',
|
||||
"docs/api.md": "# api\n",
|
||||
"examples/basic_pipeline.py": "print('hi')\n",
|
||||
"examples/README.md": "# examples\n",
|
||||
}
|
||||
|
||||
# Where each source file must land inside the wheel.
|
||||
BUNDLED_ENTRIES = {
|
||||
"orisys/configs/box.json": SOURCE_FILES["config/box.json"],
|
||||
"orisys/sensors/box/sensor.json": SOURCE_FILES["sensors/box/sensor.json"],
|
||||
"orisys/sensors/finger/manifest.json": SOURCE_FILES["sensors/finger/manifest.json"],
|
||||
"orisys/docs/api.md": SOURCE_FILES["docs/api.md"],
|
||||
"orisys/examples/basic_pipeline.py": SOURCE_FILES["examples/basic_pipeline.py"],
|
||||
"orisys/examples/README.md": SOURCE_FILES["examples/README.md"],
|
||||
}
|
||||
|
||||
|
||||
def _metadata_text(
|
||||
*,
|
||||
version: str = VERSION,
|
||||
requires_python: str | None = ">=3.10",
|
||||
classifiers: tuple[str, ...] = ("Programming Language :: Python :: 3.10",),
|
||||
project_urls: tuple[str, ...] = ("Homepage, https://example.invalid",),
|
||||
license_line: str | None = "License: Proprietary",
|
||||
core_deps: tuple[str, ...] = CORE_DEPS,
|
||||
extras: dict[str, tuple[str, ...]] | None = None,
|
||||
dynamic: tuple[str, ...] = (),
|
||||
name: str = "orisys",
|
||||
) -> str:
|
||||
if extras is None:
|
||||
extras = EXTRAS
|
||||
lines = ["Metadata-Version: 2.4", f"Name: {name}", f"Version: {version}", "Summary: synthetic"]
|
||||
if requires_python:
|
||||
lines.append(f"Requires-Python: {requires_python}")
|
||||
if license_line:
|
||||
lines.append(license_line)
|
||||
lines.extend(f"Classifier: {value}" for value in classifiers)
|
||||
lines.extend(f"Project-URL: {value}" for value in project_urls)
|
||||
lines.extend(f"Dynamic: {value}" for value in dynamic)
|
||||
lines.extend(f"Requires-Dist: {dep}" for dep in core_deps)
|
||||
for extra, requirements in extras.items():
|
||||
lines.append(f"Provides-Extra: {extra}")
|
||||
lines.extend(f'Requires-Dist: {req}; extra == "{extra}"' for req in requirements)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _write_source_tree(root: Path) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8")
|
||||
for relative, text in SOURCE_FILES.items():
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# write_bytes, not write_text: text mode would translate "\n" to "\r\n"
|
||||
# on Windows and the byte-for-byte bundle comparison would fail.
|
||||
path.write_bytes(text.encode("utf-8"))
|
||||
|
||||
|
||||
def _record(entries: dict[str, bytes]) -> str:
|
||||
lines = []
|
||||
for name, data in sorted(entries.items()):
|
||||
digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode()
|
||||
lines.append(f"{name},sha256={digest},{len(data)}")
|
||||
lines.append(f"{DIST_INFO}/RECORD,,")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _make_wheel(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
filename: str | None = None,
|
||||
metadata: str | None = None,
|
||||
drop: tuple[str, ...] = (),
|
||||
extra_entries: dict[str, bytes] | None = None,
|
||||
override_bytes: dict[str, bytes] | None = None,
|
||||
drop_record: bool = False,
|
||||
corrupt_record_hash: bool = False,
|
||||
) -> tuple[Path, Path]:
|
||||
"""Build a synthetic wheel plus its matching source tree."""
|
||||
repo = tmp_path / "repo"
|
||||
_write_source_tree(repo)
|
||||
|
||||
if metadata is None:
|
||||
metadata = _metadata_text()
|
||||
|
||||
entries: dict[str, bytes] = {name: data.encode("utf-8") for name, data in BUNDLED_ENTRIES.items()}
|
||||
entries["orisys/__init__.cp312-win_amd64.pyd"] = b"compiled"
|
||||
entries["orisys_blend/__init__.py"] = b"# blend\n"
|
||||
entries["orisys_blend/kernel.py"] = b"# kernel\n"
|
||||
entries[f"{DIST_INFO}/METADATA"] = metadata.encode("utf-8")
|
||||
entries[f"{DIST_INFO}/WHEEL"] = b"Wheel-Version: 1.0\n"
|
||||
|
||||
entries.update(extra_entries or {})
|
||||
entries.update(override_bytes or {})
|
||||
for name in drop:
|
||||
entries.pop(name, None)
|
||||
|
||||
if not drop_record:
|
||||
record = _record(entries)
|
||||
if corrupt_record_hash:
|
||||
record = record.replace("sha256=", "sha256=AAAA", 1)
|
||||
entries[f"{DIST_INFO}/RECORD"] = record.encode("utf-8")
|
||||
|
||||
wheel = tmp_path / (filename or f"orisys-{VERSION}-cp312-cp312-win_amd64.whl")
|
||||
with zipfile.ZipFile(wheel, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in entries.items():
|
||||
zf.writestr(name, data)
|
||||
return wheel, repo
|
||||
|
||||
|
||||
def _failed_names(result) -> set[str]:
|
||||
return {check.name for check in result.failures}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_repo_root(monkeypatch, tmp_path):
|
||||
"""Keep inspect_wheel's default repo root from leaking the real repo in."""
|
||||
monkeypatch.setattr(wheel_contents, "REPO_ROOT", tmp_path / "unused")
|
||||
|
||||
|
||||
def test_good_wheel_passes(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path)
|
||||
result = inspect_wheel(wheel, expected_version=VERSION, repo_root=repo)
|
||||
assert result.ok, result.render()
|
||||
assert result.version == VERSION
|
||||
assert result.platform_tag == "win_amd64"
|
||||
assert not result.warnings, result.warnings
|
||||
|
||||
|
||||
def test_missing_wheel_file_fails(tmp_path):
|
||||
result = inspect_wheel(tmp_path / "nope-0.5.2-cp312-cp312-win_amd64.whl")
|
||||
assert not result.ok
|
||||
assert "wheel-exists" in _failed_names(result)
|
||||
|
||||
|
||||
def test_unparsable_filename_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, filename="orisys-0.5.2-cp312-cp312.whl")
|
||||
result = inspect_wheel(wheel, repo_root=repo)
|
||||
assert "filename" in _failed_names(result)
|
||||
|
||||
|
||||
def test_version_mismatch_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path)
|
||||
result = inspect_wheel(wheel, expected_version="9.9.9", repo_root=repo)
|
||||
failed = _failed_names(result)
|
||||
assert {"filename", "metadata-version"} <= failed
|
||||
|
||||
|
||||
def test_missing_requires_python_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(requires_python=None))
|
||||
assert "metadata-requires-python" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_classifier_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(classifiers=()))
|
||||
assert "metadata-classifiers" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_project_url_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(project_urls=()))
|
||||
assert "metadata-project-urls" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_license_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path,
|
||||
metadata=_metadata_text(license_line=None, classifiers=("Intended Audience :: Developers",)),
|
||||
)
|
||||
assert "metadata-license" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_dynamic_version_is_rejected(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(dynamic=("version", "summary")))
|
||||
result = inspect_wheel(wheel, repo_root=repo)
|
||||
assert "metadata-no-dynamic-version" in _failed_names(result)
|
||||
assert any("Dynamic" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
def test_missing_core_requirement_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(core_deps=("numpy>=1.24.4",)))
|
||||
assert "metadata-requires-dist" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_extra_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(extras={"cuda12": EXTRAS["cuda12"]}))
|
||||
assert "metadata-extras" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_compiled_extension_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, drop=("orisys/__init__.cp312-win_amd64.pyd",))
|
||||
assert "compiled-extension" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_python_source_leak_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, extra_entries={"orisys/sensor.py": b"# leaked\n"}
|
||||
)
|
||||
result = inspect_wheel(wheel, repo_root=repo)
|
||||
assert "no-python-sources" in _failed_names(result)
|
||||
assert "orisys/sensor.py" in result.render()
|
||||
|
||||
|
||||
def test_example_python_files_are_allowed(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, extra_entries={"orisys/examples/plot_data.py": b"# shipped on purpose\n"}
|
||||
)
|
||||
assert inspect_wheel(wheel, repo_root=repo).ok
|
||||
|
||||
|
||||
def test_missing_bundled_file_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, drop=("orisys/sensors/box/sensor.json",))
|
||||
result = inspect_wheel(wheel, repo_root=repo)
|
||||
assert "bundle-completeness" in _failed_names(result)
|
||||
assert "orisys/sensors/box/sensor.json" in result.render()
|
||||
|
||||
|
||||
def test_missing_bundled_file_warns_when_not_strict(tmp_path):
|
||||
"""A file added to the tree after an old wheel was built is not a defect."""
|
||||
wheel, repo = _make_wheel(tmp_path, drop=("orisys/sensors/box/sensor.json",))
|
||||
result = inspect_wheel(wheel, repo_root=repo, strict_bundle=False)
|
||||
assert result.ok, result.render()
|
||||
assert any("absent from the wheel" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
def test_bundle_drift_is_fatal_when_strict(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, override_bytes={"orisys/docs/api.md": b"# stale\n"}
|
||||
)
|
||||
assert "bundle-fidelity" in _failed_names(inspect_wheel(wheel, repo_root=repo, strict_bundle=True))
|
||||
|
||||
|
||||
def test_bundle_drift_warns_when_not_strict(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, override_bytes={"orisys/docs/api.md": b"# stale\n"}
|
||||
)
|
||||
result = inspect_wheel(wheel, repo_root=repo, strict_bundle=False)
|
||||
assert result.ok, result.render()
|
||||
assert any("differ from the source tree" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
def test_pycache_entry_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, extra_entries={"orisys/examples/__pycache__/x.pyc": b"junk"}
|
||||
)
|
||||
assert "no-forbidden-entries" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_scratch_directory_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, extra_entries={"tests/test_smoke.py": b"x"})
|
||||
assert "no-forbidden-entries" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_traversing_entry_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, extra_entries={"../escape.txt": b"x"})
|
||||
assert "archive-paths" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_record_hash_mismatch_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, corrupt_record_hash=True)
|
||||
assert "record" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_record_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, drop_record=True)
|
||||
assert "record" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_size_limit_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path)
|
||||
assert "size-limit" in _failed_names(inspect_wheel(wheel, repo_root=repo, max_bytes=1))
|
||||
|
||||
|
||||
def test_bad_manylinux_tag_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, filename=f"orisys-{VERSION}-cp312-cp312-linux_x86_64.whl"
|
||||
)
|
||||
assert "platform-tag" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_good_manylinux_tag_passes(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path,
|
||||
filename=f"orisys-{VERSION}-cp312-cp312-manylinux_2_28_x86_64.whl",
|
||||
)
|
||||
assert inspect_wheel(wheel, repo_root=repo).ok
|
||||
|
||||
|
||||
def test_wrong_distribution_name_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path,
|
||||
filename=f"notorisys-{VERSION}-cp312-cp312-win_amd64.whl",
|
||||
metadata=_metadata_text(name="notorisys"),
|
||||
)
|
||||
failed = _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
assert {"filename", "metadata-name"} <= failed
|
||||
|
||||
|
||||
def test_wheel_layout_module_resolves_to_build_tree():
|
||||
import wheel
|
||||
|
||||
resolved = Path(wheel.__file__).resolve()
|
||||
assert BUILD_ROOT.resolve() in resolved.parents, (
|
||||
f"`wheel` resolved to {resolved}; build/ must precede site-packages so the "
|
||||
"build tooling imports the repository's wheel package."
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests for build/wheel/package.py metadata plumbing.
|
||||
|
||||
The Nuitka wheel is built from a generated ``setup.py``, which is exactly where
|
||||
the 0.5.2 release silently lost ``Requires-Python``, classifiers, project URLs
|
||||
and the license. These tests run setuptools for real (in a temp dir, on a stub
|
||||
package tree) and assert on the METADATA it produces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
BUILD_ROOT = REPO_ROOT / "build"
|
||||
if str(BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BUILD_ROOT))
|
||||
|
||||
from wheel.package import metadata_kwargs, read_long_description, write_wheel_setup # noqa: E402
|
||||
|
||||
PROJECT = {
|
||||
"name": "orisys",
|
||||
"description": "SDK for Orisys Technology Visuotactile Sensors",
|
||||
"requires-python": ">=3.10",
|
||||
"license": {"text": "Proprietary"},
|
||||
"authors": [{"name": "Orisys-Tech"}],
|
||||
"maintainers": [{"name": "Yijian Zhang", "email": "zhangyijian@orisys-tech.cn"}],
|
||||
"dependencies": ["numpy>=1.24.4"],
|
||||
"optional-dependencies": {"gui": ["open3d>=0.17.0"]},
|
||||
"classifiers": [
|
||||
"Development Status :: 4 - Beta",
|
||||
"License :: Other/Proprietary License",
|
||||
],
|
||||
"urls": {
|
||||
"Homepage": "https://www.orisys-tech.cn",
|
||||
"Documentation": "https://github.com/Orisys-Tech/orisys-sdk",
|
||||
},
|
||||
}
|
||||
|
||||
LONG_DESCRIPTION = "# Orisys SDK\n\nlong description body.\n"
|
||||
|
||||
|
||||
def _stub_wheel_root(wheel_dir: Path) -> None:
|
||||
"""Minimal package tree so the generated setup.py can build metadata."""
|
||||
(wheel_dir / "wheel_root" / "orisys").mkdir(parents=True)
|
||||
(wheel_dir / "wheel_root" / "orisys" / "__init__.cp312-win_amd64.pyd").write_bytes(b"x")
|
||||
(wheel_dir / "wheel_root" / "orisys" / "examples").mkdir()
|
||||
(wheel_dir / "wheel_root" / "orisys" / "examples" / "__init__.py").write_text("", encoding="utf-8")
|
||||
(wheel_dir / "wheel_root" / "orisys_blend").mkdir()
|
||||
(wheel_dir / "wheel_root" / "orisys_blend" / "__init__.py").write_text("", encoding="utf-8")
|
||||
|
||||
|
||||
def _generate(wheel_dir: Path, tmp_path: Path) -> Path:
|
||||
_stub_wheel_root(wheel_dir)
|
||||
write_wheel_setup(
|
||||
wheel_dir,
|
||||
version="0.5.2",
|
||||
package_data={"orisys": ["*.pyd"]},
|
||||
install_requires=["numpy>=1.24.4"],
|
||||
extras_require={"gui": ["open3d>=0.17.0"]},
|
||||
project=PROJECT,
|
||||
long_description=LONG_DESCRIPTION,
|
||||
)
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "setup.py", "-q", "dist_info"],
|
||||
cwd=wheel_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"generated setup.py failed:\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
|
||||
)
|
||||
|
||||
candidates = sorted(wheel_dir.glob("**/*.dist-info/METADATA"))
|
||||
assert candidates, f"no METADATA produced under {wheel_dir}"
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def test_metadata_kwargs_translates_pyproject():
|
||||
kwargs = metadata_kwargs(PROJECT)
|
||||
assert kwargs["description"] == PROJECT["description"]
|
||||
assert kwargs["python_requires"] == ">=3.10"
|
||||
assert kwargs["license"] == "Proprietary"
|
||||
assert kwargs["classifiers"] == PROJECT["classifiers"]
|
||||
assert kwargs["project_urls"] == PROJECT["urls"]
|
||||
assert kwargs["author"] == "Orisys-Tech"
|
||||
assert kwargs["maintainer"] == "Yijian Zhang <zhangyijian@orisys-tech.cn>"
|
||||
|
||||
|
||||
def test_metadata_kwargs_accepts_string_license_and_drops_empties():
|
||||
kwargs = metadata_kwargs({"name": "orisys", "license": "Proprietary"})
|
||||
assert kwargs["license"] == "Proprietary"
|
||||
assert "python_requires" not in kwargs
|
||||
assert "classifiers" not in kwargs
|
||||
|
||||
|
||||
def test_read_long_description_uses_pypi_readme(tmp_path):
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "pypi_readme.md").write_text(LONG_DESCRIPTION, encoding="utf-8")
|
||||
assert read_long_description(tmp_path) == LONG_DESCRIPTION
|
||||
|
||||
|
||||
def test_read_long_description_missing_returns_none(tmp_path, capsys):
|
||||
assert read_long_description(tmp_path) is None
|
||||
assert "not found" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_generated_wheel_metadata_is_complete(tmp_path):
|
||||
wheel_dir = tmp_path / "wheel"
|
||||
wheel_dir.mkdir()
|
||||
metadata_path = _generate(wheel_dir, tmp_path)
|
||||
text = metadata_path.read_text(encoding="utf-8")
|
||||
|
||||
assert "Name: orisys" in text
|
||||
assert "Version: 0.5.2" in text
|
||||
assert f"Summary: {PROJECT['description']}" in text
|
||||
assert "Requires-Python: >=3.10" in text
|
||||
assert "License: Proprietary" in text
|
||||
assert "Classifier: Development Status :: 4 - Beta" in text
|
||||
assert "Classifier: License :: Other/Proprietary License" in text
|
||||
assert "Project-URL: Homepage, https://www.orisys-tech.cn" in text
|
||||
assert "Project-URL: Documentation, https://github.com/Orisys-Tech/orisys-sdk" in text
|
||||
assert "Requires-Dist: numpy>=1.24.4" in text
|
||||
assert 'Provides-Extra: gui' in text
|
||||
assert "Description-Content-Type: text/markdown" in text
|
||||
assert "long description body." in text
|
||||
|
||||
|
||||
def test_generated_wheel_metadata_keeps_version_static(tmp_path):
|
||||
"""``Version`` (and ``Name``) must never be annotated as dynamic.
|
||||
|
||||
setuptools marks every field supplied through the legacy ``setup()`` path as
|
||||
``Dynamic:`` even when the value is present and correct (verified by
|
||||
``test_generated_wheel_metadata_is_complete``). PEP 643 ``Dynamic`` is
|
||||
sdist-only and pip/PyPI ignore it in wheels, so only the identity fields are
|
||||
asserted here.
|
||||
"""
|
||||
wheel_dir = tmp_path / "wheel"
|
||||
wheel_dir.mkdir()
|
||||
text = _generate(wheel_dir, tmp_path).read_text(encoding="utf-8")
|
||||
dynamic = {line[len("Dynamic: ") :].strip() for line in text.splitlines() if line.startswith("Dynamic:")}
|
||||
assert "version" not in dynamic, f"wheel METADATA marks Version as dynamic: {sorted(dynamic)}"
|
||||
assert "name" not in dynamic, f"wheel METADATA marks Name as dynamic: {sorted(dynamic)}"
|
||||
|
||||
|
||||
def test_write_wheel_setup_without_project_keeps_legacy_shape(tmp_path):
|
||||
"""Calling without metadata must not crash (backwards compatible)."""
|
||||
wheel_dir = tmp_path / "wheel"
|
||||
wheel_dir.mkdir()
|
||||
setup_py = write_wheel_setup(
|
||||
wheel_dir,
|
||||
version="0.0.1",
|
||||
package_data={"orisys": ["*.pyd"]},
|
||||
install_requires=["numpy"],
|
||||
extras_require={},
|
||||
)
|
||||
text = setup_py.read_text(encoding="utf-8")
|
||||
assert 'name="orisys"' in text
|
||||
assert 'version="0.0.1"' in text
|
||||
assert "long_description" not in text
|
||||
|
||||
|
||||
def test_pypi_readme_exists_in_repo():
|
||||
assert (REPO_ROOT / "docs" / "pypi_readme.md").is_file(), (
|
||||
"docs/pypi_readme.md is the PyPI long description source; without it "
|
||||
"wheels are published with no project description."
|
||||
)
|
||||
Reference in New Issue
Block a user