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
+1
View File
@@ -10,6 +10,7 @@ __pycache__/
/build/dist/
/build/lib/
/build/pyinstaller_work/
/build/release/reports/
/build/*.spec
/dist/
/dist_obfuscated/
+21 -1
View File
@@ -12,6 +12,7 @@ build/
README.md
common/ shared helpers (paths, pyproject, verify)
wheel/ SDK .whl (Nuitka + manylinux Docker)
release/ local release gate (preflight / audit)
gui/
common/ shared PyInstaller driver
ory_finger/ ORY-FINGER dashboard
@@ -41,6 +42,22 @@ build\wheel\win.bat
.\build\wheel\docker\run.ps1
```
### Release gate
Local only: never publishes, and makes no network call unless `--check-pypi` is
passed. See `docs/publishing.md` for the full flow.
```bash
python build/run.py release preflight --version 0.5.2
python build/run.py release preflight --all-wheel-pythons --check-pypi
python build/run.py release audit SDK\0.5.2\orisys-0.5.2-cp312-cp312-win_amd64.whl
```
`preflight` runs: repo state → version consistency → duplicate guard → pytest →
bundle precheck → per-wheel static content/metadata assertions → isolated runtime
verification (bundled assets + real frames, no camera). Reports land in
`build/release/reports/<version>/`.
### GUI
```bash
@@ -53,13 +70,16 @@ python build/run.py gui box # scaffold / NotImplemented
```bash
python build/run.py verify path\to\orisys.whl
python build/run.py verify path\to\orisys.whl --contents --assets --runtime
python build/run.py verify --gui-dist --product ory_finger
```
## Outputs
- Wheels: `SDK/<version>/`, `dist/repaired/`
- Preflight reports: `build/release/reports/<version>/`
- Finger GUI: `dist/OrisysDashboard/`
- Future mini/box: `dist/OryMini/`, `dist/OryBox/`
Generated work files under `build/nuitka/` and `build/pyinstaller_work/` are gitignored.
Generated work files under `build/nuitka/`, `build/release/reports/` and
`build/pyinstaller_work/` are gitignored.
+15 -2
View File
@@ -1,6 +1,19 @@
"""Post-build smoke tests for wheels and product GUI folders."""
from common.verify.gui import GUI_PRODUCTS, verify_gui_dist
from common.verify.wheel import verify_built_wheel
from common.verify.wheel import (
verify_built_wheel,
verify_wheel_assets,
verify_wheel_runtime,
)
from common.verify.wheel_contents import WheelCheckResult, inspect_wheel
__all__ = ["GUI_PRODUCTS", "verify_built_wheel", "verify_gui_dist"]
__all__ = [
"GUI_PRODUCTS",
"WheelCheckResult",
"inspect_wheel",
"verify_built_wheel",
"verify_gui_dist",
"verify_wheel_assets",
"verify_wheel_runtime",
]
+83 -1
View File
@@ -3,9 +3,23 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from common.verify import GUI_PRODUCTS, verify_built_wheel, verify_gui_dist
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from common.verify import ( # noqa: E402
GUI_PRODUCTS,
inspect_wheel,
verify_built_wheel,
verify_gui_dist,
verify_wheel_assets,
verify_wheel_runtime,
)
from common.verify.wheel import DEFAULT_RUNTIME_CONFIG, default_verification_video # noqa: E402
def main(argv: list[str] | None = None) -> int:
@@ -18,6 +32,31 @@ def main(argv: list[str] | None = None) -> int:
help="GUI product profile (default: ory_finger)",
)
parser.add_argument("--dist-dir", type=Path, default=None, help="Override GUI dist folder")
parser.add_argument(
"--contents",
action="store_true",
help="Run static metadata/content assertions on the wheel",
)
parser.add_argument(
"--assets",
action="store_true",
help="Install the wheel in a temp venv and assert bundled assets load",
)
parser.add_argument(
"--runtime",
action="store_true",
help="Install the wheel and process real frames (no camera required)",
)
parser.add_argument("--expected-version", default=None, help="Assert the wheel version")
parser.add_argument(
"--strict-bundle",
action="store_true",
help="Treat bundled-file drift from the source tree as a failure",
)
parser.add_argument("--video", type=Path, default=None, help="Clip for --runtime")
parser.add_argument("--config", default=DEFAULT_RUNTIME_CONFIG, help="Config for --runtime")
parser.add_argument("--frames", type=int, default=40, help="Frames for --runtime")
parser.add_argument("--offline", action="store_true", help="Reuse parent-env dependencies")
parser.add_argument("wheel", nargs="?", type=Path, help="Wheel path for import smoke test")
args = parser.parse_args(argv)
@@ -26,6 +65,49 @@ def main(argv: list[str] | None = None) -> int:
return 0
if args.wheel is None:
parser.error("Provide a wheel path or pass --gui-dist")
exit_code = 0
if args.contents:
result = inspect_wheel(
args.wheel,
expected_version=args.expected_version,
repo_root=REPO_ROOT,
strict_bundle=args.strict_bundle,
)
print(result.render())
if not result.ok:
exit_code = 1
if args.assets or args.runtime:
try:
print(verify_wheel_assets(args.wheel))
except Exception as exc:
print(f"[FAIL] bundled assets: {type(exc).__name__}: {exc}")
exit_code = 1
if args.runtime:
video = args.video or default_verification_video(REPO_ROOT)
if video is None:
print("[FAIL] runtime: no clip found; pass --video or set ORISYS_VERIFY_VIDEO")
return 1
try:
info = verify_wheel_runtime(
args.wheel,
video=video,
config_name=args.config,
frames=args.frames,
offline=args.offline,
)
print(f"[ok ] runtime: {info}")
except Exception as exc:
print(f"[FAIL] runtime: {type(exc).__name__}: {exc}")
exit_code = 1
if args.contents or args.assets or args.runtime:
return exit_code
# Default behaviour is unchanged: import smoke test in an isolated venv.
verify_built_wheel(args.wheel)
return 0
+342 -13
View File
@@ -1,7 +1,21 @@
"""Isolated venv smoke tests for packaged orisys wheels."""
"""Isolated venv smoke tests for packaged orisys wheels.
Three levels of confidence, all in a throwaway virtualenv so the active
environment is never touched:
* :func:`verify_built_wheel` - the public API imports (fast, original check);
* :func:`verify_wheel_assets` - bundled configs/sensors resolve and load via
``importlib.resources``;
* :func:`verify_wheel_runtime` - the compiled wheel actually processes real
frames through the public pipeline with no camera attached.
Every level can be bootstrapped from another interpreter so each supported
CPython ABI can be verified against its own wheel.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
@@ -38,6 +52,167 @@ assert Stitcher is not None
print(f"orisys {orisys.__version__} public API imports OK")
"""
# Bundled data that must be resolvable and loadable from inside site-packages.
# The wheel ships the compiled package, so these paths only exist if the bundle
# step actually ran for this build.
_ASSET_SCRIPT = r"""
import sys
from importlib.metadata import distribution
from pathlib import Path
import numpy as np
import orisys
# Locate bundled data through the installed distribution, NOT through
# orisys.__file__ / importlib.resources. Nuitka compiles the package into one
# extension module whose __init__ installs nuitka_module_loader, which reports a
# synthetic __file__ (e.g. ...\site-packages\orisys\orisys\__init__.py) and a
# synthetic __path__ ending in '\not_existing'. Neither points at the real data
# directory, so importlib.resources silently finds nothing in a Nuitka wheel.
dist = distribution("orisys")
dist_root = Path(dist.locate_file("")).resolve()
package_root = Path(dist.locate_file("orisys")).resolve()
_diagnosis = (
f"orisys distribution resolved to {dist_root} (sys.prefix={sys.prefix!r}, "
f"sys.executable={sys.executable!r}), outside the verification venv. A stale "
"orisys in the parent environment is visible through --system-site-packages; "
"run `pip uninstall -y orisys` there, or re-run without --offline."
)
assert str(Path(sys.prefix).resolve()) in str(dist_root), _diagnosis
assert package_root.is_dir(), f"package directory not found: {package_root}"
required = [
"configs/box.json",
"configs/finger.json",
"sensors/box/force_calibration.json",
"sensors/box/sensor.json",
"sensors/finger/manifest.json",
"sensors/finger/sensor.json",
"sensors/mini/sensor.json",
"sensors/finger/assets/30fps-0703/mesh.npz",
"sensors/finger/assets/30fps-0703/camera_intrinsics.npz",
"sensors/finger/assets/30fps-0703/world_to_cam.npy",
"sensors/finger/assets/visualization/mesh.npz",
"sensors/finger/assets/visualization/world_to_cam.npy",
]
missing = [name for name in required if not (package_root / name).is_file()]
assert not missing, (
f"bundled assets missing from the wheel: {missing}\n"
f"package root: {package_root}\ninstalled tree: "
f"{sorted(p.name for p in package_root.iterdir()) if package_root.is_dir() else '<missing>'}"
)
for name in required:
path = package_root / name
if name.endswith(".npz"):
data = np.load(str(path))
assert len(data.files) > 0, f"{name} is an empty npz"
elif name.endswith(".npy"):
arr = np.load(str(path))
assert arr.size > 0, f"{name} is an empty npy"
configs = orisys.list_config()
assert configs, "orisys.list_config() returned nothing"
print(f"bundled assets OK ({len(required)} files, {len(configs)} configs) "
f"from {package_root}")
"""
# End-to-end pipeline run against a video file: no camera, no GUI, no imshow.
_RUNTIME_SCRIPT = r"""
import json
import os
import sys
from importlib.metadata import distribution
from pathlib import Path
import numpy as np
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
import orisys
# Provenance via the installed distribution: a Nuitka-compiled package reports a
# synthetic orisys.__file__ (see the asset check) and a stale orisys visible
# through --system-site-packages would otherwise satisfy this check.
dist = distribution("orisys")
dist_root = Path(dist.locate_file("")).resolve()
assert str(Path(sys.prefix).resolve()) in str(dist_root), (
f"the installed orisys came from {dist_root}, outside the verification venv "
f"{sys.prefix!r}; run `pip uninstall -y orisys` in the parent environment or "
"re-run without --offline."
)
video, config_name, frames = sys.argv[1], sys.argv[2], int(sys.argv[3])
sensor = orisys.Sensor(
vid_src=video,
config_name=config_name,
backend="cpu",
verbose=False,
)
processed = 0
try:
for _ in range(frames):
img = sensor.get_img()
assert img is not None, "get_img returned None"
assert isinstance(img, np.ndarray) and img.ndim in (2, 3)
sensor.compute_deformation(check_motion=False)
flow, vnormal, vshear = sensor.read_info(
sensor.info.VRAW, sensor.info.VNORMAL, sensor.info.VSHEAR
)
for name, array in (("flow", flow), ("vnormal", vnormal), ("vshear", vshear)):
assert np.isfinite(array).all(), f"{name} contains NaN/Inf"
contact = sensor.compute_contact()
assert isinstance(contact, dict) and "centroid_found" in contact
fnormal, fsx, fsy = sensor.read_info(
sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY
)
assert np.isfinite([fnormal, fsx, fsy]).all(), "force output contains NaN/Inf"
processed += 1
finally:
sensor.disconnect()
assert processed == frames, f"only processed {processed}/{frames} frames"
print(json.dumps({
"version": orisys.__version__,
"frames": processed,
"flow_shape": list(flow.shape),
"video": video,
"config": config_name,
"package_root": str(Path(dist.locate_file("orisys")).resolve()),
}))
"""
# Default runtime-verification inputs. Overridable by CLI flags or environment.
DEFAULT_RUNTIME_CONFIG = os.getenv("ORISYS_VERIFY_CONFIG", "box")
# Clips tracked in the repository, smallest first.
DEFAULT_VIDEO_CANDIDATES = (
Path("data") / "sample" / "s1_480p.mp4",
Path("data") / "sample" / "s1_720p.mp4",
Path("tests") / "idle_1min.mp4",
)
def default_verification_video(repo_root: Path) -> Path | None:
"""Resolve the clip used for the end-to-end runtime check, if any."""
env_video = os.getenv("ORISYS_VERIFY_VIDEO")
repo_root = Path(repo_root)
if env_video:
candidate = Path(env_video)
return candidate if candidate.is_absolute() else repo_root / candidate
for relative in DEFAULT_VIDEO_CANDIDATES:
candidate = repo_root / relative
if candidate.is_file():
return candidate
return None
def _venv_python(venv_dir: Path) -> Path:
if sys.platform == "win32":
@@ -45,18 +220,172 @@ def _venv_python(venv_dir: Path) -> Path:
return venv_dir / "bin" / "python"
def verify_built_wheel(wheel_path: Path) -> None:
"""Install the wheel in a temporary venv and smoke-test public imports."""
wheel_path = wheel_path.resolve()
with tempfile.TemporaryDirectory(prefix="orisys-wheel-verify-") as tmp:
venv_dir = Path(tmp) / "venv"
venv.create(venv_dir, with_pip=True)
python = _venv_python(venv_dir)
def _hermetic_env() -> dict:
"""Environment for the verification venv with PYTHONPATH removed.
subprocess.check_call(
[python, "-m", "pip", "install", "-q", "--upgrade", "pip"],
An inherited PYTHONPATH=src would let the checkout satisfy the checks
instead of the installed wheel, which is precisely the failure mode the
provenance assertions in the scripts exist to catch.
"""
return {key: value for key, value in os.environ.items() if key != "PYTHONPATH"}
def _create_venv(
venv_dir: Path,
*,
python_executable: Path | None = None,
system_site_packages: bool = False,
) -> Path:
"""Create a throwaway venv, optionally from a different interpreter."""
if python_executable is None:
venv.create(venv_dir, with_pip=True, system_site_packages=system_site_packages)
else:
command = [str(python_executable), "-m", "venv"]
if system_site_packages:
command.append("--system-site-packages")
command.append(str(venv_dir))
subprocess.check_call(command, env=_hermetic_env())
return _venv_python(venv_dir)
def _install_wheel(
python: Path, wheel_path: Path, *, offline: bool = False, env: dict | None = None
) -> None:
"""Install the wheel under test, guaranteeing it lands in the venv.
``--force-reinstall --no-deps`` is not optional: pip reports a same-version
requirement as "already satisfied" and skips the file, which is fatal here
because ``--offline`` also exposes the parent environment's site-packages.
Without it the venv check can silently validate a *different* orisys than
the one passed in (the scripts additionally assert provenance).
"""
if offline:
# Dependencies come from the parent environment via --system-site-packages.
commands = [
[
str(python),
"-m",
"pip",
"install",
"-q",
"--no-index",
"--no-deps",
"--force-reinstall",
str(wheel_path),
]
]
else:
commands = [
[str(python), "-m", "pip", "install", "-q", "--upgrade", "pip"],
[str(python), "-m", "pip", "install", "-q", str(wheel_path)],
[
str(python),
"-m",
"pip",
"install",
"-q",
"--force-reinstall",
"--no-deps",
str(wheel_path),
],
]
for command in commands:
subprocess.check_call(command, stdout=subprocess.DEVNULL, env=env)
class WheelVerificationError(RuntimeError):
"""A verification step failed inside the throwaway venv.
Carries the child's stdout/stderr so the real traceback is visible instead of
a bare non-zero exit code.
"""
def _run_in_fresh_venv(
wheel_path: Path,
script: str,
args: list[str] | None = None,
*,
python_executable: Path | None = None,
offline: bool = False,
prefix: str = "orisys-wheel-verify-",
) -> str:
wheel_path = wheel_path.resolve()
env = _hermetic_env()
with tempfile.TemporaryDirectory(prefix=prefix) as tmp:
venv_dir = Path(tmp) / "venv"
python = _create_venv(
venv_dir,
python_executable=python_executable,
system_site_packages=offline,
)
subprocess.check_call(
[python, "-m", "pip", "install", "-q", str(wheel_path)],
_install_wheel(python, wheel_path, offline=offline, env=env)
completed = subprocess.run(
[str(python), "-c", script, *(args or [])],
capture_output=True,
text=True,
env=env,
check=False,
)
subprocess.check_call([python, "-c", _SMOKE_IMPORT_SCRIPT])
if completed.returncode != 0:
command = " ".join(str(part) for part in (args or []))
raise WheelVerificationError(
f"verification script failed in the venv (exit {completed.returncode}"
+ (f", args: {command}" if command else "")
+ ").\n--- stdout ---\n"
+ (completed.stdout.strip() or "<empty>")
+ "\n--- stderr ---\n"
+ (completed.stderr.strip() or "<empty>")
)
return completed.stdout.strip()
def verify_built_wheel(wheel_path: Path, *, python_executable: Path | None = None) -> None:
"""Install the wheel in a temporary venv and smoke-test public imports."""
_run_in_fresh_venv(
wheel_path, _SMOKE_IMPORT_SCRIPT, python_executable=python_executable
)
def verify_wheel_assets(wheel_path: Path, *, python_executable: Path | None = None) -> str:
"""Assert the wheel carries loadable bundled configs and sensor assets."""
return _run_in_fresh_venv(
wheel_path,
_ASSET_SCRIPT,
python_executable=python_executable,
prefix="orisys-wheel-assets-",
)
def verify_wheel_runtime(
wheel_path: Path,
*,
video: Path,
config_name: str,
frames: int = 40,
python_executable: Path | None = None,
offline: bool = False,
) -> dict:
"""Run real frames through the installed wheel with no camera attached.
This is the only check that proves the compiled extension plus the bundled
sensor assets actually produce finite flow/force output end to end.
"""
video = Path(video).resolve()
if not video.is_file():
raise FileNotFoundError(f"verification video not found: {video}")
if frames < 1:
raise ValueError("frames must be >= 1")
output = _run_in_fresh_venv(
wheel_path,
_RUNTIME_SCRIPT,
[str(video), config_name, str(frames)],
python_executable=python_executable,
offline=offline,
prefix="orisys-wheel-runtime-",
)
import json
return json.loads(output.splitlines()[-1])
+501
View File
@@ -0,0 +1,501 @@
"""Static content and metadata validation for built orisys wheels.
Everything here is pure standard library: the wheel is opened as a zip and
inspected in place. No installation, no network, no hardware.
The checks encode the guarantees a Nuitka-compiled release must satisfy:
* the filename, ``METADATA`` and ``RECORD`` agree on name/version/tags;
* PyPI-visible metadata (``Requires-Python``, classifiers, project URLs,
license) is present instead of silently dropped by the hand-written
``setup.py`` template in :mod:`wheel.package`;
* no Python sources ship inside ``orisys/`` (IP containment) while the compiled
extension is present;
* every file declared by ``[[tool.orisys.bundle.items]]`` is present and
byte-identical to the source tree;
* no caches, build leftovers or scratch directories leak in.
"""
from __future__ import annotations
import base64
import hashlib
import re
import sys
import zipfile
from dataclasses import dataclass, field
from email.parser import Parser
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from common.pyproject_utils import bundle_items, extras_require, install_requires, project_metadata # noqa: E402
from wheel.layout import resolve_bundle_source_files # noqa: E402
from wheel.package import is_compliant_manylinux_wheel # noqa: E402
# PyPI's default per-project file limit is 100 MB; keep headroom.
MAX_WHEEL_BYTES = 90 * 1024 * 1024
_FILENAME_RE = re.compile(
r"^(?P<name>[^-]+)-(?P<version>[^-]+)-(?P<python>[^-]+)-(?P<abi>[^-]+)-(?P<platform>.+)\.whl$"
)
_COMPILED_EXT_RE = re.compile(r"^orisys/__init__\..+\.(?:pyd|so)$")
_FORBIDDEN_PREFIXES = (
"test/",
"tests/",
"data/",
"build/",
"dist/",
"sdk/",
".git",
)
_FORBIDDEN_SUFFIXES = (".pyc", ".pyo", ".c", ".pdb", ".map", ".spec")
@dataclass(frozen=True)
class Check:
name: str
ok: bool
detail: str = ""
def render(self) -> str:
return f"[{'ok' if self.ok else 'FAIL'}] {self.name}: {self.detail}"
@dataclass
class WheelCheckResult:
wheel: Path
version: str | None = None
name: str | None = None
platform_tag: str | None = None
entry_count: int = 0
uncompressed_bytes: int = 0
file_bytes: int = 0
checks: list[Check] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return all(check.ok for check in self.checks)
@property
def failures(self) -> list[Check]:
return [check for check in self.checks if not check.ok]
def add(self, name: str, ok: bool, detail: str = "") -> bool:
self.checks.append(Check(name=name, ok=ok, detail=detail))
return ok
def warn(self, message: str) -> None:
self.warnings.append(message)
def render(self) -> str:
lines = [
f"wheel: {self.wheel.name}",
f" version={self.version} platform={self.platform_tag} "
f"entries={self.entry_count} file_size={self.file_bytes} "
f"uncompressed={self.uncompressed_bytes}",
]
lines.extend(f" {check.render()}" for check in self.checks)
lines.extend(f" [warn] {message}" for message in self.warnings)
lines.append(f" RESULT: {'PASS' if self.ok else 'FAIL'}")
return "\n".join(lines)
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _requirement_name(spec: str) -> str:
return re.split(r"[<>=!~\[; ]", spec, maxsplit=1)[0].strip().lower().replace("_", "-")
def _metadata_message(zf: zipfile.ZipFile, result: WheelCheckResult):
candidates = [name for name in zf.namelist() if name.endswith(".dist-info/METADATA")]
if not candidates:
result.add("metadata-present", False, "no *.dist-info/METADATA entry in the wheel")
return None
raw = zf.read(candidates[0]).decode("utf-8", errors="replace")
message = Parser().parsestr(raw)
result.add("metadata-present", True, candidates[0])
return message
def _check_filename(result: WheelCheckResult, expected_version: str | None) -> None:
match = _FILENAME_RE.match(result.wheel.name)
if match is None:
result.add("filename", False, f"unparsable wheel filename: {result.wheel.name}")
return
result.name = match.group("name")
result.version = match.group("version")
result.platform_tag = match.group("platform")
problems = []
if result.name != "orisys":
problems.append(f"distribution name is {result.name!r}, expected 'orisys'")
if expected_version is not None and result.version != expected_version:
problems.append(f"filename version {result.version!r} != expected {expected_version!r}")
result.add("filename", not problems, "; ".join(problems) or result.wheel.name)
def _check_metadata(
result: WheelCheckResult,
message,
expected_version: str | None,
repo_root: Path | None,
) -> None:
if message is None:
return
result.add(
"metadata-name",
message.get("Name") == "orisys",
f"Name={message.get('Name')!r}",
)
declared = message.get("Version")
expected = expected_version or result.version
if expected is not None and declared != expected:
result.add(
"metadata-version",
False,
f"METADATA Version={declared!r} != expected {expected!r}",
)
else:
result.add("metadata-version", True, f"Version={declared!r}")
requires_python = (message.get("Requires-Python") or "").strip()
result.add(
"metadata-requires-python",
bool(requires_python),
f"Requires-Python={requires_python!r}" if requires_python else "Requires-Python is missing",
)
classifiers = message.get_all("Classifier") or []
result.add(
"metadata-classifiers",
bool(classifiers),
f"{len(classifiers)} classifier(s)" if classifiers else "no Classifier fields",
)
project_urls = message.get_all("Project-URL") or []
result.add(
"metadata-project-urls",
bool(project_urls),
f"{len(project_urls)} Project-URL(s)" if project_urls else "no Project-URL fields",
)
license_values = [
(message.get("License") or "").strip(),
(message.get("License-Expression") or "").strip(),
]
has_license = any(license_values) or any(
value.startswith("License ::") for value in classifiers
)
result.add(
"metadata-license",
has_license,
f"License={license_values[0]!r} License-Expression={license_values[1]!r}"
if has_license
else "no License / License-Expression / 'License ::' classifier",
)
dynamic = message.get_all("Dynamic") or []
result.add(
"metadata-no-dynamic-version",
"version" not in dynamic,
f"Dynamic={dynamic!r}" if dynamic else "no Dynamic fields",
)
if dynamic:
result.warn(f"METADATA still declares Dynamic fields: {dynamic!r}")
if repo_root is not None and (repo_root / "pyproject.toml").is_file():
requires_dist = message.get_all("Requires-Dist") or []
provided = {_requirement_name(entry) for entry in requires_dist}
missing_core = [
spec for spec in install_requires(repo_root) if _requirement_name(spec) not in provided
]
result.add(
"metadata-requires-dist",
not missing_core,
f"missing core Requires-Dist entries: {missing_core!r}"
if missing_core
else f"{len(requires_dist)} Requires-Dist entries",
)
extras = message.get_all("Provides-Extra") or []
missing_extras = sorted(set(extras_require(repo_root)) - set(extras))
result.add(
"metadata-extras",
not missing_extras,
f"missing Provides-Extra: {missing_extras!r}"
if missing_extras
else f"Provides-Extra={sorted(extras)!r}",
)
def _check_compiled_only(result: WheelCheckResult, names: list[str]) -> None:
compiled = [name for name in names if _COMPILED_EXT_RE.match(name)]
result.add(
"compiled-extension",
bool(compiled),
f"{compiled[0]}" if compiled else "no orisys/__init__.<abi>.pyd|.so entry",
)
# orisys/examples/** is shipped as package data on purpose; every other
# orisys/**/*.py means source code leaked past the Nuitka compile step.
sources = [
name
for name in names
if name.startswith("orisys/")
and name.endswith(".py")
and not name.startswith("orisys/examples/")
]
result.add(
"no-python-sources",
not sources,
f"python sources leaked into the wheel: {sources[:5]!r}"
if sources
else "compiled package ships no python sources (examples excluded by design)",
)
blend = [name for name in names if name.startswith("orisys_blend/")]
result.add(
"orisys-blend-present",
"orisys_blend/__init__.py" in blend and "orisys_blend/kernel.py" in blend,
f"{sorted(blend)!r}",
)
def _check_bundle_fidelity(
result: WheelCheckResult,
zf: zipfile.ZipFile,
names: set[str],
repo_root: Path | None,
strict: bool,
) -> None:
if repo_root is None or not (repo_root / "pyproject.toml").is_file():
result.warn("pyproject.toml not found; bundle fidelity checks skipped")
return
missing: list[str] = []
mismatched: list[str] = []
checked = 0
for item in bundle_items(repo_root):
target = str(item["target"]).replace("\\", "/")
source_dir = repo_root / item["source"]
for source_file in resolve_bundle_source_files(repo_root, item):
if item.get("preserve_tree", False):
relative = source_file.relative_to(source_dir).as_posix()
entry = f"orisys/{target}/{relative}"
else:
entry = f"orisys/{target}/{source_file.name}"
if entry not in names:
missing.append(entry)
continue
checked += 1
if _sha256(zf.read(entry)) != _sha256(source_file.read_bytes()):
mismatched.append(entry)
# Both entries here mean "the wheel does not match this revision of the
# source tree". Against a fresh build that is fatal; against an already
# released wheel it is expected, so ``strict`` (set by preflight, unset by
# audit) decides whether it fails or warns.
if missing:
detail = (
f"{len(missing)} declared file(s) absent from the wheel (added to the "
f"source tree after this build?): {missing[:5]!r}"
)
result.add("bundle-completeness", not strict, detail)
if not strict:
result.warn(detail)
else:
result.add(
"bundle-completeness", True, f"{checked} declared bundled file(s) present"
)
if mismatched:
detail = (
f"{len(mismatched)} of {checked} bundled file(s) differ from the source "
f"tree (wheel predates or postdates it): {mismatched[:5]!r}"
)
result.add("bundle-fidelity", not strict, detail)
if not strict:
result.warn(detail)
else:
result.add(
"bundle-fidelity",
True,
f"{checked} bundled file(s) byte-identical to source",
)
def _check_forbidden_entries(result: WheelCheckResult, names: list[str]) -> None:
offenders = []
for name in names:
lowered = name.lower()
if lowered.startswith(_FORBIDDEN_PREFIXES):
offenders.append(name)
elif lowered.endswith(_FORBIDDEN_SUFFIXES):
offenders.append(name)
elif "__pycache__" in lowered or ".egg-info" in lowered:
offenders.append(name)
result.add(
"no-forbidden-entries",
not offenders,
f"unexpected entries: {offenders[:5]!r}" if offenders else "no caches/leftovers/scratch paths",
)
def _check_archive_paths(result: WheelCheckResult, names: list[str]) -> None:
unsafe = [
name
for name in names
if name.startswith(("/", "\\")) or ".." in Path(name.replace("\\", "/")).parts
]
result.add(
"archive-paths",
not unsafe,
f"absolute or traversing entries: {unsafe[:5]!r}" if unsafe else "all entries are relative",
)
def _check_record(result: WheelCheckResult, zf: zipfile.ZipFile, names: list[str]) -> None:
records = [name for name in names if name.endswith(".dist-info/RECORD")]
if not records:
result.add("record", False, "no *.dist-info/RECORD entry")
return
bad_paths: list[str] = []
bad_hashes: list[str] = []
bad_sizes: list[str] = []
present = set(names)
for line in zf.read(records[0]).decode("utf-8").splitlines():
if not line.strip():
continue
parts = line.split(",")
while len(parts) < 3:
parts.append("")
path, hash_field, size_field = parts[0], parts[1], parts[2]
if path not in present:
bad_paths.append(path)
continue
if not hash_field.startswith("sha256="):
continue
expected = hash_field[len("sha256=") :]
actual = base64.urlsafe_b64encode(
hashlib.sha256(zf.read(path)).digest()
).rstrip(b"=").decode("ascii")
if actual != expected:
bad_hashes.append(path)
if size_field and size_field.isdigit():
if int(size_field) != len(zf.read(path)):
bad_sizes.append(path)
problems = []
if bad_paths:
problems.append(f"paths not in archive: {bad_paths[:5]!r}")
if bad_hashes:
problems.append(f"sha256 mismatch: {bad_hashes[:5]!r}")
if bad_sizes:
problems.append(f"size mismatch: {bad_sizes[:5]!r}")
result.add(
"record",
not problems,
"; ".join(problems) if problems else f"{records[0]} verifies",
)
def _check_platform_tag(result: WheelCheckResult) -> None:
tag = result.platform_tag
if tag is None:
return
if "win" in tag:
ok = tag == "win_amd64"
result.add("platform-tag", ok, f"{tag!r} (expected 'win_amd64')")
return
ok = is_compliant_manylinux_wheel(result.wheel)
result.add(
"platform-tag",
ok,
f"{tag!r}" if ok else f"{tag!r} is not a PyPI-accepted manylinux x86_64 tag",
)
def _check_size(result: WheelCheckResult, max_bytes: int) -> None:
result.add(
"size-limit",
result.file_bytes <= max_bytes,
f"{result.file_bytes} bytes (limit {max_bytes})",
)
def inspect_wheel(
wheel_path: Path,
*,
expected_version: str | None = None,
repo_root: Path | None = None,
max_bytes: int = MAX_WHEEL_BYTES,
strict_bundle: bool = True,
) -> WheelCheckResult:
"""Validate one built wheel without installing it.
``strict_bundle`` controls how the wheel's bundled files are compared with the
current source tree: ``True`` (gate a fresh build, used by
``release preflight``) fails on any missing or changed declared file;
``False`` (audit an already-released wheel) reports the same findings as
warnings.
"""
wheel_path = Path(wheel_path).resolve()
if repo_root is None:
repo_root = REPO_ROOT if (REPO_ROOT / "pyproject.toml").is_file() else None
else:
repo_root = Path(repo_root).resolve()
result = WheelCheckResult(wheel=wheel_path)
if not wheel_path.is_file():
result.add("wheel-exists", False, f"not a file: {wheel_path}")
return result
result.file_bytes = wheel_path.stat().st_size
result.add("wheel-exists", True, str(wheel_path))
_check_filename(result, expected_version)
_check_platform_tag(result)
_check_size(result, max_bytes)
try:
zf = zipfile.ZipFile(wheel_path)
except zipfile.BadZipFile as exc:
result.add("zip-integrity", False, f"not a valid zip: {exc}")
return result
with zf:
corrupt = zf.testzip()
result.add(
"zip-integrity",
corrupt is None,
"CRC check passed" if corrupt is None else f"corrupt entry: {corrupt}",
)
names = zf.namelist()
name_set = set(names)
result.entry_count = len(names)
result.uncompressed_bytes = sum(info.file_size for info in zf.infolist())
_check_archive_paths(result, names)
_check_forbidden_entries(result, names)
message = _metadata_message(zf, result)
_check_metadata(result, message, expected_version, repo_root)
_check_compiled_only(result, names)
_check_bundle_fidelity(result, zf, name_set, repo_root, strict_bundle)
_check_record(result, zf, names)
return result
+7
View File
@@ -0,0 +1,7 @@
"""Local release tooling: preflight gate and per-wheel audit.
Entry point is ``python build/run.py release <command>``; see
:mod:`release.preflight` for the gate steps and :mod:`release.cli` for the CLI.
Imports are intentionally not re-exported here so importing a submodule never
triggers the whole subsystem.
"""
+163
View File
@@ -0,0 +1,163 @@
"""Command line for ``python build/run.py release``."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from common.verify.wheel import ( # noqa: E402
DEFAULT_RUNTIME_CONFIG,
default_verification_video,
verify_wheel_assets,
verify_wheel_runtime,
)
from common.verify.wheel_contents import inspect_wheel # noqa: E402
from release.preflight import PreflightOptions, run_preflight # noqa: E402
def _cmd_preflight(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
prog="build/run.py release preflight",
description="Gate a release locally: version, tests, wheel content, runtime.",
)
parser.add_argument("--version", help="release version (default: pyproject's dynamic version)")
parser.add_argument("--wheel-dir", type=Path, help="directory to inspect (default: SDK/<version>)")
parser.add_argument("--video", type=Path, help="clip for the end-to-end runtime check")
parser.add_argument("--config", default=DEFAULT_RUNTIME_CONFIG, help="config name for that check")
parser.add_argument("--frames", type=int, default=40, help="frames to process (default: 40)")
parser.add_argument("--skip-tests", action="store_true", help="skip pytest (recorded in the report)")
parser.add_argument("--longrun", action="store_true", help="include the slow long-run stability test")
parser.add_argument("--allow-dirty", action="store_true", help="allow a dirty working tree")
parser.add_argument("--force", action="store_true", help="allow re-staging an existing SDK/<version>")
parser.add_argument("--check-pypi", action="store_true", help="read PyPI and fail if the version exists")
parser.add_argument(
"--all-wheel-pythons",
action="store_true",
help="verify cp310/cp311/cp312 wheels, each in its own conda env",
)
parser.add_argument("--offline", action="store_true", help="reuse parent-env deps instead of PyPI")
parser.add_argument("--no-runtime", action="store_true", help="skip the isolated runtime check")
parser.add_argument("--no-assets", action="store_true", help="skip the bundled-asset check")
parser.add_argument("--keep-going", action="store_true", help="collect every failure instead of stopping")
parser.add_argument("--report-dir", type=Path, help="override build/release/reports")
args = parser.parse_args(argv)
options = PreflightOptions(
version=args.version,
wheel_dir=args.wheel_dir,
video=args.video,
config_name=args.config,
frames=args.frames,
skip_tests=args.skip_tests,
longrun=args.longrun,
allow_dirty=args.allow_dirty,
force=args.force,
check_pypi=args.check_pypi,
all_wheel_pythons=args.all_wheel_pythons,
offline=args.offline,
keep_going=args.keep_going,
report_dir=args.report_dir,
no_runtime=args.no_runtime,
no_assets=args.no_assets,
)
return run_preflight(options, repo_root=REPO_ROOT)
def _cmd_audit(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
prog="build/run.py release audit",
description="Inspect one built wheel without a release context.",
)
parser.add_argument("wheel", type=Path, help="path to an .whl")
parser.add_argument("--expected-version", help="assert the wheel reports this version")
parser.add_argument(
"--strict-bundle",
action="store_true",
help="treat bundled-file drift from the current source tree as a failure",
)
parser.add_argument("--runtime", action="store_true", help="also install and run the wheel")
parser.add_argument("--assets", action="store_true", help="also assert bundled assets load")
parser.add_argument("--video", type=Path, help="clip for --runtime")
parser.add_argument("--config", default=DEFAULT_RUNTIME_CONFIG)
parser.add_argument("--frames", type=int, default=40)
parser.add_argument("--offline", action="store_true")
parser.add_argument(
"--python",
type=Path,
default=None,
help="interpreter to build the verification venv from (default: the running one)",
)
args = parser.parse_args(argv)
wheel = args.wheel
if not wheel.is_file():
print(f"[ERROR] wheel not found: {wheel}", file=sys.stderr)
return 2
exit_code = 0
result = inspect_wheel(
wheel,
expected_version=args.expected_version,
repo_root=REPO_ROOT,
strict_bundle=args.strict_bundle,
)
print(result.render())
if not result.ok:
exit_code = 1
if args.assets or args.runtime:
try:
print(verify_wheel_assets(wheel, python_executable=args.python))
except Exception as exc:
print(f"[FAIL] bundled assets: {type(exc).__name__}: {exc}")
exit_code = 1
if args.runtime:
video = args.video or default_verification_video(REPO_ROOT)
if video is None:
print("[FAIL] runtime: no verification clip; pass --video or set ORISYS_VERIFY_VIDEO")
return 1
try:
info = verify_wheel_runtime(
wheel,
video=video,
config_name=args.config,
frames=args.frames,
python_executable=args.python,
offline=args.offline,
)
print(f"[ok ] runtime: {info}")
except Exception as exc:
print(f"[FAIL] runtime: {type(exc).__name__}: {exc}")
exit_code = 1
return exit_code
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
print("Commands: preflight | audit")
return 0
command, rest = argv[0], argv[1:]
if command == "preflight":
return _cmd_preflight(rest)
if command == "audit":
return _cmd_audit(rest)
print(f"Unknown release command: {command!r}", file=sys.stderr)
print("Choose from: preflight, audit", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+515
View File
@@ -0,0 +1,515 @@
"""Local release gate: everything that must hold before publishing a wheel.
``release preflight`` answers one question with an exit code: is this checkout,
this version and these wheels safe to publish? It never publishes anything, and
by default it makes no network calls.
Steps, in order:
1. ``repo-state`` - git repository, clean working tree, recorded commit
2. ``version-consistency`` - __version__ == pyproject dynamic attr == wheel names
3. ``duplicate-guard`` - SDK/<version>/ already populated; optionally PyPI
4. ``tests`` - pytest (slow/hardware markers excluded)
5. ``bundle-precheck`` - every bundle item resolves; required assets present
6. ``wheel-inspection`` - static metadata/content assertions per wheel
7. ``wheel-runtime`` - isolated venv: bundled assets load and real frames
flow through the public pipeline
Output lands in ``build/release/reports/<version>/preflight.{json,txt}``.
"""
from __future__ import annotations
import hashlib
import json
import platform
import subprocess
import sys
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from common.pyproject_utils import bundle_items, get_version # noqa: E402
from common.verify.wheel import ( # noqa: E402
DEFAULT_RUNTIME_CONFIG,
default_verification_video,
verify_wheel_assets,
verify_wheel_runtime,
)
from common.verify.wheel_contents import inspect_wheel # noqa: E402
from release import report as report_mod # noqa: E402
from release import version as version_mod # noqa: E402
from wheel.layout import resolve_bundle_source_files, verify_required_assets # noqa: E402
@dataclass
class Step:
name: str
ok: bool
detail: str = ""
duration_s: float = 0.0
data: dict = field(default_factory=dict)
@dataclass
class PreflightOptions:
version: str | None = None
wheel_dir: Path | None = None
video: Path | None = None
config_name: str = DEFAULT_RUNTIME_CONFIG
frames: int = 40
skip_tests: bool = False
longrun: bool = False
allow_dirty: bool = False
force: bool = False
check_pypi: bool = False
all_wheel_pythons: bool = False
offline: bool = False
keep_going: bool = False
report_dir: Path | None = None
no_runtime: bool = False
no_assets: bool = False
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class Preflight:
def __init__(self, options: PreflightOptions, repo_root: Path | None = None) -> None:
self.options = options
self.repo_root = Path(repo_root or REPO_ROOT)
self.steps: list[Step] = []
self.git: dict = {"commit": None, "branch": None, "dirty": None}
self.wheels: list[Path] = []
self.wheel_dir: Path | None = None
self.runtime_records: list[dict] = []
self.aborted = False
self.ok = False
if options.version:
self.version = options.version
self.version_source = "explicit --version"
elif (self.repo_root / "pyproject.toml").is_file():
self.version = get_version(self.repo_root)
self.version_source = "pyproject.toml dynamic attr"
else:
self.version = "0.0.0"
self.version_source = "unresolved"
self.report_dir = report_mod.reports_root(self.repo_root, options.report_dir) / self.version
# -- helpers ---------------------------------------------------------
def _git(self, *args: str) -> str:
try:
completed = subprocess.run(
["git", *args],
cwd=self.repo_root,
capture_output=True,
text=True,
check=False,
)
except OSError:
return ""
return (completed.stdout or "").strip()
def _execute(self, step_fn) -> None:
started = time.perf_counter()
try:
step = step_fn()
except Exception as exc: # a crashed step is a failed step, never a silent pass
step = Step(name=getattr(step_fn, "__name__", "step").lstrip("_").replace("_", "-"),
ok=False, detail=f"crashed: {type(exc).__name__}: {exc}")
step.duration_s = round(time.perf_counter() - started, 3)
self.steps.append(step)
flag = "ok " if step.ok else "FAIL"
print(f"[{flag}] {step.name} ({step.duration_s:.2f}s)")
for line in str(step.detail).splitlines():
print(f" {line}")
if not step.ok and not self.options.keep_going:
self.aborted = True
print(" stopping: pass --keep-going to collect every failure")
# -- steps -----------------------------------------------------------
def _repo_state(self) -> Step:
if not (self.repo_root / ".git").exists():
return Step("repo-state", False, f"{self.repo_root} is not a git repository")
commit = self._git("rev-parse", "HEAD")
branch = self._git("rev-parse", "--abbrev-ref", "HEAD")
porcelain = self._git("status", "--porcelain")
dirty = bool(porcelain)
self.git = {"commit": commit, "branch": branch, "dirty": dirty}
if dirty and not self.options.allow_dirty:
preview = "\n".join(porcelain.splitlines()[:20])
return Step(
"repo-state",
False,
f"working tree is dirty; commit, stash or pass --allow-dirty:\n{preview}",
data=self.git,
)
detail = f"{branch} @ {commit[:12]} dirty={dirty} version={self.version} ({self.version_source})"
if dirty:
detail += " (accepted via --allow-dirty)"
return Step("repo-state", True, detail, data=self.git)
def _version_consistency(self) -> Step:
problems: list[str] = []
data: dict = {"version": self.version, "source": self.version_source}
attr = version_mod.dynamic_version_attr(self.repo_root)
data["dynamic_attr"] = attr
if attr is None:
problems.append(
"pyproject.toml no longer declares tool.setuptools.dynamic.version.attr"
)
if (self.repo_root / "pyproject.toml").is_file():
resolved = get_version(self.repo_root)
data["resolved_version"] = resolved
if resolved != self.version:
problems.append(
f"src/orisys/__init__.py __version__ is {resolved!r} but the release "
f"version is {self.version!r}"
)
sdk_dirs = version_mod.sdk_versions(self.repo_root)
data["sdk_versions"] = sdk_dirs
for wheel in version_mod.sdk_wheels(self.repo_root, self.version):
found = version_mod.wheel_version(wheel)
if found != self.version:
problems.append(f"{wheel.name} carries version {found!r}, expected {self.version!r}")
detail = (
f"version={self.version} ({self.version_source}) dynamic_attr={attr}\n"
f"SDK/ versions present: {sdk_dirs or 'none'}"
)
if problems:
detail += "\n" + "\n".join(problems)
return Step("version-consistency", not problems, detail, data=data)
def _duplicate_guard(self) -> Step:
problems: list[str] = []
lines: list[str] = []
existing = version_mod.sdk_wheels(self.repo_root, self.version)
if existing:
if self.options.force:
lines.append(
f"SDK/{self.version}/ already holds {len(existing)} wheel(s); "
"overwriting because --force was passed"
)
else:
problems.append(
f"SDK/{self.version}/ already holds {len(existing)} wheel(s): "
f"{[w.name for w in existing]}. Pass --force to rebuild over them."
)
else:
lines.append(f"SDK/{self.version}/ is empty (no wheel has been staged yet)")
if self.options.check_pypi:
versions, error = version_mod.pypi_released_versions()
if error:
problems.append(f"PyPI lookup failed: {error}")
elif versions is None:
problems.append("PyPI lookup returned no version list")
elif self.version in versions:
problems.append(
f"orisys {self.version} is already published on PyPI; a PyPI upload "
"cannot be undone. Published: " + ", ".join(versions)
)
else:
lines.append(
f"PyPI has no {self.version}; published versions: " + (", ".join(versions) or "none")
)
else:
lines.append("PyPI existence check skipped (pass --check-pypi to enable)")
detail = "\n".join(lines)
if problems:
detail += "\n" + "\n".join(problems)
return Step("duplicate-guard", not problems, detail)
def _tests(self) -> Step:
if self.options.skip_tests:
return Step(
"tests",
True,
"WARNING: test gate SKIPPED via --skip-tests; this run cannot certify a release",
data={"status": "skipped"},
)
marker = "not hardware" if self.options.longrun else "not slow and not hardware"
self.report_dir.mkdir(parents=True, exist_ok=True)
junit = self.report_dir / "junit.xml"
command = [
sys.executable,
"-m",
"pytest",
"-q",
"-m",
marker,
f"--junitxml={junit}",
]
try:
completed = subprocess.run(
command, cwd=self.repo_root, capture_output=True, text=True, check=False
)
except FileNotFoundError as exc:
return Step("tests", False, f"cannot run pytest: {exc}")
ok = completed.returncode == 0
tail = ((completed.stdout or "") + (completed.stderr or "")).strip().splitlines()[-12:]
detail = f"pytest -m {marker!r} in {sys.executable}\nexit code {completed.returncode}"
if tail:
detail += "\n" + "\n".join(tail)
return Step("tests", ok, detail, data={"marker": marker, "junit": str(junit)})
def _bundle_precheck(self) -> Step:
if not (self.repo_root / "pyproject.toml").is_file():
return Step("bundle-precheck", False, "pyproject.toml not found")
items = bundle_items(self.repo_root)
problems: list[str] = []
counts: dict[str, int] = {}
for item in items:
files = resolve_bundle_source_files(self.repo_root, item)
counts[str(item.get("name"))] = len(files)
if not files:
problems.append(
f"bundle item {item.get('name')!r} resolved 0 files; its patterns match nothing"
)
try:
verify_required_assets(self.repo_root, items)
except FileNotFoundError as exc:
problems.append(str(exc))
detail = "resolved files per bundle item: " + json.dumps(counts)
if problems:
detail += "\n" + "\n".join(problems)
return Step("bundle-precheck", not problems, detail, data={"counts": counts})
def _discover_wheels(self) -> str | None:
"""Populate self.wheels; return an error message when nothing was found."""
directory = (
Path(self.options.wheel_dir)
if self.options.wheel_dir
else self.repo_root / "SDK" / self.version
)
self.wheel_dir = directory
if not directory.is_dir():
return (
f"{directory} does not exist. Build first "
"(`python build/run.py wheel`) or pass --wheel-dir."
)
self.wheels = sorted(directory.glob("*.whl"))
if not self.wheels:
return f"no *.whl found in {directory}. Build first or pass --wheel-dir."
return None
def _wheel_inspection(self) -> Step:
problem = self._discover_wheels()
if problem:
return Step("wheel-inspection", False, problem)
problems: list[str] = []
blocks: list[str] = []
for wheel in self.wheels:
result = inspect_wheel(
wheel,
expected_version=self.version,
repo_root=self.repo_root,
strict_bundle=True,
)
blocks.append(result.render())
if not result.ok:
problems.append(
f"{wheel.name}: " + ", ".join(check.name for check in result.failures)
)
detail = f"{len(self.wheels)} wheel(s) in {self.wheel_dir}\n" + "\n".join(blocks)
if problems:
detail += "\nfailed checks:\n" + "\n".join(problems)
return Step("wheel-inspection", not problems, detail, data={"count": len(self.wheels)})
def _platform_token(self) -> str:
return "win_amd64" if sys.platform == "win32" else "manylinux"
def _runtime_targets(self) -> list[tuple[str, Path, Path | None]]:
token = self._platform_token()
if self.options.all_wheel_pythons:
targets: list[tuple[str, Path, Path | None]] = []
for tag in ("cp310", "cp311", "cp312"):
match = next(
(w for w in self.wheels if f"-{tag}-" in w.name and token in w.name), None
)
if match is None:
raise RuntimeError(
f"no {tag} {token} wheel among {[w.name for w in self.wheels]}"
)
executable = version_mod.python_for_tag(tag)
if executable is None:
raise RuntimeError(
f"cannot resolve an interpreter for {tag}; create the conda env "
f"py{tag[2:]} or set CONDA_ENVS_ROOT"
)
targets.append((tag, match, executable))
return targets
tag = f"cp{sys.version_info.major}{sys.version_info.minor}"
match = next((w for w in self.wheels if f"-{tag}-" in w.name and token in w.name), None)
if match is None:
raise RuntimeError(
f"no {tag} {token} wheel among {[w.name for w in self.wheels]}; pass "
"--all-wheel-pythons to verify every ABI in its own environment, or run from "
"a matching interpreter"
)
return [(tag, match, None)]
def _wheel_runtime(self) -> Step:
if self.options.no_runtime:
return Step(
"wheel-runtime",
True,
"WARNING: runtime verification SKIPPED via --no-runtime",
data={"status": "skipped"},
)
video = self.options.video or default_verification_video(self.repo_root)
if video is None:
return Step(
"wheel-runtime",
False,
"no verification clip found. Pass --video, set ORISYS_VERIFY_VIDEO, or commit a "
"clip under data/sample/. The runtime check is never silently skipped.",
)
video = Path(video)
if not video.is_file():
return Step("wheel-runtime", False, f"verification clip not found: {video}")
try:
targets = self._runtime_targets()
except RuntimeError as exc:
return Step("wheel-runtime", False, str(exc))
problems: list[str] = []
lines: list[str] = [f"clip: {video}", f"config: {self.options.config_name}"]
for tag, wheel, executable in targets:
label = f"{tag} {wheel.name}"
try:
if self.options.no_assets:
assets_out = "asset check skipped (--no-assets)"
else:
assets_out = verify_wheel_assets(wheel, python_executable=executable)
runtime_out = verify_wheel_runtime(
wheel,
video=video,
config_name=self.options.config_name,
frames=self.options.frames,
python_executable=executable,
offline=self.options.offline,
)
except subprocess.CalledProcessError as exc:
problems.append(f"{label}: command failed ({exc})")
continue
except Exception as exc:
problems.append(f"{label}: {type(exc).__name__}: {exc}")
continue
lines.append(f"{label}: {assets_out} | frames={runtime_out['frames']} flow={runtime_out['flow_shape']}")
self.runtime_records.append({"tag": tag, "wheel": wheel.name, **runtime_out})
detail = "\n".join(lines)
if problems:
detail += "\nfailures:\n" + "\n".join(problems)
return Step("wheel-runtime", not problems, detail, data={"records": self.runtime_records})
# -- driver ----------------------------------------------------------
def run(self) -> int:
started_at = _now()
started = time.perf_counter()
print(f"=== orisys release preflight: version {self.version} ({self.version_source}) ===")
print(f"repo: {self.repo_root}")
print(f"report: {self.report_dir}")
print()
try:
for step_fn in (
self._repo_state,
self._version_consistency,
self._duplicate_guard,
self._tests,
self._bundle_precheck,
self._wheel_inspection,
self._wheel_runtime,
):
self._execute(step_fn)
if self.aborted:
break
finally:
self.ok = bool(self.steps) and all(step.ok for step in self.steps) and not self.aborted
duration = round(time.perf_counter() - started, 3)
payload = {
"version": self.version,
"version_source": self.version_source,
"ok": self.ok,
"aborted": self.aborted,
"started_at": started_at,
"finished_at": _now(),
"duration_s": duration,
"git": self.git,
"interpreter": {
"executable": sys.executable,
"version": platform.python_version(),
"platform": sys.platform,
},
"options": {key: value for key, value in asdict(self.options).items()
if not isinstance(value, Path)},
"steps": [asdict(step) for step in self.steps],
"wheels": [
{
"name": wheel.name,
"size": wheel.stat().st_size,
"sha256": _sha256_file(wheel),
}
for wheel in self.wheels
],
}
payload["options"]["wheel_dir"] = str(self.options.wheel_dir) if self.options.wheel_dir else None
payload["options"]["video"] = str(self.options.video) if self.options.video else None
json_path, text_path = report_mod.write_report(payload, self.report_dir)
print()
print(f"report: {json_path}")
print(f"report: {text_path}")
print()
print(f"=== preflight {'PASSED' if self.ok else 'FAILED'} ({duration}s) ===")
return 0 if self.ok else 1
def run_preflight(options: PreflightOptions, repo_root: Path | None = None) -> int:
return Preflight(options, repo_root=repo_root).run()
+71
View File
@@ -0,0 +1,71 @@
"""Structured preflight reports.
A preflight run writes both a machine-readable ``preflight.json`` (the contract a
future publish step will consume and re-verify) and a human ``preflight.txt``.
"""
from __future__ import annotations
import json
from pathlib import Path
REPORTS_RELPATH = Path("build") / "release" / "reports"
SCHEMA_VERSION = 1
def reports_root(repo_root: Path, override: Path | None = None) -> Path:
return Path(override) if override else Path(repo_root) / REPORTS_RELPATH
def render_text(payload: dict) -> str:
git = payload.get("git") or {}
interpreter = payload.get("interpreter") or {}
lines = [
f"orisys release preflight :: version {payload.get('version')}",
f" result: {'PASS' if payload.get('ok') else 'FAIL'}",
f" git: {git.get('branch')} @ {git.get('commit')} (dirty={git.get('dirty')})",
f" interpreter: {interpreter.get('version')} at {interpreter.get('executable')}",
f" duration: {payload.get('duration_s')}s",
"",
]
for step in payload.get("steps", []):
flag = "ok " if step.get("ok") else "FAIL"
lines.append(f"[{flag}] {step.get('name')} ({step.get('duration_s')}s)")
for detail_line in str(step.get("detail") or "").splitlines():
lines.append(f" {detail_line}")
lines.append("")
wheels = payload.get("wheels") or []
if wheels:
lines.append("wheels:")
for wheel in wheels:
lines.append(
f" {wheel.get('name')} {wheel.get('size')} bytes sha256={wheel.get('sha256')}"
)
lines.append("")
options = payload.get("options") or {}
waived = [
key
for key, value in options.items()
if value and key in {"skip_tests", "allow_dirty", "force", "no_runtime", "no_assets"}
]
if waived:
lines.append(f"waived guards: {', '.join(sorted(waived))}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def write_report(payload: dict, out_dir: Path) -> tuple[Path, Path]:
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
payload = {**payload, "schema": SCHEMA_VERSION}
json_path = out_dir / "preflight.json"
text_path = out_dir / "preflight.txt"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=False), encoding="utf-8")
text_path.write_text(render_text(payload), encoding="utf-8")
return json_path, text_path
+141
View File
@@ -0,0 +1,141 @@
"""Version consistency and duplicate-release guards for the release gate."""
from __future__ import annotations
import json
import os
import re
import urllib.error
import urllib.request
from pathlib import Path
PYPI_JSON_URL = "https://pypi.org/pypi/orisys/json"
# orisys-<version>-<pytag>-<abitag>-<platformtag>.whl
_WHEEL_NAME_RE = re.compile(r"^orisys-(?P<version>[^-]+)-[^-]+-[^-]+-.+\.whl$")
def wheel_version(wheel_path: Path) -> str | None:
match = _WHEEL_NAME_RE.match(Path(wheel_path).name)
return match.group("version") if match else None
def sdk_versions(repo_root: Path) -> list[str]:
"""Version directory names present under ``SDK/``, in ascending order.
Only digit-leading names count as versions, so stray directories (``tmp``,
``latest``) never appear as releases.
"""
sdk_root = Path(repo_root) / "SDK"
if not sdk_root.is_dir():
return []
return sorted(
(
entry.name
for entry in sdk_root.iterdir()
if entry.is_dir() and entry.name[:1].isdigit()
),
key=_version_sort_key,
)
def sdk_wheels(repo_root: Path, version: str) -> list[Path]:
"""Wheels already published into ``SDK/<version>/``."""
version_dir = Path(repo_root) / "SDK" / version
if not version_dir.is_dir():
return []
return sorted(version_dir.glob("*.whl"))
def dynamic_version_attr(repo_root: Path) -> str | None:
"""Return pyproject's configured dynamic version attr (e.g. orisys.__version__)."""
from common.pyproject_utils import load_pyproject
config = (
load_pyproject(Path(repo_root))
.get("tool", {})
.get("setuptools", {})
.get("dynamic", {})
.get("version")
)
if isinstance(config, dict):
attr = config.get("attr")
if isinstance(attr, str):
return attr
return None
def pypi_released_versions(timeout: float = 20.0) -> tuple[list[str] | None, str | None]:
"""Read the published version list from PyPI.
Returns ``(versions, error)``; ``versions`` is ``None`` when the lookup
failed. This is the only network call in the release gate and it is
read-only, so it is safe to run before an irreversible upload.
"""
request = urllib.request.Request(
PYPI_JSON_URL,
headers={"User-Agent": "orisys-release-preflight", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.load(response)
except urllib.error.HTTPError as exc:
if exc.code == 404:
return [], None
return None, f"PyPI returned HTTP {exc.code}"
except Exception as exc: # network, DNS, TLS, JSON
return None, f"{type(exc).__name__}: {exc}"
releases = payload.get("releases")
if not isinstance(releases, dict):
return None, "unexpected PyPI response shape (no 'releases' mapping)"
listed = [
str(version)
for version, files in releases.items()
if isinstance(files, list) and files
]
return sorted(listed, key=_version_sort_key), None
def _version_sort_key(name: str):
parts = re.findall(r"\d+", name)
return ([int(part) for part in parts] or [0], name)
def conda_envs_root() -> Path | None:
"""Locate the conda envs directory the same way build/wheel/win.bat does."""
override = os.getenv("CONDA_ENVS_ROOT")
if override:
path = Path(override)
return path if path.is_dir() else None
home = Path.home()
candidates = (
home / "anaconda3" / "envs",
home / "miniconda3" / "envs",
Path(os.getenv("LOCALAPPDATA", "")) / "anaconda3" / "envs",
Path(os.getenv("LOCALAPPDATA", "")) / "miniconda3" / "envs",
)
for candidate in candidates:
if candidate.is_dir():
return candidate
return None
def python_for_tag(tag: str, envs_root: Path | None = None) -> Path | None:
"""Resolve an interpreter for a wheel tag like ``cp310`` (env ``py310``)."""
digits = tag[2:] if tag.startswith("cp") else tag
env_name = f"py{digits}"
root = envs_root or conda_envs_root()
if root is None:
return None
for relative in (
Path(env_name) / "python.exe",
Path(env_name) / "bin" / "python",
):
candidate = root / relative
if candidate.is_file():
return candidate
return None
+16 -2
View File
@@ -8,6 +8,8 @@ Usage (from repo root):
python build/run.py gui finger|mini|box
python build/run.py verify [wheel.whl]
python build/run.py verify --gui-dist [--product ory_finger]
python build/run.py release preflight [--version V]
python build/run.py release audit <wheel.whl>
See build/README.md for details.
"""
@@ -74,15 +76,23 @@ def _cmd_verify(argv: list[str]) -> int:
return verify_main(argv)
def _cmd_release(argv: list[str]) -> int:
from release.cli import main as release_main
return release_main(argv)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
print("Commands: wheel | wheel-win | manylinux | gui | verify")
print("Commands: wheel | wheel-win | manylinux | gui | verify | release")
print("Examples:")
print(" python build/run.py wheel --help")
print(" python build/run.py gui finger")
print(" python build/run.py manylinux -PythonVersions 3.10")
print(" python build/run.py release preflight --version 0.5.2")
print(" python build/run.py release audit SDK\\0.5.2\\orisys-0.5.2-cp312-cp312-win_amd64.whl")
return 0
command, rest = argv[0], argv[1:]
@@ -92,10 +102,14 @@ def main(argv: list[str] | None = None) -> int:
"wheel-manylinux": _cmd_manylinux,
"gui": _cmd_gui,
"verify": _cmd_verify,
"release": _cmd_release,
}
if command not in dispatch:
print(f"Unknown command: {command!r}", file=sys.stderr)
print("Choose from: wheel, wheel-win, manylinux, gui, verify", file=sys.stderr)
print(
"Choose from: wheel, wheel-win, manylinux, gui, verify, release",
file=sys.stderr,
)
return 2
return dispatch[command](rest)
+10 -1
View File
@@ -30,7 +30,13 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repaired_wheel_dir, repo_path
from common.pyproject_utils import bundle_items, extras_require, get_version, install_requires
from common.pyproject_utils import (
bundle_items,
extras_require,
get_version,
install_requires,
project_metadata,
)
from common.verify.wheel import verify_built_wheel
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
from wheel.layout import (
@@ -41,6 +47,7 @@ from wheel.layout import (
from wheel.package import (
is_compliant_manylinux_wheel,
publish_wheel_to_sdk,
read_long_description,
repair_linux_wheel,
run_bdist_wheel,
write_wheel_setup,
@@ -122,6 +129,8 @@ def build(*, target: str = "internal", verify: bool | None = None) -> Path:
package_data=package_data_from_bundle(ext_suffix, bundle),
install_requires=install_requires(REPO_ROOT),
extras_require=extras_require(REPO_ROOT),
project=project_metadata(REPO_ROOT),
long_description=read_long_description(REPO_ROOT),
)
# --- 3. Build wheel, repair on Linux, publish to SDK/ ---
+83 -3
View File
@@ -19,6 +19,76 @@ _MANYLINUX_TAG_RE = re.compile(
r"(?:manylinux2014|manylinux_2_(?:17|24|27|28|31|34|35))_x86_64"
)
# Source of the PyPI project description (see read_long_description).
PYPI_README_RELPATH = Path("docs") / "pypi_readme.md"
def _license_text(project: dict) -> str | None:
"""Extract free-text license from either pyproject license form."""
declared = project.get("license")
if isinstance(declared, str):
return declared
if isinstance(declared, dict):
text = declared.get("text")
if isinstance(text, str):
return text
return None
def _person_strings(entries) -> list[str]:
"""Render PEP 621 author/maintainer tables as ``Name <email>`` strings."""
rendered: list[str] = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
name = (entry.get("name") or "").strip()
email = (entry.get("email") or "").strip()
if name and email:
rendered.append(f"{name} <{email}>")
elif name or email:
rendered.append(name or email)
return rendered
def metadata_kwargs(project: dict) -> dict:
"""Translate a pyproject ``[project]`` table into ``setup()`` keywords.
The Nuitka wheel is built by a generated ``setup.py`` outside the normal
setuptools pipeline, so every PyPI-visible field has to be passed through
explicitly. Omitting them is what left the 0.5.2 release with no
``Requires-Python``, classifiers, project URLs or license on PyPI.
"""
authors = _person_strings(project.get("authors"))
maintainers = _person_strings(project.get("maintainers"))
candidates = {
"description": project.get("description"),
"python_requires": project.get("requires-python"),
"classifiers": list(project.get("classifiers") or []),
"project_urls": dict(project.get("urls") or {}),
"license": _license_text(project),
"author": authors[0] if authors else None,
"author_email": None,
"maintainer": maintainers[0] if maintainers else None,
}
return {key: value for key, value in candidates.items() if value}
def read_long_description(repo_root: Path) -> str | None:
"""Return the PyPI long description, or ``None`` when it is missing.
Deliberately not the repository ``README.md``: that file documents the
closed-source build/publishing layout, which should not end up on the PyPI
project page.
"""
path = Path(repo_root) / PYPI_README_RELPATH
if not path.is_file():
print(
f"[WARN] {PYPI_README_RELPATH.as_posix()} not found; the wheel will be "
"published without a long description."
)
return None
return path.read_text(encoding="utf-8")
def write_wheel_setup(
wheel_dir: Path,
@@ -27,7 +97,17 @@ def write_wheel_setup(
package_data: dict[str, list[str]],
install_requires: list[str],
extras_require: dict[str, list[str]],
project: dict | None = None,
long_description: str | None = None,
) -> Path:
metadata_lines = ""
if project:
for key, value in sorted(metadata_kwargs(project).items()):
metadata_lines += f" {key}={value!r},\n"
if long_description:
metadata_lines += " long_description=_LONG_DESCRIPTION,\n"
metadata_lines += ' long_description_content_type="text/markdown",\n'
setup_py_path = wheel_dir / "setup.py"
setup_content = f"""
from setuptools import setup, Distribution
@@ -36,12 +116,12 @@ class BinaryDistribution(Distribution):
def has_ext_modules(self):
return True
{'_LONG_DESCRIPTION = ' + repr(long_description) if long_description else ''}
setup(
name="orisys",
version="{version}",
description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)",
author="Orisys",
packages=['orisys', 'orisys.examples', 'orisys_blend'],
{metadata_lines} packages=['orisys', 'orisys.examples', 'orisys_blend'],
package_dir={{'': 'wheel_root'}},
package_data={package_data},
include_package_data=True,
+5 -5
View File
@@ -5,7 +5,7 @@ description: 安装并运行 Orisys SDK 的基础步骤
# 快速开始
版本:0.5.1
版本:0.5.2
## 简介
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
@@ -84,9 +84,9 @@ pip install --upgrade orisys
### 使用 .whl 文件安装(离线或指定版本)
从官方渠道获取 .whl 文件,文件名格式通常为:
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
例如:orisys-0.5.1-cp310-cp310-win_amd64.whl 表示:
例如:orisys-0.5.2-cp310-cp310-win_amd64.whl 表示:
版本:0.5.1
版本:0.5.2
Python 版本:3.10(cp310)
操作系统:Windows(win)
硬件架构:64位(amd64)
@@ -97,7 +97,7 @@ Python 版本:3.10(cp310)
```bash
# 请将文件名替换为您实际下载的文件名
pip install orisys-0.5.1-cp310-cp310-win_amd64.whl
pip install orisys-0.5.2-cp310-cp310-win_amd64.whl
```
### 安装 CuPy(可选)
@@ -139,7 +139,7 @@ python
import orisys
print(orisys.__version__)
```
如果输出版本号(例如 0.5.1),则说明安装成功。
如果输出版本号(例如 0.5.2),则说明安装成功。
## 使用方法
### 运行示例程序
+183
View File
@@ -0,0 +1,183 @@
# 发布流程(构建 → 校验 → 发布)
本文档说明如何构建 orisys 的 Nuitka 二进制 wheel、如何在发布前用一条命令做完整门禁,以及当前仍需人工执行的发布步骤。
> 历史说明:`README.md` 与 `build/wheel/docker/run.ps1` 早就引用了本文件,但它此前并不存在。本文件补齐该引用。
---
## 0. 环境准备
推荐用 `py310` conda 环境执行构建与校验(它同时具备 pytest / twine / numba / Nuitka / open3d / PyQt5):
```powershell
conda activate py310
python -m pytest -m "not slow and not hardware" # 开发期快速回归
```
`py311`、`py312` 仅作为 Nuitka 各 ABI 的编译环境按需调用(`build/wheel/win.bat` 会依次使用 `py310`/`py311`/`py312`)。
**不要**在同一个环境里既装旧版 `orisys` 又从源码目录跑测试:site-packages 里的旧包会静默遮蔽 `src/orisys`。
`tests/test_harness_guards.py` 会显式报错提示执行 `pip uninstall -y orisys`。
---
## 1. 构建 wheel
```powershell
# Windows:py310 / py311 / py312 三个 ABI
python build/run.py wheel-win
# 单个解释器(当前环境)
python build/run.py wheel
# Linux manylinux(Docker Desktop,Linux 容器模式)
python build/run.py manylinux
python build/run.py wheel-manylinux -PythonVersions 3.10
```
产物落点:
- `SDK/<version>/` — 交付用 wheel(git 忽略)
- `dist/repaired/` — 上传候选(git 忽略)
---
## 2. 发布前门禁:`release preflight`
```powershell
python build/run.py release preflight --version 0.5.2
```
按顺序执行 7 个步骤,任一步失败即整体失败(`--keep-going` 可改为收集全部失败):
| 步骤 | 检查内容 |
| --- | --- |
| `repo-state` | 是 git 仓库、工作树干净、记录 HEAD(`--allow-dirty` 可豁免) |
| `version-consistency` | `src/orisys/__init__.py::__version__` == pyproject 的 dynamic attr == `SDK/<version>/` 中 wheel 文件名里的版本 |
| `duplicate-guard` | `SDK/<version>/` 已有产物则拒绝(`--force` 豁免);`--check-pypi` 时只读查询 PyPI,版本已存在则拒绝 |
| `tests` | `pytest -m "not slow and not hardware"`(`--longrun` 时包含 slow) |
| `bundle-precheck` | 每个 `[[tool.orisys.bundle.items]]` 至少解析出 1 个文件;finger 必需资产存在 |
| `wheel-inspection` | 逐个 wheel 做静态断言:文件名/版本/METADATA 字段/RECORD 哈希/压缩包路径/体积上限/无 pyc 与缓存/无 `orisys/**/*.py` 源码泄漏/与源码树逐文件字节一致 |
| `wheel-runtime` | 在一次性 venv 中安装该 wheel,断言内置 configs/sensors 资产可加载,并对真实视频跑 40 帧公开流水线(无需相机、无 GUI) |
产物报告:
- `build/release/reports/<version>/preflight.json` — 机器可读,含每个 wheel 的 sha256,供后续发布步骤复用
- `build/release/reports/<version>/preflight.txt` — 人工阅读
- `build/release/reports/<version>/junit.xml` — pytest 结果
(`build/release/reports/` 已加入 `.gitignore`。)
### 常用参数
| 参数 | 作用 |
| --- | --- |
| `--wheel-dir DIR` | 校验指定目录下的 wheel(默认 `SDK/<version>`) |
| `--all-wheel-pythons` | cp310/cp311/cp312 各自在对应 conda 环境里独立校验 |
| `--video PATH` / `--frames N` / `--config NAME` | 覆盖运行时校验输入(默认 `data/sample/s1_480p.mp4` / 40 帧 / `box`) |
| `--offline` | 复用父环境的 numpy/opencv/numba,不去 PyPI 装依赖 |
| `--check-pypi` | 唯一一处网络调用:只读 PyPI,防止重复发布 |
| `--skip-tests` / `--no-runtime` / `--no-assets` | 显式跳过并在报告中留痕;**跳过的运行不能作为发布依据** |
| `--report-dir DIR` | 覆盖报告根目录 |
`wheel-runtime` **不会静默跳过**:找不到视频时直接失败,提示 `--video` / `ORISYS_VERIFY_VIDEO`。
### 单体体检:`release audit`
对单个 wheel 做静态断言(+ 可选运行时校验),不需要版本上下文。适合体检历史产物:
```powershell
python build/run.py release audit SDK\0.5.2\orisys-0.5.2-cp312-cp312-win_amd64.whl
python build/run.py release audit dist\repaired\orisys-0.5.2-cp310-cp310-win_amd64.whl --strict-bundle
# 追加运行时校验
python build/run.py release audit <wheel> --assets --runtime --video data/sample/s1_480p.mp4
```
默认对「内置文件与当前源码树不一致」只告警(历史 wheel 与当前分支不一致是正常的);加 `--strict-bundle` 改成失败(刚构建完的 wheel 应当一致)。
旧的导入冒烟测试仍然可用,行为不变:
```powershell
python build/run.py verify <wheel>
python build/run.py verify <wheel> --contents # 静态断言
python build/run.py verify <wheel> --assets --runtime # 资产 + 真实帧
python build/run.py verify --gui-dist --product ory_finger
```
---
## 3. 发布(目前仍需人工)
阶段一不包含自动发布。当前人工步骤:
1. 用 `release preflight` 拿到 PASS(并保留 `preflight.json`)。
2. 把 `SDK/<version>/` 同步到 `orisys-sdk-release` 仓库的 `release/<version>/`。
3. GitHub:打 tag **`v<version>`** 并创建 Release,上传 6 个 wheel 作为附件。
- 注意历史 tag 里有一个 typo:`0.50`(与 `v0.5.2` 指向同一 commit)。
4. PyPI:先 TestPyPI 再正式仓库。
```powershell
python -m twine upload --repository testpypi SDK/0.5.2/*.whl
python -m twine upload SDK/0.5.2/*.whl
```
**不要把 API token 写进仓库**。`release preflight --check-pypi` 只做只读查询,不需要 token。
### 已知的历史发布空洞
| 版本 | 本地 `SDK/` | PyPI | GitHub Release |
| --- | --- | --- | --- |
| 0.4.2 | — | 有 | 有 |
| 0.4.3 | — | 有 | 有 |
| 0.4.4 | 有 | **无** | **无** |
| 0.5.0 | 有 | **无** | **无** |
| 0.5.2 | 有 | 有 | 有(tag `v0.5.2` + typo tag `0.50`) |
---
## 4. 环境变量
| 变量 | 用途 |
| --- | --- |
| `ORISYS_TEST_VIDEO` | pytest 用的视频;默认自动取 `data/sample/s1_480p.mp4` |
| `ORISYS_TEST_CONFIG` | pytest 用的配置名;默认自动挑一个含 `stitcher.box_size` 的配置 |
| `ORISYS_VERIFY_VIDEO` | 运行时校验用的视频 |
| `ORISYS_VERIFY_CONFIG` | 运行时校验用的配置名(默认 `box`) |
| `ORISYS_LONGRUN_BACKEND` / `ORISYS_LONGRUN_FRAMES` | `slow` 长稳测试的参数 |
| `CONDA_ENVS_ROOT` | conda envs 根目录(`--all-wheel-pythons` 用它解析 `py310`/`py311`/`py312`) |
---
## 5. 两个必须记住的坑
### 5.1 Nuitka 编译包的 `__file__` 是假的
Nuitka 把整个 `orisys` 编译成单个扩展模块,其 `__init__` 会安装 `nuitka_module_loader`。于是:
```python
>>> import orisys
>>> orisys.__file__ # 合成的、磁盘上并不存在的路径
'...\\site-packages\\orisys\\orisys\\__init__.py'
>>> orisys.__path__ # 第二项是 Nuitka 的哨兵值
['...\\site-packages\\orisys\\orisys', '\\not_existing']
>>> orisys.__loader__.__class__.__name__
'nuitka_module_loader'
```
后果:**在 Nuitka wheel 里不能靠 `orisys.__file__` 或 `importlib.resources.files("orisys")` 定位内置数据**——它们会指向不存在的目录,静默返回「文件不存在」。请改用发行包定位:
```python
from importlib.metadata import distribution
root = distribution("orisys").locate_file("orisys") # site-packages/orisys
```
`build/common/verify/wheel.py` 的内置资产校验就是这么做的,并且会显式断言该目录位于校验用 venv 内,避免被父环境里的旧 `orisys` 顶替。
### 5.2 `--offline` 会被父环境的脏安装污染
`--offline` 通过 venv 的 `--system-site-packages` 复用父环境的 numpy/numba 等依赖,这也顺带暴露了父环境里的 `orisys`。若父环境残留了半卸载的 editable 安装(典型症状是 `pip` 提示 `Ignoring invalid distribution -risys`,且 `site-packages/orisys/__init__.py` 是一个指向死路径的 shim),校验就会读到它。
处置:在构建环境里执行 `pip uninstall -y orisys`,或改用不带 `--offline` 的完全隔离 venv(会联网安装依赖)。
+63
View File
@@ -0,0 +1,63 @@
# Orisys SDK
叠动科技视触觉传感器配套的 Python SDK,提供图像采集、拼接、稠密光流、Helmholtz-Hodge 分解、接触与深度估计,以及可运行的示例。
本文件是 PyPI 项目描述(`long_description`)的源文件。它按构建配置 `PYPI_README_RELPATH` 打包进 wheel,因此**不要**在这里写内部仓库布局、构建流水线或发布步骤——那些内容属于仓库内的 `README.md` 与 `docs/publishing.md`。
```bash
python -m pip install orisys
```
支持 Python 3.10 / 3.11 / 3.12,Windows x64 与 Ubuntu x64。
## 快速开始
```bash
python -c "import orisys; print(orisys.__version__)"
python -c "import orisys; print(orisys.list_config())"
```
## 安装选项
- `orisys` — CPU / 基础流程
- `orisys[gui]` — Qt / Open3D 可视化示例
- `orisys[cuda12]` / `orisys[cuda13]` — NVIDIA GPU 加速
- `orisys[gui,cuda12]` — GUI + CUDA 12
`orisys[cuda]` 与 `orisys[cuda12]` 等价。
## 配置
配置解析顺序:
1. 显式 JSON 文件路径
2. 内置配置名(`box`、`mini`、`finger` 等)
3. 产品别名(如 `dd02` -> `dd02-ov`)
4. 向后兼容的 JSON `"name"` 匹配
```python
import orisys
sensor = orisys.Sensor(0, config_name="box")
orisys.export_config("box", "./exported_config.json")
```
## 示例
示例随 wheel 一起分发,位于 `orisys/examples/`。也可以从项目仓库获取:
```bash
python examples/basic_pipeline.py -v 0 -c box
python examples/finger_pipeline.py --camera_id 0 --config finger
```
无相机时可以改用视频文件:
```bash
python examples/finger_pipeline.py --video ./sample.mp4 --config finger
```
## 支持与反馈
- 官网:[https://www.orisys-tech.cn](https://www.orisys-tech.cn)
- 问题反馈:GitHub Issues
+1 -1
View File
@@ -7,7 +7,7 @@ ORY-BOX:单传感器基础流程
3. 按需计算接触区域
4. 读取并可视化结果
SDK version: 0.5.1
SDK version: 0.5.2
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+1 -1
View File
@@ -7,7 +7,7 @@ ORY-MINI:单传感器基础流程
3. 按需计算接触区域
4. 读取并可视化结果
SDK version: 0.5.1
SDK version: 0.5.2
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+1 -1
View File
@@ -18,7 +18,7 @@
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
- 每个传感器进程都会占用独立的 GPU 显存
SDK version: 0.5.1
SDK version: 0.5.2
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+4
View File
@@ -1,5 +1,9 @@
[pytest]
testpaths = tests
python_files = test_*.py
# Run against the checkout without requiring `pip install -e .`, so a stale
# orisys in site-packages cannot silently shadow src/orisys.
pythonpath = src
markers =
slow: long-running tests for release validation
hardware: requires a physical sensor/camera attached to the machine
+1 -1
View File
@@ -5,7 +5,7 @@ A Python library for processing and analyzing optical tactile sensor data,
including image stitching, optical flow tracking, and deformation decomposition.
"""
__version__ = "0.5.1"
__version__ = "0.5.2"
__author__ = "Orisys"
from .sensor import Sensor
+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."
)