bb15b750fb
复用现有 Docker 和 Nuitka 流程,隔离架构镜像并支持 aarch64 标签。已通过 47 项测试、代码及文档门禁,并完成 Python 3.10 ARM64 wheel 构建、隔离导入与内容审计。
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""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 = "1.2.3"
|
|
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-1.2.3-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-1.2.3-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))
|
|
|
|
|
|
@pytest.mark.parametrize("architecture", ["x86_64", "aarch64"])
|
|
def test_good_manylinux_tag_passes(tmp_path, architecture):
|
|
wheel, repo = _make_wheel(
|
|
tmp_path,
|
|
filename=f"orisys-{VERSION}-cp312-cp312-manylinux_2_28_{architecture}.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."
|
|
)
|