added preflight check

This commit is contained in:
ykcheungab
2026-09-12 15:49:28 +08:00
parent 9c23ba3d50
commit 9537cf91a1
31 changed files with 3346 additions and 43 deletions
+145
View File
@@ -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
View File
@@ -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
+116
View File
@@ -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."
)
+8 -3
View File
@@ -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()
+1 -1
View File
@@ -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):
+291
View File
@@ -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
+6 -6
View File
@@ -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")
)
+378
View File
@@ -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."
)
+172
View File
@@ -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."
)