added preflight check
This commit is contained in:
@@ -10,6 +10,7 @@ __pycache__/
|
|||||||
/build/dist/
|
/build/dist/
|
||||||
/build/lib/
|
/build/lib/
|
||||||
/build/pyinstaller_work/
|
/build/pyinstaller_work/
|
||||||
|
/build/release/reports/
|
||||||
/build/*.spec
|
/build/*.spec
|
||||||
/dist/
|
/dist/
|
||||||
/dist_obfuscated/
|
/dist_obfuscated/
|
||||||
|
|||||||
+21
-1
@@ -12,6 +12,7 @@ build/
|
|||||||
README.md
|
README.md
|
||||||
common/ shared helpers (paths, pyproject, verify)
|
common/ shared helpers (paths, pyproject, verify)
|
||||||
wheel/ SDK .whl (Nuitka + manylinux Docker)
|
wheel/ SDK .whl (Nuitka + manylinux Docker)
|
||||||
|
release/ local release gate (preflight / audit)
|
||||||
gui/
|
gui/
|
||||||
common/ shared PyInstaller driver
|
common/ shared PyInstaller driver
|
||||||
ory_finger/ ORY-FINGER dashboard
|
ory_finger/ ORY-FINGER dashboard
|
||||||
@@ -41,6 +42,22 @@ build\wheel\win.bat
|
|||||||
.\build\wheel\docker\run.ps1
|
.\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
|
### GUI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -53,13 +70,16 @@ python build/run.py gui box # scaffold / NotImplemented
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python build/run.py verify path\to\orisys.whl
|
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
|
python build/run.py verify --gui-dist --product ory_finger
|
||||||
```
|
```
|
||||||
|
|
||||||
## Outputs
|
## Outputs
|
||||||
|
|
||||||
- Wheels: `SDK/<version>/`, `dist/repaired/`
|
- Wheels: `SDK/<version>/`, `dist/repaired/`
|
||||||
|
- Preflight reports: `build/release/reports/<version>/`
|
||||||
- Finger GUI: `dist/OrisysDashboard/`
|
- Finger GUI: `dist/OrisysDashboard/`
|
||||||
- Future mini/box: `dist/OryMini/`, `dist/OryBox/`
|
- 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.
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
"""Post-build smoke tests for wheels and product GUI folders."""
|
"""Post-build smoke tests for wheels and product GUI folders."""
|
||||||
|
|
||||||
from common.verify.gui import GUI_PRODUCTS, verify_gui_dist
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -3,9 +3,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
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:
|
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)",
|
help="GUI product profile (default: ory_finger)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--dist-dir", type=Path, default=None, help="Override GUI dist folder")
|
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")
|
parser.add_argument("wheel", nargs="?", type=Path, help="Wheel path for import smoke test")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
@@ -26,6 +65,49 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
if args.wheel is None:
|
if args.wheel is None:
|
||||||
parser.error("Provide a wheel path or pass --gui-dist")
|
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)
|
verify_built_wheel(args.wheel)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
+342
-13
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -38,6 +52,167 @@ assert Stitcher is not None
|
|||||||
print(f"orisys {orisys.__version__} public API imports OK")
|
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:
|
def _venv_python(venv_dir: Path) -> Path:
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -45,18 +220,172 @@ def _venv_python(venv_dir: Path) -> Path:
|
|||||||
return venv_dir / "bin" / "python"
|
return venv_dir / "bin" / "python"
|
||||||
|
|
||||||
|
|
||||||
def verify_built_wheel(wheel_path: Path) -> None:
|
def _hermetic_env() -> dict:
|
||||||
"""Install the wheel in a temporary venv and smoke-test public imports."""
|
"""Environment for the verification venv with PYTHONPATH removed.
|
||||||
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)
|
|
||||||
|
|
||||||
subprocess.check_call(
|
An inherited PYTHONPATH=src would let the checkout satisfy the checks
|
||||||
[python, "-m", "pip", "install", "-q", "--upgrade", "pip"],
|
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(
|
_install_wheel(python, wheel_path, offline=offline, env=env)
|
||||||
[python, "-m", "pip", "install", "-q", str(wheel_path)],
|
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])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
@@ -8,6 +8,8 @@ Usage (from repo root):
|
|||||||
python build/run.py gui finger|mini|box
|
python build/run.py gui finger|mini|box
|
||||||
python build/run.py verify [wheel.whl]
|
python build/run.py verify [wheel.whl]
|
||||||
python build/run.py verify --gui-dist [--product ory_finger]
|
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.
|
See build/README.md for details.
|
||||||
"""
|
"""
|
||||||
@@ -74,15 +76,23 @@ def _cmd_verify(argv: list[str]) -> int:
|
|||||||
return verify_main(argv)
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
argv = list(sys.argv[1:] if argv is None else argv)
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
if not argv or argv[0] in ("-h", "--help"):
|
if not argv or argv[0] in ("-h", "--help"):
|
||||||
print(__doc__)
|
print(__doc__)
|
||||||
print("Commands: wheel | wheel-win | manylinux | gui | verify")
|
print("Commands: wheel | wheel-win | manylinux | gui | verify | release")
|
||||||
print("Examples:")
|
print("Examples:")
|
||||||
print(" python build/run.py wheel --help")
|
print(" python build/run.py wheel --help")
|
||||||
print(" python build/run.py gui finger")
|
print(" python build/run.py gui finger")
|
||||||
print(" python build/run.py manylinux -PythonVersions 3.10")
|
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
|
return 0
|
||||||
|
|
||||||
command, rest = argv[0], argv[1:]
|
command, rest = argv[0], argv[1:]
|
||||||
@@ -92,10 +102,14 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"wheel-manylinux": _cmd_manylinux,
|
"wheel-manylinux": _cmd_manylinux,
|
||||||
"gui": _cmd_gui,
|
"gui": _cmd_gui,
|
||||||
"verify": _cmd_verify,
|
"verify": _cmd_verify,
|
||||||
|
"release": _cmd_release,
|
||||||
}
|
}
|
||||||
if command not in dispatch:
|
if command not in dispatch:
|
||||||
print(f"Unknown command: {command!r}", file=sys.stderr)
|
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 2
|
||||||
return dispatch[command](rest)
|
return dispatch[command](rest)
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -30,7 +30,13 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
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.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 common.verify.wheel import verify_built_wheel
|
||||||
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
|
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
|
||||||
from wheel.layout import (
|
from wheel.layout import (
|
||||||
@@ -41,6 +47,7 @@ from wheel.layout import (
|
|||||||
from wheel.package import (
|
from wheel.package import (
|
||||||
is_compliant_manylinux_wheel,
|
is_compliant_manylinux_wheel,
|
||||||
publish_wheel_to_sdk,
|
publish_wheel_to_sdk,
|
||||||
|
read_long_description,
|
||||||
repair_linux_wheel,
|
repair_linux_wheel,
|
||||||
run_bdist_wheel,
|
run_bdist_wheel,
|
||||||
write_wheel_setup,
|
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),
|
package_data=package_data_from_bundle(ext_suffix, bundle),
|
||||||
install_requires=install_requires(REPO_ROOT),
|
install_requires=install_requires(REPO_ROOT),
|
||||||
extras_require=extras_require(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/ ---
|
# --- 3. Build wheel, repair on Linux, publish to SDK/ ---
|
||||||
|
|||||||
+83
-3
@@ -19,6 +19,76 @@ _MANYLINUX_TAG_RE = re.compile(
|
|||||||
r"(?:manylinux2014|manylinux_2_(?:17|24|27|28|31|34|35))_x86_64"
|
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(
|
def write_wheel_setup(
|
||||||
wheel_dir: Path,
|
wheel_dir: Path,
|
||||||
@@ -27,7 +97,17 @@ def write_wheel_setup(
|
|||||||
package_data: dict[str, list[str]],
|
package_data: dict[str, list[str]],
|
||||||
install_requires: list[str],
|
install_requires: list[str],
|
||||||
extras_require: dict[str, list[str]],
|
extras_require: dict[str, list[str]],
|
||||||
|
project: dict | None = None,
|
||||||
|
long_description: str | None = None,
|
||||||
) -> Path:
|
) -> 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_py_path = wheel_dir / "setup.py"
|
||||||
setup_content = f"""
|
setup_content = f"""
|
||||||
from setuptools import setup, Distribution
|
from setuptools import setup, Distribution
|
||||||
@@ -36,12 +116,12 @@ class BinaryDistribution(Distribution):
|
|||||||
def has_ext_modules(self):
|
def has_ext_modules(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
{'_LONG_DESCRIPTION = ' + repr(long_description) if long_description else ''}
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="orisys",
|
name="orisys",
|
||||||
version="{version}",
|
version="{version}",
|
||||||
description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)",
|
{metadata_lines} packages=['orisys', 'orisys.examples', 'orisys_blend'],
|
||||||
author="Orisys",
|
|
||||||
packages=['orisys', 'orisys.examples', 'orisys_blend'],
|
|
||||||
package_dir={{'': 'wheel_root'}},
|
package_dir={{'': 'wheel_root'}},
|
||||||
package_data={package_data},
|
package_data={package_data},
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
|
|||||||
+5
-5
@@ -5,7 +5,7 @@ description: 安装并运行 Orisys SDK 的基础步骤
|
|||||||
|
|
||||||
# 快速开始
|
# 快速开始
|
||||||
|
|
||||||
版本:0.5.1
|
版本:0.5.2
|
||||||
|
|
||||||
## 简介
|
## 简介
|
||||||
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
|
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
|
||||||
@@ -84,9 +84,9 @@ pip install --upgrade orisys
|
|||||||
### 使用 .whl 文件安装(离线或指定版本)
|
### 使用 .whl 文件安装(离线或指定版本)
|
||||||
从官方渠道获取 .whl 文件,文件名格式通常为:
|
从官方渠道获取 .whl 文件,文件名格式通常为:
|
||||||
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.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)
|
Python 版本:3.10(cp310)
|
||||||
操作系统:Windows(win)
|
操作系统:Windows(win)
|
||||||
硬件架构:64位(amd64)
|
硬件架构:64位(amd64)
|
||||||
@@ -97,7 +97,7 @@ Python 版本:3.10(cp310)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 请将文件名替换为您实际下载的文件名
|
# 请将文件名替换为您实际下载的文件名
|
||||||
pip install orisys-0.5.1-cp310-cp310-win_amd64.whl
|
pip install orisys-0.5.2-cp310-cp310-win_amd64.whl
|
||||||
```
|
```
|
||||||
|
|
||||||
### 安装 CuPy(可选)
|
### 安装 CuPy(可选)
|
||||||
@@ -139,7 +139,7 @@ python
|
|||||||
import orisys
|
import orisys
|
||||||
print(orisys.__version__)
|
print(orisys.__version__)
|
||||||
```
|
```
|
||||||
如果输出版本号(例如 0.5.1),则说明安装成功。
|
如果输出版本号(例如 0.5.2),则说明安装成功。
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
### 运行示例程序
|
### 运行示例程序
|
||||||
|
|||||||
@@ -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(会联网安装依赖)。
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -7,7 +7,7 @@ ORY-BOX:单传感器基础流程
|
|||||||
3. 按需计算接触区域
|
3. 按需计算接触区域
|
||||||
4. 读取并可视化结果
|
4. 读取并可视化结果
|
||||||
|
|
||||||
SDK version: 0.5.1
|
SDK version: 0.5.2
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ ORY-MINI:单传感器基础流程
|
|||||||
3. 按需计算接触区域
|
3. 按需计算接触区域
|
||||||
4. 读取并可视化结果
|
4. 读取并可视化结果
|
||||||
|
|
||||||
SDK version: 0.5.1
|
SDK version: 0.5.2
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
||||||
- 每个传感器进程都会占用独立的 GPU 显存
|
- 每个传感器进程都会占用独立的 GPU 显存
|
||||||
|
|
||||||
SDK version: 0.5.1
|
SDK version: 0.5.2
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
python_files = test_*.py
|
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 =
|
markers =
|
||||||
slow: long-running tests for release validation
|
slow: long-running tests for release validation
|
||||||
|
hardware: requires a physical sensor/camera attached to the machine
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ A Python library for processing and analyzing optical tactile sensor data,
|
|||||||
including image stitching, optical flow tracking, and deformation decomposition.
|
including image stitching, optical flow tracking, and deformation decomposition.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.5.1"
|
__version__ = "0.5.2"
|
||||||
__author__ = "Orisys"
|
__author__ = "Orisys"
|
||||||
|
|
||||||
from .sensor import Sensor
|
from .sensor import Sensor
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Shared pytest fixtures for the orisys test suite.
|
||||||
|
|
||||||
|
This module was accidentally deleted in commit edc6e42 while four test modules
|
||||||
|
(``test_backends.py``, ``test_smoke_api.py``, ``test_errors_and_cleanup.py``,
|
||||||
|
``test_longrun_stability.py``) still depend on its fixtures, which made the
|
||||||
|
whole suite unrunnable. It is restored here with two fixes:
|
||||||
|
|
||||||
|
* the default test video now points at clips that are actually tracked in the
|
||||||
|
repository (``data/sample/``), instead of the never-committed
|
||||||
|
``tests/idle_1min.mp4``;
|
||||||
|
* the stale ``"prototype"`` default config in :func:`make_sensor` is gone, so
|
||||||
|
callers cannot silently bypass the ``stitcher.box_size`` validation.
|
||||||
|
|
||||||
|
``tests/test_harness_guards.py`` guards against a repeat deletion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_ROOT = REPO_ROOT / "src"
|
||||||
|
if str(SRC_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_ROOT))
|
||||||
|
|
||||||
|
from orisys import Sensor
|
||||||
|
from orisys import configs as orisys_configs
|
||||||
|
from orisys.backend_availability import check_cupy_available, check_opencl_available
|
||||||
|
|
||||||
|
# Video clips that are tracked in git, in preference order.
|
||||||
|
DEFAULT_VIDEO_CANDIDATES = (
|
||||||
|
Path("tests") / "idle_1min.mp4",
|
||||||
|
Path("data") / "sample" / "s1_480p.mp4",
|
||||||
|
Path("data") / "sample" / "s1_720p.mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_test_video() -> Path:
|
||||||
|
env_video = os.getenv("ORISYS_TEST_VIDEO")
|
||||||
|
if env_video:
|
||||||
|
raw = Path(env_video)
|
||||||
|
candidates = [
|
||||||
|
raw, # as provided (absolute or cwd-relative)
|
||||||
|
REPO_ROOT / raw, # repo-relative
|
||||||
|
]
|
||||||
|
# Common typo fallback: "test/..." -> "tests/..."
|
||||||
|
if env_video.startswith("test/") or env_video.startswith("test\\"):
|
||||||
|
fixed = Path("tests", *Path(env_video).parts[1:])
|
||||||
|
candidates.append(REPO_ROOT / fixed)
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return raw
|
||||||
|
|
||||||
|
for relative in DEFAULT_VIDEO_CANDIDATES:
|
||||||
|
candidate = REPO_ROOT / relative
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# Keep the reported path informative when nothing is available; the
|
||||||
|
# ``test_video_path`` fixture turns this into a skip.
|
||||||
|
return REPO_ROOT / DEFAULT_VIDEO_CANDIDATES[-1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def test_video_path() -> Path:
|
||||||
|
path = _resolve_test_video()
|
||||||
|
if not path.exists():
|
||||||
|
pytest.skip(
|
||||||
|
"Test video not found. Set ORISYS_TEST_VIDEO to a valid video path."
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def test_config_name() -> str:
|
||||||
|
requested = os.getenv("ORISYS_TEST_CONFIG")
|
||||||
|
|
||||||
|
def _is_usable_config(name: str) -> bool:
|
||||||
|
cfg = orisys_configs.load_config(name)
|
||||||
|
return "box_size" in cfg.get("stitcher", {})
|
||||||
|
|
||||||
|
if requested:
|
||||||
|
if not _is_usable_config(requested):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ORISYS_TEST_CONFIG='{requested}' is missing required key "
|
||||||
|
"'stitcher.box_size'. Use a config like 'ddjx01' or provide a full path."
|
||||||
|
)
|
||||||
|
return requested
|
||||||
|
|
||||||
|
# Auto-pick a stable config for tests (avoid prototype variants missing keys).
|
||||||
|
for name in orisys_configs.list_config():
|
||||||
|
try:
|
||||||
|
if _is_usable_config(name):
|
||||||
|
return name
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"No usable config found for tests (missing 'stitcher.box_size'). "
|
||||||
|
"Set ORISYS_TEST_CONFIG to a valid config JSON/path."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def available_backends() -> list[str]:
|
||||||
|
backends = ["cpu"]
|
||||||
|
if check_cupy_available():
|
||||||
|
backends.append("cupy")
|
||||||
|
if check_opencl_available():
|
||||||
|
backends.append("opencl")
|
||||||
|
return backends
|
||||||
|
|
||||||
|
|
||||||
|
def make_sensor(
|
||||||
|
video_path: Path,
|
||||||
|
config_name: str,
|
||||||
|
backend: str = "cpu",
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> Sensor:
|
||||||
|
use_cuda = backend in ("cupy", "opencl")
|
||||||
|
return Sensor(
|
||||||
|
vid_src=str(video_path),
|
||||||
|
cuda=use_cuda,
|
||||||
|
isstitch=True,
|
||||||
|
verbose=verbose,
|
||||||
|
config_name=config_name,
|
||||||
|
backend=backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sensor_factory(test_video_path, test_config_name):
|
||||||
|
def _factory(backend: str = "cpu", verbose: bool = False):
|
||||||
|
return make_sensor(
|
||||||
|
test_video_path,
|
||||||
|
config_name=test_config_name,
|
||||||
|
backend=backend,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _factory
|
||||||
+1
-1
@@ -8,7 +8,7 @@ Flow:
|
|||||||
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
|
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
|
||||||
5. Compute deformation/contact and visualize
|
5. Compute deformation/contact and visualize
|
||||||
|
|
||||||
SDK version: 0.5.1
|
SDK version: 0.5.2
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Guards for the pytest harness itself.
|
||||||
|
|
||||||
|
``tests/conftest.py`` was accidentally deleted in commit edc6e42 while four test
|
||||||
|
modules still depended on its fixtures, which made the suite unrunnable without
|
||||||
|
anything failing loudly in CI. These tests turn that class of regression into an
|
||||||
|
explicit failure, and also catch a stale ``orisys`` install silently shadowing
|
||||||
|
``src/orisys`` (a real hazard: base/py312 environments have older orisys copies
|
||||||
|
in site-packages).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CONFTEST_PATH = Path(__file__).resolve().parent / "conftest.py"
|
||||||
|
|
||||||
|
# Names that must exist in tests/conftest.py.
|
||||||
|
REQUIRED_CONFTEST_NAMES = (
|
||||||
|
"DEFAULT_VIDEO_CANDIDATES",
|
||||||
|
"make_sensor",
|
||||||
|
"test_video_path",
|
||||||
|
"test_config_name",
|
||||||
|
"available_backends",
|
||||||
|
"sensor_factory",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fixtures that must be resolvable through pytest.
|
||||||
|
REQUIRED_FIXTURES = (
|
||||||
|
"test_video_path",
|
||||||
|
"test_config_name",
|
||||||
|
"available_backends",
|
||||||
|
"sensor_factory",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def _load_conftest_module():
|
||||||
|
"""Load tests/conftest.py by path so a deletion is observable here."""
|
||||||
|
spec = importlib.util.spec_from_file_location("_orisys_conftest_probe", CONFTEST_PATH)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise AssertionError(f"Cannot load {CONFTEST_PATH}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_conftest_defines_shared_fixtures():
|
||||||
|
assert CONFTEST_PATH.is_file(), (
|
||||||
|
f"{CONFTEST_PATH} is missing. test_backends.py, test_smoke_api.py, "
|
||||||
|
"test_errors_and_cleanup.py and test_longrun_stability.py depend on its "
|
||||||
|
"fixtures; it was accidentally deleted in commit edc6e42. Restore it "
|
||||||
|
"instead of deleting it."
|
||||||
|
)
|
||||||
|
|
||||||
|
module = _load_conftest_module()
|
||||||
|
missing = [
|
||||||
|
name for name in REQUIRED_CONFTEST_NAMES if getattr(module, name, None) is None
|
||||||
|
]
|
||||||
|
assert not missing, (
|
||||||
|
f"tests/conftest.py no longer defines: {', '.join(missing)}. "
|
||||||
|
"The shared sensor-test fixtures are required by the suite."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_test_video_is_reachable():
|
||||||
|
"""At least one default candidate must exist, or every sensor test skips."""
|
||||||
|
module = _load_conftest_module()
|
||||||
|
candidates = [REPO_ROOT / Path(rel) for rel in module.DEFAULT_VIDEO_CANDIDATES]
|
||||||
|
existing = [path for path in candidates if path.is_file()]
|
||||||
|
assert existing, (
|
||||||
|
"None of the default test videos exist:\n "
|
||||||
|
+ "\n ".join(str(path) for path in candidates)
|
||||||
|
+ "\nCommit a sample clip under data/sample/ or set ORISYS_TEST_VIDEO."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixtures_are_registered_with_pytest(request):
|
||||||
|
missing: list[str] = []
|
||||||
|
for name in REQUIRED_FIXTURES:
|
||||||
|
try:
|
||||||
|
request.getfixturevalue(name)
|
||||||
|
except pytest.FixtureLookupError:
|
||||||
|
missing.append(name)
|
||||||
|
assert not missing, (
|
||||||
|
f"pytest did not register these fixtures: {', '.join(missing)}. "
|
||||||
|
"Check that tests/conftest.py is present and the @pytest.fixture "
|
||||||
|
"decorators are intact."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tests_run_against_checkout_not_stale_install():
|
||||||
|
"""Fail loudly when an old orisys in site-packages shadows src/orisys."""
|
||||||
|
src_pkg = (REPO_ROOT / "src" / "orisys").resolve()
|
||||||
|
if not src_pkg.is_dir():
|
||||||
|
pytest.skip("src/orisys is not present in this checkout layout")
|
||||||
|
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
module_file = getattr(orisys, "__file__", None)
|
||||||
|
assert module_file, (
|
||||||
|
"import orisys resolved to a namespace package (__file__ is None) "
|
||||||
|
f"instead of {src_pkg}. A stale orisys in site-packages is shadowing "
|
||||||
|
"the checkout; run `pip uninstall -y orisys` in this environment."
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_parent = Path(module_file).resolve().parent
|
||||||
|
assert resolved_parent == src_pkg, (
|
||||||
|
f"import orisys resolved to {resolved_parent} instead of {src_pkg}. "
|
||||||
|
"A stale orisys in site-packages is shadowing the checkout; run "
|
||||||
|
"`pip uninstall -y orisys` in this environment."
|
||||||
|
)
|
||||||
@@ -70,17 +70,22 @@ def test_sensor_detect_motion_ignores_invalid_region():
|
|||||||
backend="cpu",
|
backend="cpu",
|
||||||
)
|
)
|
||||||
try:
|
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)
|
frame = np.zeros((728, 672, 3), dtype=np.uint8)
|
||||||
mask = np.zeros((728, 672), dtype=bool)
|
mask = np.zeros((728, 672), dtype=bool)
|
||||||
mask[180:540, 160:512] = True
|
mask[180:540, 160:512] = True
|
||||||
sensor.get_img(frame=frame, already_processed=True, valid_mask=mask)
|
sensor.get_img(frame=frame, already_processed=True, valid_mask=mask)
|
||||||
|
|
||||||
sensor.flow = np.zeros((728, 672, 2), dtype=np.float32)
|
flow_shape = sensor.valid_mask.shape
|
||||||
sensor.flow[~mask] = 10.0
|
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)
|
assert not sensor.detect_motion(threshold=1.0)
|
||||||
|
|
||||||
sensor.flow[:] = 0.0
|
sensor.flow[:] = 0.0
|
||||||
sensor.flow[mask] = 10.0
|
sensor.flow[sensor.valid_mask] = 10.0
|
||||||
assert sensor.detect_motion(threshold=1.0)
|
assert sensor.detect_motion(threshold=1.0)
|
||||||
finally:
|
finally:
|
||||||
sensor.disconnect()
|
sensor.disconnect()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils):
|
|||||||
sys.path.insert(0, str(build_dir))
|
sys.path.insert(0, str(build_dir))
|
||||||
from common.pyproject_utils import get_version
|
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):
|
def test_pyproject_bundle_manifest(packaging_utils):
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""Tests for build/release version guards, report writing and preflight steps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BUILD_ROOT = REPO_ROOT / "build"
|
||||||
|
if str(BUILD_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BUILD_ROOT))
|
||||||
|
|
||||||
|
from release import report as report_mod # noqa: E402
|
||||||
|
from release import version as version_mod # noqa: E402
|
||||||
|
from release.preflight import Preflight, PreflightOptions # noqa: E402
|
||||||
|
|
||||||
|
PYPROJECT = """
|
||||||
|
[project]
|
||||||
|
name = "orisys"
|
||||||
|
description = "synthetic"
|
||||||
|
|
||||||
|
[tool.setuptools.dynamic]
|
||||||
|
version = {attr = "orisys.__version__"}
|
||||||
|
""".lstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_repo(tmp_path: Path, *, version: str = "0.5.2", with_git: bool = False) -> Path:
|
||||||
|
(tmp_path / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8")
|
||||||
|
package = tmp_path / "src" / "orisys"
|
||||||
|
package.mkdir(parents=True)
|
||||||
|
(package / "__init__.py").write_text(f'__version__ = "{version}"\n', encoding="utf-8")
|
||||||
|
if with_git:
|
||||||
|
(tmp_path / ".git").mkdir()
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_wheel(repo: Path, version: str, name: str | None = None) -> Path:
|
||||||
|
directory = repo / "SDK" / version
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
wheel = directory / (name or f"orisys-{version}-cp310-cp310-win_amd64.whl")
|
||||||
|
wheel.write_bytes(b"not really a wheel")
|
||||||
|
return wheel
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, payload: dict) -> None:
|
||||||
|
self._buffer = io.BytesIO(json.dumps(payload).encode("utf-8"))
|
||||||
|
|
||||||
|
def read(self, *args):
|
||||||
|
return self._buffer.read(*args)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_version_parsing():
|
||||||
|
assert version_mod.wheel_version(Path("orisys-0.5.2-cp310-cp310-win_amd64.whl")) == "0.5.2"
|
||||||
|
assert version_mod.wheel_version(Path("orisys-1.0.0-1-cp312-abi3-manylinux_2_28_x86_64.whl")) == "1.0.0"
|
||||||
|
assert version_mod.wheel_version(Path("something-else.whl")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_versions_are_numerically_sorted(tmp_path):
|
||||||
|
for name in ("0.9.0", "0.4.10", "0.5.2"):
|
||||||
|
(tmp_path / "SDK" / name).mkdir(parents=True)
|
||||||
|
(tmp_path / "SDK" / "not-a-version").mkdir()
|
||||||
|
assert version_mod.sdk_versions(tmp_path)[:3] == ["0.4.10", "0.5.2", "0.9.0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_versions_empty_without_sdk_dir(tmp_path):
|
||||||
|
assert version_mod.sdk_versions(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_wheels_lists_only_that_version(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
_stage_wheel(repo, "0.5.2")
|
||||||
|
_stage_wheel(repo, "0.4.3")
|
||||||
|
wheels = version_mod.sdk_wheels(repo, "0.5.2")
|
||||||
|
assert [w.name for w in wheels] == ["orisys-0.5.2-cp310-cp310-win_amd64.whl"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_version_attr_read_from_pyproject(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
assert version_mod.dynamic_version_attr(repo) == "orisys.__version__"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_version_attr_none_when_absent(tmp_path):
|
||||||
|
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n", encoding="utf-8")
|
||||||
|
assert version_mod.dynamic_version_attr(tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pypi_released_versions_parses_response(tmp_path, monkeypatch):
|
||||||
|
fake = _FakeResponse({"releases": {"0.4.2": [{"filename": "a"}], "0.5.2": [{"filename": "b"}], "0.0.0": []}})
|
||||||
|
monkeypatch.setattr(version_mod.urllib.request, "urlopen", lambda *a, **k: fake)
|
||||||
|
versions, error = version_mod.pypi_released_versions()
|
||||||
|
assert error is None
|
||||||
|
# An empty file list means nothing was ever uploaded for that version.
|
||||||
|
assert versions == ["0.4.2", "0.5.2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pypi_released_versions_reports_http_404_as_empty(monkeypatch):
|
||||||
|
def _raise(*args, **kwargs):
|
||||||
|
raise urllib.error.HTTPError(version_mod.PYPI_JSON_URL, 404, "not found", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(version_mod.urllib.request, "urlopen", _raise)
|
||||||
|
assert version_mod.pypi_released_versions() == ([], None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pypi_released_versions_returns_error_on_failure(monkeypatch):
|
||||||
|
def _raise(*args, **kwargs):
|
||||||
|
raise urllib.error.URLError("no network")
|
||||||
|
|
||||||
|
monkeypatch.setattr(version_mod.urllib.request, "urlopen", _raise)
|
||||||
|
versions, error = version_mod.pypi_released_versions()
|
||||||
|
assert versions is None
|
||||||
|
assert error and "URLError" in error
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_for_tag_uses_envs_root(tmp_path):
|
||||||
|
executable = tmp_path / "py310" / "python.exe"
|
||||||
|
executable.parent.mkdir(parents=True)
|
||||||
|
executable.write_bytes(b"")
|
||||||
|
assert version_mod.python_for_tag("cp310", envs_root=tmp_path) == executable
|
||||||
|
assert version_mod.python_for_tag("cp311", envs_root=tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- report -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_report_round_trips(tmp_path):
|
||||||
|
payload = {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"ok": False,
|
||||||
|
"git": {"commit": "abc", "branch": "dev", "dirty": True},
|
||||||
|
"interpreter": {"version": "3.10.19", "executable": "python"},
|
||||||
|
"duration_s": 1.5,
|
||||||
|
"options": {"skip_tests": True},
|
||||||
|
"steps": [{"name": "tests", "ok": True, "detail": "skipped", "duration_s": 0.0, "data": {}}],
|
||||||
|
"wheels": [{"name": "w.whl", "size": 12, "sha256": "deadbeef"}],
|
||||||
|
}
|
||||||
|
json_path, text_path = report_mod.write_report(payload, tmp_path / "reports")
|
||||||
|
loaded = json.loads(json_path.read_text(encoding="utf-8"))
|
||||||
|
assert loaded["schema"] == report_mod.SCHEMA_VERSION
|
||||||
|
assert loaded["wheels"][0]["sha256"] == "deadbeef"
|
||||||
|
|
||||||
|
text = text_path.read_text(encoding="utf-8")
|
||||||
|
assert "FAIL" in text
|
||||||
|
assert "deadbeef" in text
|
||||||
|
assert "waived guards: skip_tests" in text
|
||||||
|
|
||||||
|
|
||||||
|
# --- preflight steps --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_resolves_version_from_pyproject(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.6.0")
|
||||||
|
preflight = Preflight(PreflightOptions(), repo_root=repo)
|
||||||
|
assert preflight.version == "0.6.0"
|
||||||
|
assert preflight.version_source == "pyproject.toml dynamic attr"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_explicit_version_wins(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.6.0")
|
||||||
|
preflight = Preflight(PreflightOptions(version="0.7.0"), repo_root=repo)
|
||||||
|
assert preflight.version == "0.7.0"
|
||||||
|
assert preflight.version_source == "explicit --version"
|
||||||
|
|
||||||
|
|
||||||
|
def test_repo_state_fails_outside_git(tmp_path, capsys):
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
step = Preflight(PreflightOptions(), repo_root=repo)._repo_state()
|
||||||
|
assert not step.ok
|
||||||
|
assert "not a git repository" in step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_consistency_detects_mismatch(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
step = Preflight(PreflightOptions(version="9.9.9"), repo_root=repo)._version_consistency()
|
||||||
|
assert not step.ok
|
||||||
|
assert "0.5.2" in step.detail and "9.9.9" in step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_consistency_passes_on_match(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._version_consistency()
|
||||||
|
assert step.ok, step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_guard_blocks_existing_sdk_dir(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
_stage_wheel(repo, "0.5.2")
|
||||||
|
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._duplicate_guard()
|
||||||
|
assert not step.ok
|
||||||
|
assert "--force" in step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_guard_allows_force(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
_stage_wheel(repo, "0.5.2")
|
||||||
|
step = Preflight(
|
||||||
|
PreflightOptions(version="0.5.2", force=True), repo_root=repo
|
||||||
|
)._duplicate_guard()
|
||||||
|
assert step.ok, step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_guard_flags_published_version(tmp_path, monkeypatch):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
monkeypatch.setattr(version_mod, "pypi_released_versions", lambda *a, **k: (["0.5.2"], None))
|
||||||
|
step = Preflight(
|
||||||
|
PreflightOptions(version="0.5.2", check_pypi=True), repo_root=repo
|
||||||
|
)._duplicate_guard()
|
||||||
|
assert not step.ok
|
||||||
|
assert "already published on PyPI" in step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_precheck_fails_when_pattern_resolves_nothing(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
(repo / "pyproject.toml").write_text(
|
||||||
|
PYPROJECT
|
||||||
|
+ """
|
||||||
|
[[tool.orisys.bundle.items]]
|
||||||
|
name = "configs"
|
||||||
|
source = "config"
|
||||||
|
target = "configs"
|
||||||
|
patterns = ["*.json"]
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._bundle_precheck()
|
||||||
|
assert not step.ok
|
||||||
|
assert "resolved 0 files" in step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_inspection_fails_without_wheels(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._wheel_inspection()
|
||||||
|
assert not step.ok
|
||||||
|
assert "does not exist" in step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_runtime_fails_without_video(tmp_path, monkeypatch):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
_stage_wheel(repo, "0.5.2")
|
||||||
|
monkeypatch.setattr("release.preflight.default_verification_video", lambda root: None)
|
||||||
|
preflight = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)
|
||||||
|
preflight._discover_wheels()
|
||||||
|
step = preflight._wheel_runtime()
|
||||||
|
assert not step.ok
|
||||||
|
assert "never silently skipped" in step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_runtime_skip_is_recorded(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
step = Preflight(
|
||||||
|
PreflightOptions(version="0.5.2", no_runtime=True), repo_root=repo
|
||||||
|
)._wheel_runtime()
|
||||||
|
assert step.ok
|
||||||
|
assert "SKIPPED" in step.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_tests_skip_is_loud_and_recorded(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
step = Preflight(
|
||||||
|
PreflightOptions(version="0.5.2", skip_tests=True), repo_root=repo
|
||||||
|
)._tests()
|
||||||
|
assert step.ok
|
||||||
|
assert "SKIPPED" in step.detail
|
||||||
|
assert step.data["status"] == "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_marks_failure_and_aborts(tmp_path, capsys):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
preflight = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)
|
||||||
|
preflight._execute(preflight._repo_state)
|
||||||
|
assert preflight.aborted
|
||||||
|
assert preflight.steps[-1].name == "repo-state"
|
||||||
|
assert not preflight.steps[-1].ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_keep_going_does_not_abort(tmp_path):
|
||||||
|
repo = _make_repo(tmp_path, version="0.5.2")
|
||||||
|
preflight = Preflight(PreflightOptions(version="0.5.2", keep_going=True), repo_root=repo)
|
||||||
|
preflight._execute(preflight._repo_state)
|
||||||
|
assert not preflight.aborted
|
||||||
@@ -19,25 +19,25 @@ def wheel_package():
|
|||||||
|
|
||||||
def test_compliant_manylinux_tags(wheel_package):
|
def test_compliant_manylinux_tags(wheel_package):
|
||||||
assert wheel_package.is_compliant_manylinux_wheel(
|
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(
|
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(
|
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):
|
def test_rejects_illegal_manylinux_tags(wheel_package):
|
||||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
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(
|
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(
|
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.5.1-cp310-cp310-win_amd64.whl")
|
Path("orisys-0.5.2-cp310-cp310-win_amd64.whl")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""Tests for :mod:`common.verify.wheel_contents`.
|
||||||
|
|
||||||
|
Every check is exercised in both directions: a well-formed synthetic wheel must
|
||||||
|
pass, and each specific defect must fail with the check named in the report.
|
||||||
|
Synthetic wheels keep the tests fast and keep them independent of whatever
|
||||||
|
happens to sit in ``SDK/``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BUILD_ROOT = REPO_ROOT / "build"
|
||||||
|
if str(BUILD_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BUILD_ROOT))
|
||||||
|
|
||||||
|
from common.verify import wheel_contents # noqa: E402
|
||||||
|
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
||||||
|
|
||||||
|
VERSION = "0.5.2"
|
||||||
|
DIST_INFO = f"orisys-{VERSION}.dist-info"
|
||||||
|
|
||||||
|
CORE_DEPS = ("numpy>=1.24.4", "opencv-python>=4.10.0.84")
|
||||||
|
EXTRAS = {"cuda12": ("cupy-cuda12x>=12.3.0",), "gui": ("open3d>=0.17.0",)}
|
||||||
|
|
||||||
|
PYPROJECT = f"""
|
||||||
|
[project]
|
||||||
|
name = "orisys"
|
||||||
|
version = "{VERSION}"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [{", ".join(repr(dep) for dep in CORE_DEPS)}]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
cuda12 = ["cupy-cuda12x>=12.3.0"]
|
||||||
|
gui = ["open3d>=0.17.0"]
|
||||||
|
|
||||||
|
[[tool.orisys.bundle.items]]
|
||||||
|
name = "configs"
|
||||||
|
source = "config"
|
||||||
|
target = "configs"
|
||||||
|
patterns = ["*.json"]
|
||||||
|
|
||||||
|
[[tool.orisys.bundle.items]]
|
||||||
|
name = "sensors"
|
||||||
|
source = "sensors"
|
||||||
|
target = "sensors"
|
||||||
|
patterns = ["**/*.json"]
|
||||||
|
preserve_tree = true
|
||||||
|
|
||||||
|
[[tool.orisys.bundle.items]]
|
||||||
|
name = "docs"
|
||||||
|
source = "docs"
|
||||||
|
target = "docs"
|
||||||
|
patterns = ["*.md"]
|
||||||
|
|
||||||
|
[[tool.orisys.bundle.items]]
|
||||||
|
name = "examples"
|
||||||
|
source = "examples"
|
||||||
|
target = "examples"
|
||||||
|
patterns = ["*.py", "README.md"]
|
||||||
|
preserve_tree = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Source tree files, keyed by path relative to the synthetic repo root.
|
||||||
|
SOURCE_FILES = {
|
||||||
|
"config/box.json": '{"name": "box"}',
|
||||||
|
"sensors/box/sensor.json": '{"name": "box"}',
|
||||||
|
"sensors/finger/manifest.json": '{"name": "finger"}',
|
||||||
|
"docs/api.md": "# api\n",
|
||||||
|
"examples/basic_pipeline.py": "print('hi')\n",
|
||||||
|
"examples/README.md": "# examples\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Where each source file must land inside the wheel.
|
||||||
|
BUNDLED_ENTRIES = {
|
||||||
|
"orisys/configs/box.json": SOURCE_FILES["config/box.json"],
|
||||||
|
"orisys/sensors/box/sensor.json": SOURCE_FILES["sensors/box/sensor.json"],
|
||||||
|
"orisys/sensors/finger/manifest.json": SOURCE_FILES["sensors/finger/manifest.json"],
|
||||||
|
"orisys/docs/api.md": SOURCE_FILES["docs/api.md"],
|
||||||
|
"orisys/examples/basic_pipeline.py": SOURCE_FILES["examples/basic_pipeline.py"],
|
||||||
|
"orisys/examples/README.md": SOURCE_FILES["examples/README.md"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_text(
|
||||||
|
*,
|
||||||
|
version: str = VERSION,
|
||||||
|
requires_python: str | None = ">=3.10",
|
||||||
|
classifiers: tuple[str, ...] = ("Programming Language :: Python :: 3.10",),
|
||||||
|
project_urls: tuple[str, ...] = ("Homepage, https://example.invalid",),
|
||||||
|
license_line: str | None = "License: Proprietary",
|
||||||
|
core_deps: tuple[str, ...] = CORE_DEPS,
|
||||||
|
extras: dict[str, tuple[str, ...]] | None = None,
|
||||||
|
dynamic: tuple[str, ...] = (),
|
||||||
|
name: str = "orisys",
|
||||||
|
) -> str:
|
||||||
|
if extras is None:
|
||||||
|
extras = EXTRAS
|
||||||
|
lines = ["Metadata-Version: 2.4", f"Name: {name}", f"Version: {version}", "Summary: synthetic"]
|
||||||
|
if requires_python:
|
||||||
|
lines.append(f"Requires-Python: {requires_python}")
|
||||||
|
if license_line:
|
||||||
|
lines.append(license_line)
|
||||||
|
lines.extend(f"Classifier: {value}" for value in classifiers)
|
||||||
|
lines.extend(f"Project-URL: {value}" for value in project_urls)
|
||||||
|
lines.extend(f"Dynamic: {value}" for value in dynamic)
|
||||||
|
lines.extend(f"Requires-Dist: {dep}" for dep in core_deps)
|
||||||
|
for extra, requirements in extras.items():
|
||||||
|
lines.append(f"Provides-Extra: {extra}")
|
||||||
|
lines.extend(f'Requires-Dist: {req}; extra == "{extra}"' for req in requirements)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_source_tree(root: Path) -> None:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8")
|
||||||
|
for relative, text in SOURCE_FILES.items():
|
||||||
|
path = root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# write_bytes, not write_text: text mode would translate "\n" to "\r\n"
|
||||||
|
# on Windows and the byte-for-byte bundle comparison would fail.
|
||||||
|
path.write_bytes(text.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _record(entries: dict[str, bytes]) -> str:
|
||||||
|
lines = []
|
||||||
|
for name, data in sorted(entries.items()):
|
||||||
|
digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode()
|
||||||
|
lines.append(f"{name},sha256={digest},{len(data)}")
|
||||||
|
lines.append(f"{DIST_INFO}/RECORD,,")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_wheel(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
filename: str | None = None,
|
||||||
|
metadata: str | None = None,
|
||||||
|
drop: tuple[str, ...] = (),
|
||||||
|
extra_entries: dict[str, bytes] | None = None,
|
||||||
|
override_bytes: dict[str, bytes] | None = None,
|
||||||
|
drop_record: bool = False,
|
||||||
|
corrupt_record_hash: bool = False,
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
"""Build a synthetic wheel plus its matching source tree."""
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
_write_source_tree(repo)
|
||||||
|
|
||||||
|
if metadata is None:
|
||||||
|
metadata = _metadata_text()
|
||||||
|
|
||||||
|
entries: dict[str, bytes] = {name: data.encode("utf-8") for name, data in BUNDLED_ENTRIES.items()}
|
||||||
|
entries["orisys/__init__.cp312-win_amd64.pyd"] = b"compiled"
|
||||||
|
entries["orisys_blend/__init__.py"] = b"# blend\n"
|
||||||
|
entries["orisys_blend/kernel.py"] = b"# kernel\n"
|
||||||
|
entries[f"{DIST_INFO}/METADATA"] = metadata.encode("utf-8")
|
||||||
|
entries[f"{DIST_INFO}/WHEEL"] = b"Wheel-Version: 1.0\n"
|
||||||
|
|
||||||
|
entries.update(extra_entries or {})
|
||||||
|
entries.update(override_bytes or {})
|
||||||
|
for name in drop:
|
||||||
|
entries.pop(name, None)
|
||||||
|
|
||||||
|
if not drop_record:
|
||||||
|
record = _record(entries)
|
||||||
|
if corrupt_record_hash:
|
||||||
|
record = record.replace("sha256=", "sha256=AAAA", 1)
|
||||||
|
entries[f"{DIST_INFO}/RECORD"] = record.encode("utf-8")
|
||||||
|
|
||||||
|
wheel = tmp_path / (filename or f"orisys-{VERSION}-cp312-cp312-win_amd64.whl")
|
||||||
|
with zipfile.ZipFile(wheel, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for name, data in entries.items():
|
||||||
|
zf.writestr(name, data)
|
||||||
|
return wheel, repo
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_names(result) -> set[str]:
|
||||||
|
return {check.name for check in result.failures}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolated_repo_root(monkeypatch, tmp_path):
|
||||||
|
"""Keep inspect_wheel's default repo root from leaking the real repo in."""
|
||||||
|
monkeypatch.setattr(wheel_contents, "REPO_ROOT", tmp_path / "unused")
|
||||||
|
|
||||||
|
|
||||||
|
def test_good_wheel_passes(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path)
|
||||||
|
result = inspect_wheel(wheel, expected_version=VERSION, repo_root=repo)
|
||||||
|
assert result.ok, result.render()
|
||||||
|
assert result.version == VERSION
|
||||||
|
assert result.platform_tag == "win_amd64"
|
||||||
|
assert not result.warnings, result.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_wheel_file_fails(tmp_path):
|
||||||
|
result = inspect_wheel(tmp_path / "nope-0.5.2-cp312-cp312-win_amd64.whl")
|
||||||
|
assert not result.ok
|
||||||
|
assert "wheel-exists" in _failed_names(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unparsable_filename_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, filename="orisys-0.5.2-cp312-cp312.whl")
|
||||||
|
result = inspect_wheel(wheel, repo_root=repo)
|
||||||
|
assert "filename" in _failed_names(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_mismatch_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path)
|
||||||
|
result = inspect_wheel(wheel, expected_version="9.9.9", repo_root=repo)
|
||||||
|
failed = _failed_names(result)
|
||||||
|
assert {"filename", "metadata-version"} <= failed
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_requires_python_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(requires_python=None))
|
||||||
|
assert "metadata-requires-python" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_classifier_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(classifiers=()))
|
||||||
|
assert "metadata-classifiers" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_project_url_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(project_urls=()))
|
||||||
|
assert "metadata-project-urls" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_license_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(
|
||||||
|
tmp_path,
|
||||||
|
metadata=_metadata_text(license_line=None, classifiers=("Intended Audience :: Developers",)),
|
||||||
|
)
|
||||||
|
assert "metadata-license" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_version_is_rejected(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(dynamic=("version", "summary")))
|
||||||
|
result = inspect_wheel(wheel, repo_root=repo)
|
||||||
|
assert "metadata-no-dynamic-version" in _failed_names(result)
|
||||||
|
assert any("Dynamic" in warning for warning in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_core_requirement_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(core_deps=("numpy>=1.24.4",)))
|
||||||
|
assert "metadata-requires-dist" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_extra_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(extras={"cuda12": EXTRAS["cuda12"]}))
|
||||||
|
assert "metadata-extras" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_compiled_extension_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, drop=("orisys/__init__.cp312-win_amd64.pyd",))
|
||||||
|
assert "compiled-extension" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_source_leak_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(
|
||||||
|
tmp_path, extra_entries={"orisys/sensor.py": b"# leaked\n"}
|
||||||
|
)
|
||||||
|
result = inspect_wheel(wheel, repo_root=repo)
|
||||||
|
assert "no-python-sources" in _failed_names(result)
|
||||||
|
assert "orisys/sensor.py" in result.render()
|
||||||
|
|
||||||
|
|
||||||
|
def test_example_python_files_are_allowed(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(
|
||||||
|
tmp_path, extra_entries={"orisys/examples/plot_data.py": b"# shipped on purpose\n"}
|
||||||
|
)
|
||||||
|
assert inspect_wheel(wheel, repo_root=repo).ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_bundled_file_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, drop=("orisys/sensors/box/sensor.json",))
|
||||||
|
result = inspect_wheel(wheel, repo_root=repo)
|
||||||
|
assert "bundle-completeness" in _failed_names(result)
|
||||||
|
assert "orisys/sensors/box/sensor.json" in result.render()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_bundled_file_warns_when_not_strict(tmp_path):
|
||||||
|
"""A file added to the tree after an old wheel was built is not a defect."""
|
||||||
|
wheel, repo = _make_wheel(tmp_path, drop=("orisys/sensors/box/sensor.json",))
|
||||||
|
result = inspect_wheel(wheel, repo_root=repo, strict_bundle=False)
|
||||||
|
assert result.ok, result.render()
|
||||||
|
assert any("absent from the wheel" in warning for warning in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_drift_is_fatal_when_strict(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(
|
||||||
|
tmp_path, override_bytes={"orisys/docs/api.md": b"# stale\n"}
|
||||||
|
)
|
||||||
|
assert "bundle-fidelity" in _failed_names(inspect_wheel(wheel, repo_root=repo, strict_bundle=True))
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_drift_warns_when_not_strict(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(
|
||||||
|
tmp_path, override_bytes={"orisys/docs/api.md": b"# stale\n"}
|
||||||
|
)
|
||||||
|
result = inspect_wheel(wheel, repo_root=repo, strict_bundle=False)
|
||||||
|
assert result.ok, result.render()
|
||||||
|
assert any("differ from the source tree" in warning for warning in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pycache_entry_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(
|
||||||
|
tmp_path, extra_entries={"orisys/examples/__pycache__/x.pyc": b"junk"}
|
||||||
|
)
|
||||||
|
assert "no-forbidden-entries" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_scratch_directory_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, extra_entries={"tests/test_smoke.py": b"x"})
|
||||||
|
assert "no-forbidden-entries" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_traversing_entry_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, extra_entries={"../escape.txt": b"x"})
|
||||||
|
assert "archive-paths" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_hash_mismatch_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, corrupt_record_hash=True)
|
||||||
|
assert "record" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_record_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path, drop_record=True)
|
||||||
|
assert "record" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_limit_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(tmp_path)
|
||||||
|
assert "size-limit" in _failed_names(inspect_wheel(wheel, repo_root=repo, max_bytes=1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_manylinux_tag_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(
|
||||||
|
tmp_path, filename=f"orisys-{VERSION}-cp312-cp312-linux_x86_64.whl"
|
||||||
|
)
|
||||||
|
assert "platform-tag" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
|
||||||
|
|
||||||
|
def test_good_manylinux_tag_passes(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(
|
||||||
|
tmp_path,
|
||||||
|
filename=f"orisys-{VERSION}-cp312-cp312-manylinux_2_28_x86_64.whl",
|
||||||
|
)
|
||||||
|
assert inspect_wheel(wheel, repo_root=repo).ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_distribution_name_fails(tmp_path):
|
||||||
|
wheel, repo = _make_wheel(
|
||||||
|
tmp_path,
|
||||||
|
filename=f"notorisys-{VERSION}-cp312-cp312-win_amd64.whl",
|
||||||
|
metadata=_metadata_text(name="notorisys"),
|
||||||
|
)
|
||||||
|
failed = _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||||
|
assert {"filename", "metadata-name"} <= failed
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_layout_module_resolves_to_build_tree():
|
||||||
|
import wheel
|
||||||
|
|
||||||
|
resolved = Path(wheel.__file__).resolve()
|
||||||
|
assert BUILD_ROOT.resolve() in resolved.parents, (
|
||||||
|
f"`wheel` resolved to {resolved}; build/ must precede site-packages so the "
|
||||||
|
"build tooling imports the repository's wheel package."
|
||||||
|
)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Tests for build/wheel/package.py metadata plumbing.
|
||||||
|
|
||||||
|
The Nuitka wheel is built from a generated ``setup.py``, which is exactly where
|
||||||
|
the 0.5.2 release silently lost ``Requires-Python``, classifiers, project URLs
|
||||||
|
and the license. These tests run setuptools for real (in a temp dir, on a stub
|
||||||
|
package tree) and assert on the METADATA it produces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BUILD_ROOT = REPO_ROOT / "build"
|
||||||
|
if str(BUILD_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BUILD_ROOT))
|
||||||
|
|
||||||
|
from wheel.package import metadata_kwargs, read_long_description, write_wheel_setup # noqa: E402
|
||||||
|
|
||||||
|
PROJECT = {
|
||||||
|
"name": "orisys",
|
||||||
|
"description": "SDK for Orisys Technology Visuotactile Sensors",
|
||||||
|
"requires-python": ">=3.10",
|
||||||
|
"license": {"text": "Proprietary"},
|
||||||
|
"authors": [{"name": "Orisys-Tech"}],
|
||||||
|
"maintainers": [{"name": "Yijian Zhang", "email": "zhangyijian@orisys-tech.cn"}],
|
||||||
|
"dependencies": ["numpy>=1.24.4"],
|
||||||
|
"optional-dependencies": {"gui": ["open3d>=0.17.0"]},
|
||||||
|
"classifiers": [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"License :: Other/Proprietary License",
|
||||||
|
],
|
||||||
|
"urls": {
|
||||||
|
"Homepage": "https://www.orisys-tech.cn",
|
||||||
|
"Documentation": "https://github.com/Orisys-Tech/orisys-sdk",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
LONG_DESCRIPTION = "# Orisys SDK\n\nlong description body.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_wheel_root(wheel_dir: Path) -> None:
|
||||||
|
"""Minimal package tree so the generated setup.py can build metadata."""
|
||||||
|
(wheel_dir / "wheel_root" / "orisys").mkdir(parents=True)
|
||||||
|
(wheel_dir / "wheel_root" / "orisys" / "__init__.cp312-win_amd64.pyd").write_bytes(b"x")
|
||||||
|
(wheel_dir / "wheel_root" / "orisys" / "examples").mkdir()
|
||||||
|
(wheel_dir / "wheel_root" / "orisys" / "examples" / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(wheel_dir / "wheel_root" / "orisys_blend").mkdir()
|
||||||
|
(wheel_dir / "wheel_root" / "orisys_blend" / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _generate(wheel_dir: Path, tmp_path: Path) -> Path:
|
||||||
|
_stub_wheel_root(wheel_dir)
|
||||||
|
write_wheel_setup(
|
||||||
|
wheel_dir,
|
||||||
|
version="0.5.2",
|
||||||
|
package_data={"orisys": ["*.pyd"]},
|
||||||
|
install_requires=["numpy>=1.24.4"],
|
||||||
|
extras_require={"gui": ["open3d>=0.17.0"]},
|
||||||
|
project=PROJECT,
|
||||||
|
long_description=LONG_DESCRIPTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, "setup.py", "-q", "dist_info"],
|
||||||
|
cwd=wheel_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode == 0, (
|
||||||
|
f"generated setup.py failed:\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = sorted(wheel_dir.glob("**/*.dist-info/METADATA"))
|
||||||
|
assert candidates, f"no METADATA produced under {wheel_dir}"
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_kwargs_translates_pyproject():
|
||||||
|
kwargs = metadata_kwargs(PROJECT)
|
||||||
|
assert kwargs["description"] == PROJECT["description"]
|
||||||
|
assert kwargs["python_requires"] == ">=3.10"
|
||||||
|
assert kwargs["license"] == "Proprietary"
|
||||||
|
assert kwargs["classifiers"] == PROJECT["classifiers"]
|
||||||
|
assert kwargs["project_urls"] == PROJECT["urls"]
|
||||||
|
assert kwargs["author"] == "Orisys-Tech"
|
||||||
|
assert kwargs["maintainer"] == "Yijian Zhang <zhangyijian@orisys-tech.cn>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_kwargs_accepts_string_license_and_drops_empties():
|
||||||
|
kwargs = metadata_kwargs({"name": "orisys", "license": "Proprietary"})
|
||||||
|
assert kwargs["license"] == "Proprietary"
|
||||||
|
assert "python_requires" not in kwargs
|
||||||
|
assert "classifiers" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_long_description_uses_pypi_readme(tmp_path):
|
||||||
|
(tmp_path / "docs").mkdir()
|
||||||
|
(tmp_path / "docs" / "pypi_readme.md").write_text(LONG_DESCRIPTION, encoding="utf-8")
|
||||||
|
assert read_long_description(tmp_path) == LONG_DESCRIPTION
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_long_description_missing_returns_none(tmp_path, capsys):
|
||||||
|
assert read_long_description(tmp_path) is None
|
||||||
|
assert "not found" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_wheel_metadata_is_complete(tmp_path):
|
||||||
|
wheel_dir = tmp_path / "wheel"
|
||||||
|
wheel_dir.mkdir()
|
||||||
|
metadata_path = _generate(wheel_dir, tmp_path)
|
||||||
|
text = metadata_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "Name: orisys" in text
|
||||||
|
assert "Version: 0.5.2" in text
|
||||||
|
assert f"Summary: {PROJECT['description']}" in text
|
||||||
|
assert "Requires-Python: >=3.10" in text
|
||||||
|
assert "License: Proprietary" in text
|
||||||
|
assert "Classifier: Development Status :: 4 - Beta" in text
|
||||||
|
assert "Classifier: License :: Other/Proprietary License" in text
|
||||||
|
assert "Project-URL: Homepage, https://www.orisys-tech.cn" in text
|
||||||
|
assert "Project-URL: Documentation, https://github.com/Orisys-Tech/orisys-sdk" in text
|
||||||
|
assert "Requires-Dist: numpy>=1.24.4" in text
|
||||||
|
assert 'Provides-Extra: gui' in text
|
||||||
|
assert "Description-Content-Type: text/markdown" in text
|
||||||
|
assert "long description body." in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_wheel_metadata_keeps_version_static(tmp_path):
|
||||||
|
"""``Version`` (and ``Name``) must never be annotated as dynamic.
|
||||||
|
|
||||||
|
setuptools marks every field supplied through the legacy ``setup()`` path as
|
||||||
|
``Dynamic:`` even when the value is present and correct (verified by
|
||||||
|
``test_generated_wheel_metadata_is_complete``). PEP 643 ``Dynamic`` is
|
||||||
|
sdist-only and pip/PyPI ignore it in wheels, so only the identity fields are
|
||||||
|
asserted here.
|
||||||
|
"""
|
||||||
|
wheel_dir = tmp_path / "wheel"
|
||||||
|
wheel_dir.mkdir()
|
||||||
|
text = _generate(wheel_dir, tmp_path).read_text(encoding="utf-8")
|
||||||
|
dynamic = {line[len("Dynamic: ") :].strip() for line in text.splitlines() if line.startswith("Dynamic:")}
|
||||||
|
assert "version" not in dynamic, f"wheel METADATA marks Version as dynamic: {sorted(dynamic)}"
|
||||||
|
assert "name" not in dynamic, f"wheel METADATA marks Name as dynamic: {sorted(dynamic)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_wheel_setup_without_project_keeps_legacy_shape(tmp_path):
|
||||||
|
"""Calling without metadata must not crash (backwards compatible)."""
|
||||||
|
wheel_dir = tmp_path / "wheel"
|
||||||
|
wheel_dir.mkdir()
|
||||||
|
setup_py = write_wheel_setup(
|
||||||
|
wheel_dir,
|
||||||
|
version="0.0.1",
|
||||||
|
package_data={"orisys": ["*.pyd"]},
|
||||||
|
install_requires=["numpy"],
|
||||||
|
extras_require={},
|
||||||
|
)
|
||||||
|
text = setup_py.read_text(encoding="utf-8")
|
||||||
|
assert 'name="orisys"' in text
|
||||||
|
assert 'version="0.0.1"' in text
|
||||||
|
assert "long_description" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_pypi_readme_exists_in_repo():
|
||||||
|
assert (REPO_ROOT / "docs" / "pypi_readme.md").is_file(), (
|
||||||
|
"docs/pypi_readme.md is the PyPI long description source; without it "
|
||||||
|
"wheels are published with no project description."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user