Compare commits
30 Commits
main
..
07da372204
| Author | SHA1 | Date | |
|---|---|---|---|
| 07da372204 | |||
| 0e903792bd | |||
| 153f51df11 | |||
| 2e7b65298b | |||
| e2b906a640 | |||
| 45419a59c4 | |||
| 05b2354d0d | |||
| 1cf78c9d71 | |||
| 14fbfec62e | |||
| 11962cdc34 | |||
| ae5bf0c208 | |||
| 7f8e5d0975 | |||
| 35190562e5 | |||
| b956d071ea | |||
| 0beb36a1af | |||
| 1adb1c30f0 | |||
| 514874e2f4 | |||
| 0d5d8ff70d | |||
| 5f78115429 | |||
| 488c8b0bc1 | |||
| 94f38bc389 | |||
| 174bceabc4 | |||
| 388f34ca85 | |||
| 35af74f639 | |||
| 57bef85e32 | |||
| 9537cf91a1 | |||
| 9c23ba3d50 | |||
| 96f4428230 | |||
| 9cbba08d68 | |||
| ad680b77dc |
@@ -10,6 +10,7 @@ __pycache__/
|
||||
/build/dist/
|
||||
/build/lib/
|
||||
/build/pyinstaller_work/
|
||||
/build/release/reports/
|
||||
/build/*.spec
|
||||
/dist/
|
||||
/dist_obfuscated/
|
||||
@@ -23,6 +24,7 @@ __pycache__/
|
||||
# Local tools / editor state
|
||||
/.cursor/
|
||||
/.vscode/
|
||||
/_perf/
|
||||
|
||||
# Local data / models
|
||||
/models/
|
||||
@@ -42,3 +44,6 @@ __pycache__/
|
||||
# Legacy / scratch folders
|
||||
/old_orisys/
|
||||
/tools/calibration/
|
||||
|
||||
# Scratch output of examples/ (multi_stream.py's default --out-dir)
|
||||
/outputs/
|
||||
@@ -2,6 +2,8 @@
|
||||
include pyproject.toml
|
||||
include setup.py
|
||||
include README.md
|
||||
# Build-time input for the PyPI long description (read from the checkout).
|
||||
include docs/pypi_readme.md
|
||||
recursive-include config *.json
|
||||
recursive-include sensors *
|
||||
recursive-include build/common *.py
|
||||
|
||||
+55
-2
@@ -12,6 +12,7 @@ build/
|
||||
README.md
|
||||
common/ shared helpers (paths, pyproject, verify)
|
||||
wheel/ SDK .whl (Nuitka + manylinux Docker)
|
||||
release/ local release gate (preflight / audit)
|
||||
gui/
|
||||
common/ shared PyInstaller driver
|
||||
ory_finger/ ORY-FINGER dashboard
|
||||
@@ -38,9 +39,58 @@ Direct module paths (same behavior):
|
||||
```bash
|
||||
python build/wheel/build.py
|
||||
build\wheel\win.bat
|
||||
.\build\wheel\docker\run.ps1
|
||||
.\build\wheel\docker\run.ps1 -SkipImageBuild # reuse the image when only sources changed
|
||||
```
|
||||
|
||||
The image bakes in per-ABI build tooling and a per-ABI wheelhouse of the runtime
|
||||
deps, so the entrypoint no longer re-resolves them over the network. See
|
||||
`docs/publishing.md` §6 for build-time details, why `-SkipImageBuild` matters,
|
||||
and what to change before attempting to parallelize the per-ABI builds.
|
||||
|
||||
### 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 <version>
|
||||
python build/run.py release preflight --all-wheel-pythons --check-pypi
|
||||
python build/run.py release audit SDK\<version>\orisys-<version>-cp312-cp312-win_amd64.whl
|
||||
```
|
||||
|
||||
`preflight` runs: repo state → version consistency → version-string scan →
|
||||
duplicate guard (fails only when the version is already released: a local git
|
||||
tag, or PyPI with `--check-pypi`) → pytest → bundle precheck → per-wheel static
|
||||
content/metadata assertions → isolated runtime verification in one venv per wheel
|
||||
(imports + bundled assets + real frames, no camera). A populated `SDK/<version>/`
|
||||
is the expected input, so build first:
|
||||
|
||||
```bash
|
||||
python build/run.py wheel-win
|
||||
python build/run.py release preflight --all-wheel-pythons --check-pypi
|
||||
```
|
||||
|
||||
Reports land in `build/release/reports/<version>/`.
|
||||
|
||||
### Publish
|
||||
|
||||
Both default to a dry run; `--apply` is required for anything irreversible.
|
||||
|
||||
```bash
|
||||
python build/run.py release auth # credential status
|
||||
python build/run.py release auth --set-token pypi # hidden prompt, writes ~/.pypirc
|
||||
python build/run.py release github --version <version> # preview
|
||||
python build/run.py release github --version <version> --apply # sync + tag + push + Release
|
||||
python build/run.py release pypi --version <version> --apply # TestPyPI only
|
||||
python build/run.py release pypi --version <version> --confirm-pypi --apply # + PyPI
|
||||
python build/run.py release pypi --version <version> --pypi-only --apply # skip TestPyPI
|
||||
```
|
||||
|
||||
Both refuse to act unless `release preflight` passed on a clean commit with no
|
||||
waived options, and every wheel still hashes to what that report recorded.
|
||||
Tokens are only ever read from a hidden prompt or stdin, never from argv.
|
||||
See `docs/publishing.md` §3.
|
||||
|
||||
### GUI
|
||||
|
||||
```bash
|
||||
@@ -53,13 +103,16 @@ python build/run.py gui box # scaffold / NotImplemented
|
||||
|
||||
```bash
|
||||
python build/run.py verify path\to\orisys.whl
|
||||
python build/run.py verify path\to\orisys.whl --contents --assets --runtime
|
||||
python build/run.py verify --gui-dist --product ory_finger
|
||||
```
|
||||
|
||||
## Outputs
|
||||
|
||||
- Wheels: `SDK/<version>/`, `dist/repaired/`
|
||||
- Preflight reports: `build/release/reports/<version>/`
|
||||
- Finger GUI: `dist/OrisysDashboard/`
|
||||
- Future mini/box: `dist/OryMini/`, `dist/OryBox/`
|
||||
|
||||
Generated work files under `build/nuitka/` and `build/pyinstaller_work/` are gitignored.
|
||||
Generated work files under `build/nuitka/`, `build/release/reports/` and
|
||||
`build/pyinstaller_work/` are gitignored.
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
"""Post-build smoke tests for wheels and product GUI folders."""
|
||||
|
||||
from common.verify.gui import GUI_PRODUCTS, verify_gui_dist
|
||||
from common.verify.wheel import verify_built_wheel
|
||||
from common.verify.wheel import (
|
||||
verify_built_wheel,
|
||||
verify_wheel,
|
||||
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",
|
||||
"verify_wheel_assets",
|
||||
"verify_wheel_runtime",
|
||||
]
|
||||
|
||||
+105
-1
@@ -3,9 +3,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from common.verify import GUI_PRODUCTS, verify_built_wheel, verify_gui_dist
|
||||
_BUILD_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.paths import REPO_ROOT # noqa: E402
|
||||
from common.verify import ( # noqa: E402
|
||||
GUI_PRODUCTS,
|
||||
inspect_wheel,
|
||||
verify_built_wheel,
|
||||
verify_gui_dist,
|
||||
verify_wheel,
|
||||
verify_wheel_assets,
|
||||
verify_wheel_runtime,
|
||||
)
|
||||
from common.verify.wheel import DEFAULT_RUNTIME_CONFIG, default_verification_video # noqa: E402
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -18,6 +33,31 @@ def main(argv: list[str] | None = None) -> int:
|
||||
help="GUI product profile (default: ory_finger)",
|
||||
)
|
||||
parser.add_argument("--dist-dir", type=Path, default=None, help="Override GUI dist folder")
|
||||
parser.add_argument(
|
||||
"--contents",
|
||||
action="store_true",
|
||||
help="Run static metadata/content assertions on the wheel",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assets",
|
||||
action="store_true",
|
||||
help="Install the wheel in a temp venv and assert bundled assets load",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--runtime",
|
||||
action="store_true",
|
||||
help="Install the wheel and process real frames (no camera required)",
|
||||
)
|
||||
parser.add_argument("--expected-version", default=None, help="Assert the wheel version")
|
||||
parser.add_argument(
|
||||
"--strict-bundle",
|
||||
action="store_true",
|
||||
help="Treat bundled-file drift from the source tree as a failure",
|
||||
)
|
||||
parser.add_argument("--video", type=Path, default=None, help="Clip for --runtime")
|
||||
parser.add_argument("--config", default=DEFAULT_RUNTIME_CONFIG, help="Config for --runtime")
|
||||
parser.add_argument("--frames", type=int, default=40, help="Frames for --runtime")
|
||||
parser.add_argument("--offline", action="store_true", help="Reuse parent-env dependencies")
|
||||
parser.add_argument("wheel", nargs="?", type=Path, help="Wheel path for import smoke test")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
@@ -26,6 +66,70 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 0
|
||||
if args.wheel is None:
|
||||
parser.error("Provide a wheel path or pass --gui-dist")
|
||||
|
||||
exit_code = 0
|
||||
|
||||
if args.contents:
|
||||
result = inspect_wheel(
|
||||
args.wheel,
|
||||
expected_version=args.expected_version,
|
||||
repo_root=REPO_ROOT,
|
||||
strict_bundle=args.strict_bundle,
|
||||
)
|
||||
print(result.render())
|
||||
if not result.ok:
|
||||
exit_code = 1
|
||||
|
||||
if args.assets and 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:
|
||||
record = verify_wheel(
|
||||
args.wheel,
|
||||
video=video,
|
||||
config_name=args.config,
|
||||
frames=args.frames,
|
||||
offline=args.offline,
|
||||
)
|
||||
print(f"[ok ] {record['imports']}")
|
||||
print(f"[ok ] {record['assets']}")
|
||||
print(f"[ok ] runtime: frames={record['frames']} flow={record['flow_shape']}")
|
||||
except Exception as exc:
|
||||
print(f"[FAIL] isolated verification: {type(exc).__name__}: {exc}")
|
||||
exit_code = 1
|
||||
return exit_code
|
||||
|
||||
if args.assets:
|
||||
try:
|
||||
print(verify_wheel_assets(args.wheel))
|
||||
except Exception as exc:
|
||||
print(f"[FAIL] bundled assets: {type(exc).__name__}: {exc}")
|
||||
exit_code = 1
|
||||
|
||||
if args.runtime:
|
||||
video = args.video or default_verification_video(REPO_ROOT)
|
||||
if video is None:
|
||||
print("[FAIL] runtime: no clip found; pass --video or set ORISYS_VERIFY_VIDEO")
|
||||
return 1
|
||||
try:
|
||||
info = verify_wheel_runtime(
|
||||
args.wheel,
|
||||
video=video,
|
||||
config_name=args.config,
|
||||
frames=args.frames,
|
||||
offline=args.offline,
|
||||
)
|
||||
print(f"[ok ] runtime: {info}")
|
||||
except Exception as exc:
|
||||
print(f"[FAIL] runtime: {type(exc).__name__}: {exc}")
|
||||
exit_code = 1
|
||||
|
||||
if args.contents or args.assets or args.runtime:
|
||||
return exit_code
|
||||
|
||||
# Default behaviour is unchanged: import smoke test in an isolated venv.
|
||||
verify_built_wheel(args.wheel)
|
||||
return 0
|
||||
|
||||
|
||||
+397
-13
@@ -1,11 +1,26 @@
|
||||
"""Isolated venv smoke tests for packaged orisys wheels."""
|
||||
"""Isolated venv smoke tests for packaged orisys wheels.
|
||||
|
||||
Three levels of confidence, all in a throwaway virtualenv so the active
|
||||
environment is never touched:
|
||||
|
||||
* :func:`verify_built_wheel` - the public API imports (fast, original check);
|
||||
* :func:`verify_wheel_assets` - bundled configs/sensors resolve and load via
|
||||
``importlib.resources``;
|
||||
* :func:`verify_wheel_runtime` - the compiled wheel actually processes real
|
||||
frames through the public pipeline with no camera attached.
|
||||
|
||||
Every level can be bootstrapped from another interpreter so each supported
|
||||
CPython ABI can be verified against its own wheel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import venv
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
# Public SDK surface that must import from a released wheel.
|
||||
@@ -38,6 +53,167 @@ assert Stitcher is not None
|
||||
print(f"orisys {orisys.__version__} public API imports OK")
|
||||
"""
|
||||
|
||||
# Bundled data that must be resolvable and loadable from inside site-packages.
|
||||
# The wheel ships the compiled package, so these paths only exist if the bundle
|
||||
# step actually ran for this build.
|
||||
_ASSET_SCRIPT = r"""
|
||||
import sys
|
||||
from importlib.metadata import distribution
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import orisys
|
||||
|
||||
# Locate bundled data through the installed distribution, NOT through
|
||||
# orisys.__file__ / importlib.resources. Nuitka compiles the package into one
|
||||
# extension module whose __init__ installs nuitka_module_loader, which reports a
|
||||
# synthetic __file__ (e.g. ...\site-packages\orisys\orisys\__init__.py) and a
|
||||
# synthetic __path__ ending in '\not_existing'. Neither points at the real data
|
||||
# directory, so importlib.resources silently finds nothing in a Nuitka wheel.
|
||||
dist = distribution("orisys")
|
||||
dist_root = Path(dist.locate_file("")).resolve()
|
||||
package_root = Path(dist.locate_file("orisys")).resolve()
|
||||
|
||||
_diagnosis = (
|
||||
f"orisys distribution resolved to {dist_root} (sys.prefix={sys.prefix!r}, "
|
||||
f"sys.executable={sys.executable!r}), outside the verification venv. A stale "
|
||||
"orisys in the parent environment is visible through --system-site-packages; "
|
||||
"run `pip uninstall -y orisys` there, or re-run without --offline."
|
||||
)
|
||||
assert str(Path(sys.prefix).resolve()) in str(dist_root), _diagnosis
|
||||
assert package_root.is_dir(), f"package directory not found: {package_root}"
|
||||
|
||||
required = [
|
||||
"configs/box.json",
|
||||
"configs/finger.json",
|
||||
"sensors/box/force_calibration.json",
|
||||
"sensors/box/sensor.json",
|
||||
"sensors/finger/manifest.json",
|
||||
"sensors/finger/sensor.json",
|
||||
"sensors/mini/sensor.json",
|
||||
"sensors/finger/assets/30fps-0703/mesh.npz",
|
||||
"sensors/finger/assets/30fps-0703/camera_intrinsics.npz",
|
||||
"sensors/finger/assets/30fps-0703/world_to_cam.npy",
|
||||
"sensors/finger/assets/visualization/mesh.npz",
|
||||
"sensors/finger/assets/visualization/world_to_cam.npy",
|
||||
]
|
||||
|
||||
missing = [name for name in required if not (package_root / name).is_file()]
|
||||
assert not missing, (
|
||||
f"bundled assets missing from the wheel: {missing}\n"
|
||||
f"package root: {package_root}\ninstalled tree: "
|
||||
f"{sorted(p.name for p in package_root.iterdir()) if package_root.is_dir() else '<missing>'}"
|
||||
)
|
||||
|
||||
for name in required:
|
||||
path = package_root / name
|
||||
if name.endswith(".npz"):
|
||||
data = np.load(str(path))
|
||||
assert len(data.files) > 0, f"{name} is an empty npz"
|
||||
elif name.endswith(".npy"):
|
||||
arr = np.load(str(path))
|
||||
assert arr.size > 0, f"{name} is an empty npy"
|
||||
|
||||
configs = orisys.list_config()
|
||||
assert configs, "orisys.list_config() returned nothing"
|
||||
|
||||
print(f"bundled assets OK ({len(required)} files, {len(configs)} configs) "
|
||||
f"from {package_root}")
|
||||
"""
|
||||
|
||||
# End-to-end pipeline run against a video file: no camera, no GUI, no imshow.
|
||||
_RUNTIME_SCRIPT = r"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from importlib.metadata import distribution
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
import orisys
|
||||
|
||||
# Provenance via the installed distribution: a Nuitka-compiled package reports a
|
||||
# synthetic orisys.__file__ (see the asset check) and a stale orisys visible
|
||||
# through --system-site-packages would otherwise satisfy this check.
|
||||
dist = distribution("orisys")
|
||||
dist_root = Path(dist.locate_file("")).resolve()
|
||||
assert str(Path(sys.prefix).resolve()) in str(dist_root), (
|
||||
f"the installed orisys came from {dist_root}, outside the verification venv "
|
||||
f"{sys.prefix!r}; run `pip uninstall -y orisys` in the parent environment or "
|
||||
"re-run without --offline."
|
||||
)
|
||||
|
||||
video, config_name, frames = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
||||
|
||||
sensor = orisys.Sensor(
|
||||
vid_src=video,
|
||||
config_name=config_name,
|
||||
backend="cpu",
|
||||
verbose=False,
|
||||
)
|
||||
processed = 0
|
||||
try:
|
||||
for _ in range(frames):
|
||||
img = sensor.get_img()
|
||||
assert img is not None, "get_img returned None"
|
||||
assert isinstance(img, np.ndarray) and img.ndim in (2, 3)
|
||||
|
||||
sensor.compute_deformation(check_motion=False)
|
||||
flow, vnormal, vshear = sensor.read_info(
|
||||
sensor.info.VRAW, sensor.info.VNORMAL, sensor.info.VSHEAR
|
||||
)
|
||||
for name, array in (("flow", flow), ("vnormal", vnormal), ("vshear", vshear)):
|
||||
assert np.isfinite(array).all(), f"{name} contains NaN/Inf"
|
||||
|
||||
contact = sensor.compute_contact()
|
||||
assert isinstance(contact, dict) and "centroid_found" in contact
|
||||
|
||||
fnormal, fsx, fsy = sensor.read_info(
|
||||
sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY
|
||||
)
|
||||
assert np.isfinite([fnormal, fsx, fsy]).all(), "force output contains NaN/Inf"
|
||||
processed += 1
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
assert processed == frames, f"only processed {processed}/{frames} frames"
|
||||
print(json.dumps({
|
||||
"version": orisys.__version__,
|
||||
"frames": processed,
|
||||
"flow_shape": list(flow.shape),
|
||||
"video": video,
|
||||
"config": config_name,
|
||||
"package_root": str(Path(dist.locate_file("orisys")).resolve()),
|
||||
}))
|
||||
"""
|
||||
|
||||
|
||||
# Default runtime-verification inputs. Overridable by CLI flags or environment.
|
||||
DEFAULT_RUNTIME_CONFIG = os.getenv("ORISYS_VERIFY_CONFIG", "box")
|
||||
# Clips tracked in the repository. ``data/sample/s1.mp4`` is the sanctioned
|
||||
# verification input; the larger 720p clip is deliberately not listed, so a
|
||||
# missing s1.mp4 fails loudly instead of silently verifying against it.
|
||||
DEFAULT_VIDEO_CANDIDATES = (
|
||||
Path("data") / "sample" / "s1.mp4",
|
||||
)
|
||||
|
||||
|
||||
def default_verification_video(repo_root: Path) -> Path | None:
|
||||
"""Resolve the clip used for the end-to-end runtime check, if any."""
|
||||
env_video = os.getenv("ORISYS_VERIFY_VIDEO")
|
||||
repo_root = Path(repo_root)
|
||||
if env_video:
|
||||
candidate = Path(env_video)
|
||||
return candidate if candidate.is_absolute() else repo_root / candidate
|
||||
for relative in DEFAULT_VIDEO_CANDIDATES:
|
||||
candidate = repo_root / relative
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _venv_python(venv_dir: Path) -> Path:
|
||||
if sys.platform == "win32":
|
||||
@@ -45,18 +221,226 @@ def _venv_python(venv_dir: Path) -> Path:
|
||||
return venv_dir / "bin" / "python"
|
||||
|
||||
|
||||
def verify_built_wheel(wheel_path: Path) -> None:
|
||||
"""Install the wheel in a temporary venv and smoke-test public imports."""
|
||||
wheel_path = wheel_path.resolve()
|
||||
with tempfile.TemporaryDirectory(prefix="orisys-wheel-verify-") as tmp:
|
||||
venv_dir = Path(tmp) / "venv"
|
||||
venv.create(venv_dir, with_pip=True)
|
||||
python = _venv_python(venv_dir)
|
||||
def _hermetic_env() -> dict:
|
||||
"""Environment for the verification venv with PYTHONPATH removed.
|
||||
|
||||
subprocess.check_call(
|
||||
[python, "-m", "pip", "install", "-q", "--upgrade", "pip"],
|
||||
An inherited PYTHONPATH=src would let the checkout satisfy the checks
|
||||
instead of the installed wheel, which is precisely the failure mode the
|
||||
provenance assertions in the scripts exist to catch.
|
||||
"""
|
||||
return {key: value for key, value in os.environ.items() if key != "PYTHONPATH"}
|
||||
|
||||
|
||||
def _create_venv(
|
||||
venv_dir: Path,
|
||||
*,
|
||||
python_executable: Path | None = None,
|
||||
system_site_packages: bool = False,
|
||||
) -> Path:
|
||||
"""Create a throwaway venv, optionally from a different interpreter."""
|
||||
if python_executable is None:
|
||||
venv.create(venv_dir, with_pip=True, system_site_packages=system_site_packages)
|
||||
else:
|
||||
command = [str(python_executable), "-m", "venv"]
|
||||
if system_site_packages:
|
||||
command.append("--system-site-packages")
|
||||
command.append(str(venv_dir))
|
||||
subprocess.check_call(command, env=_hermetic_env())
|
||||
return _venv_python(venv_dir)
|
||||
|
||||
|
||||
def _install_wheel(
|
||||
python: Path, wheel_path: Path, *, offline: bool = False, env: dict | None = None
|
||||
) -> None:
|
||||
"""Install the wheel under test, guaranteeing it lands in the venv.
|
||||
|
||||
``--force-reinstall --no-deps`` is not optional: pip reports a same-version
|
||||
requirement as "already satisfied" and skips the file, which is fatal here
|
||||
because ``--offline`` also exposes the parent environment's site-packages.
|
||||
Without it the venv check can silently validate a *different* orisys than
|
||||
the one passed in (the scripts additionally assert provenance).
|
||||
"""
|
||||
if offline:
|
||||
# Dependencies come from the parent environment via --system-site-packages.
|
||||
commands = [
|
||||
[
|
||||
str(python),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"-q",
|
||||
"--no-index",
|
||||
"--no-deps",
|
||||
"--force-reinstall",
|
||||
str(wheel_path),
|
||||
]
|
||||
]
|
||||
else:
|
||||
commands = [
|
||||
[str(python), "-m", "pip", "install", "-q", "--upgrade", "pip"],
|
||||
[str(python), "-m", "pip", "install", "-q", str(wheel_path)],
|
||||
[
|
||||
str(python),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"-q",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
str(wheel_path),
|
||||
],
|
||||
]
|
||||
|
||||
for command in commands:
|
||||
subprocess.check_call(command, stdout=subprocess.DEVNULL, env=env)
|
||||
|
||||
|
||||
class WheelVerificationError(RuntimeError):
|
||||
"""A verification step failed inside the throwaway venv.
|
||||
|
||||
Carries the child's stdout/stderr so the real traceback is visible instead of
|
||||
a bare non-zero exit code.
|
||||
"""
|
||||
|
||||
|
||||
def _run_script(
|
||||
python: Path, script: str, args: list[str] | None = None, *, env: dict
|
||||
) -> str:
|
||||
"""Run a verification script in an already-prepared venv."""
|
||||
completed = subprocess.run(
|
||||
[str(python), "-c", script, *(args or [])],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
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>")
|
||||
)
|
||||
subprocess.check_call(
|
||||
[python, "-m", "pip", "install", "-q", str(wheel_path)],
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _installed_venv(
|
||||
wheel_path: Path,
|
||||
*,
|
||||
python_executable: Path | None = None,
|
||||
offline: bool = False,
|
||||
prefix: str = "orisys-wheel-verify-",
|
||||
):
|
||||
"""Create a venv with the wheel installed, for one or more checks.
|
||||
|
||||
Creating a venv and installing the runtime dependencies is by far the most
|
||||
expensive part of verification (tens of seconds plus a large download when
|
||||
not using ``--offline``), so callers that run several checks must share one
|
||||
venv instead of building one per check.
|
||||
"""
|
||||
wheel_path = wheel_path.resolve()
|
||||
env = _hermetic_env()
|
||||
with tempfile.TemporaryDirectory(prefix=prefix) as tmp:
|
||||
venv_dir = Path(tmp) / "venv"
|
||||
interpreter = python_executable or Path(sys.executable)
|
||||
print(
|
||||
f" [{wheel_path.name}] creating venv from {interpreter.name}"
|
||||
+ ("" if offline else " and installing dependencies (first run downloads packages)")
|
||||
+ " ...",
|
||||
flush=True,
|
||||
)
|
||||
subprocess.check_call([python, "-c", _SMOKE_IMPORT_SCRIPT])
|
||||
python = _create_venv(
|
||||
venv_dir,
|
||||
python_executable=python_executable,
|
||||
system_site_packages=offline,
|
||||
)
|
||||
_install_wheel(python, wheel_path, offline=offline, env=env)
|
||||
yield python, env
|
||||
|
||||
|
||||
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."""
|
||||
with _installed_venv(wheel_path, python_executable=python_executable) as (python, env):
|
||||
_run_script(python, _SMOKE_IMPORT_SCRIPT, env=env)
|
||||
|
||||
|
||||
def verify_wheel_assets(wheel_path: Path, *, python_executable: Path | None = None) -> str:
|
||||
"""Assert the wheel carries loadable bundled configs and sensor assets."""
|
||||
with _installed_venv(wheel_path, python_executable=python_executable) as (python, env):
|
||||
return _run_script(python, _ASSET_SCRIPT, env=env)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
with _installed_venv(
|
||||
wheel_path, python_executable=python_executable, offline=offline
|
||||
) as (python, env):
|
||||
return _runtime_check(python, env, video=video, config_name=config_name, frames=frames)
|
||||
|
||||
|
||||
def _runtime_check(
|
||||
python: Path, env: dict, *, video: Path, config_name: str, frames: int
|
||||
) -> dict:
|
||||
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")
|
||||
|
||||
print(f" processing {frames} frame(s) with config {config_name!r} ...", flush=True)
|
||||
output = _run_script(
|
||||
python, _RUNTIME_SCRIPT, [str(video), config_name, str(frames)], env=env
|
||||
)
|
||||
import json
|
||||
|
||||
return json.loads(output.splitlines()[-1])
|
||||
|
||||
|
||||
def verify_wheel(
|
||||
wheel_path: Path,
|
||||
*,
|
||||
video: Path | None = None,
|
||||
config_name: str = DEFAULT_RUNTIME_CONFIG,
|
||||
frames: int = 40,
|
||||
python_executable: Path | None = None,
|
||||
offline: bool = False,
|
||||
check_assets: bool = True,
|
||||
) -> dict:
|
||||
"""Run every isolated-check level for one wheel inside a single venv.
|
||||
|
||||
Use this instead of calling the individual ``verify_wheel_*`` helpers: it
|
||||
pays the venv/dependency cost once and returns the runtime record plus the
|
||||
asset and import summaries.
|
||||
"""
|
||||
with _installed_venv(
|
||||
wheel_path, python_executable=python_executable, offline=offline
|
||||
) as (python, env):
|
||||
imports = _run_script(python, _SMOKE_IMPORT_SCRIPT, env=env)
|
||||
record: dict = {"wheel": Path(wheel_path).name, "imports": imports}
|
||||
if check_assets:
|
||||
record["assets"] = _run_script(python, _ASSET_SCRIPT, env=env)
|
||||
else:
|
||||
record["assets"] = "asset check skipped (--no-assets)"
|
||||
if video is not None:
|
||||
record.update(
|
||||
_runtime_check(
|
||||
python, env, video=video, config_name=config_name, frames=frames
|
||||
)
|
||||
)
|
||||
return record
|
||||
|
||||
@@ -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,398 @@
|
||||
"""PyPI credential setup: locate, inspect and write ``~/.pypirc``.
|
||||
|
||||
Editing the config from a command rather than by hand avoids the two mistakes
|
||||
that actually happen: putting something other than ``__token__`` in ``username``,
|
||||
and pasting a TestPyPI token into the ``[pypi]`` section (the two indexes reject
|
||||
each other's tokens with 403, and both token kinds start with ``pypi-``).
|
||||
|
||||
The writer does targeted text edits rather than rewriting through configparser,
|
||||
so the explanatory comments in the file survive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
INDEXES: dict[str, dict[str, str | None]] = {
|
||||
"pypi": {
|
||||
"upload_url": "https://upload.pypi.org/legacy/",
|
||||
"json_url": "https://pypi.org/pypi/orisys/json",
|
||||
"simple_url": "https://pypi.org/simple/",
|
||||
"token_url": "https://pypi.org/manage/account/token/",
|
||||
"register_url": None,
|
||||
},
|
||||
"testpypi": {
|
||||
"upload_url": "https://test.pypi.org/legacy/",
|
||||
"json_url": "https://test.pypi.org/pypi/orisys/json",
|
||||
"simple_url": "https://test.pypi.org/simple/",
|
||||
"token_url": "https://test.pypi.org/manage/account/token/",
|
||||
"register_url": "https://test.pypi.org/account/register/",
|
||||
},
|
||||
}
|
||||
|
||||
# Substrings that mark a token slot that was never filled in.
|
||||
PLACEHOLDER_MARKERS = ("PASTE", "REPLACE", "TOKEN-HERE", "YOUR-TOKEN", "XXXX")
|
||||
|
||||
DEFAULT_TEMPLATE = """\
|
||||
# PyPI / TestPyPI credentials for twine.
|
||||
#
|
||||
# Keep this file out of any git repository: a token committed once stays in the
|
||||
# history even after you delete it.
|
||||
#
|
||||
# * username is literally "__token__" - never an account name or email
|
||||
# * PyPI and TestPyPI tokens both start with "pypi-" but are NOT
|
||||
# interchangeable: each index rejects the other's with 403
|
||||
# * TestPyPI is a separate service with a separate account
|
||||
|
||||
[distutils]
|
||||
index-servers =
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredentialState:
|
||||
index: str
|
||||
configured: bool
|
||||
source: str | None = None
|
||||
problem: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TwineLookup:
|
||||
"""What twine's own resolver says about one index."""
|
||||
|
||||
available: bool
|
||||
known: bool
|
||||
config: dict = field(default_factory=dict)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def pypirc_path() -> Path:
|
||||
"""Where twine looks by default: ``~/.pypirc`` (same expansion twine uses)."""
|
||||
return Path(os.path.expanduser("~")) / ".pypirc"
|
||||
|
||||
|
||||
def looks_like_placeholder(token: str) -> bool:
|
||||
upper = token.upper()
|
||||
return any(marker in upper for marker in PLACEHOLDER_MARKERS)
|
||||
|
||||
|
||||
def parse_pypirc(text: str) -> dict[str, dict[str, str]]:
|
||||
"""Section -> key -> value, tolerating comments and continuation lines."""
|
||||
sections: dict[str, dict[str, str]] = {}
|
||||
current: str | None = None
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith(("#", ";", "%")):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
current = line[1:-1].strip()
|
||||
sections.setdefault(current, {})
|
||||
continue
|
||||
if current is not None and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
sections[current][key.strip()] = value.strip()
|
||||
return sections
|
||||
|
||||
|
||||
def credential_state(
|
||||
index: str,
|
||||
*,
|
||||
path: Path | None = None,
|
||||
env: dict | None = None,
|
||||
) -> CredentialState:
|
||||
"""Is ``index`` usable by twine, and why not when it is not.
|
||||
|
||||
Delegates to ``twine.utils.get_config`` when twine is importable, because the
|
||||
only definition that matters is twine's. The important subtlety that a naive
|
||||
check misses: **twine ignores any section not listed in
|
||||
``[distutils] index-servers``** (with a fallback of ``pypi testpypi`` when the
|
||||
key is absent). A ``[testpypi]`` section without that listing looks configured
|
||||
but twine reports "Missing 'testpypi' section".
|
||||
"""
|
||||
if index not in INDEXES:
|
||||
raise ValueError(f"unknown index {index!r}; expected one of {sorted(INDEXES)}")
|
||||
|
||||
env = os.environ if env is None else env
|
||||
if env.get("TWINE_PASSWORD"):
|
||||
return CredentialState(index, True, "environment variables")
|
||||
|
||||
path = path or pypirc_path()
|
||||
if not path.is_file():
|
||||
return CredentialState(index, False, None, f"{path} does not exist yet")
|
||||
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError as exc:
|
||||
return CredentialState(index, False, None, f"cannot read {path}: {exc}")
|
||||
|
||||
sections = parse_pypirc(text)
|
||||
if index not in sections and index not in index_servers(text):
|
||||
# Do not blame index-servers for a section that simply is not there.
|
||||
return CredentialState(index, False, None, f"{path} has no [{index}] section")
|
||||
|
||||
lookup = _twine_lookup(index, path)
|
||||
if lookup.error:
|
||||
return CredentialState(index, False, None, f"twine cannot read {path}: {lookup.error}")
|
||||
|
||||
if lookup.available:
|
||||
if not lookup.known:
|
||||
return CredentialState(index, False, None, _ignored_section(index, path, text, sections))
|
||||
username = str(lookup.config.get("username") or "").strip()
|
||||
password = str(lookup.config.get("password") or "").strip()
|
||||
if not password:
|
||||
# twine knows the index but resolved no password for it: almost always
|
||||
# a section that index-servers does not list.
|
||||
detail = _ignored_section(index, path, text, sections)
|
||||
return CredentialState(index, False, None, detail)
|
||||
else:
|
||||
# twine is not installed: replicate its rule locally.
|
||||
if index not in index_servers(text):
|
||||
return CredentialState(index, False, None, _ignored_section(index, path, text, sections))
|
||||
section = sections[index]
|
||||
username = section.get("username", "").strip()
|
||||
password = section.get("password", "").strip()
|
||||
|
||||
if looks_like_placeholder(password):
|
||||
return CredentialState(
|
||||
index, False, str(path), f"[{index}] still contains a placeholder token"
|
||||
)
|
||||
if username != "__token__":
|
||||
return CredentialState(
|
||||
index,
|
||||
False,
|
||||
str(path),
|
||||
f"[{index}] username is {username!r}; API tokens need the literal '__token__'",
|
||||
)
|
||||
return CredentialState(index, True, str(path))
|
||||
|
||||
|
||||
def _ignored_section(
|
||||
index: str, path: Path, text: str, sections: dict[str, dict[str, str]]
|
||||
) -> str:
|
||||
"""Name the real cause: absent section, or present but not listed by twine."""
|
||||
if index not in sections:
|
||||
return f"{path} has no [{index}] section"
|
||||
if index not in index_servers(text):
|
||||
return (
|
||||
f"[{index}] exists in {path} but is not listed in "
|
||||
"[distutils] index-servers, so twine ignores it entirely; "
|
||||
"fix with `release auth --repair`"
|
||||
)
|
||||
return f"twine resolves no password for [{index}] from {path}"
|
||||
|
||||
|
||||
def _twine_lookup(index: str, path: Path) -> TwineLookup:
|
||||
"""Ask twine itself; ``available=False`` when twine is not installed.
|
||||
|
||||
``get_config`` is not enough on its own: it always contains a ``pypi`` entry
|
||||
(and a ``testpypi`` one when listed) even when it never copied any
|
||||
credentials, so the resolved username/password are what matter.
|
||||
"""
|
||||
try:
|
||||
from twine import utils
|
||||
except ImportError:
|
||||
return TwineLookup(available=False, known=False)
|
||||
try:
|
||||
config = utils.get_config(str(path))
|
||||
except Exception as exc: # twine reports malformed files as exceptions
|
||||
return TwineLookup(available=True, known=False, error=f"{type(exc).__name__}: {exc}")
|
||||
section = config.get(index)
|
||||
if section is None:
|
||||
return TwineLookup(available=True, known=False)
|
||||
return TwineLookup(available=True, known=True, config=dict(section))
|
||||
|
||||
|
||||
def index_servers(text: str) -> set[str]:
|
||||
"""Names twine honours from ``[distutils] index-servers``.
|
||||
|
||||
Replicates ``twine.utils.get_config``, including its surprising asymmetry:
|
||||
|
||||
* key **absent** -> falls back to ``pypi testpypi``
|
||||
* key **present but empty** -> an empty list, so *no* section is honoured
|
||||
(the fallback does not apply), and every section looks configured while
|
||||
twine silently ignores it
|
||||
* continuation lines are part of the value, so ``index-servers =`` followed
|
||||
by indented names is the normal form
|
||||
"""
|
||||
parser = configparser.RawConfigParser()
|
||||
try:
|
||||
parser.read_string(text)
|
||||
except configparser.Error:
|
||||
return set()
|
||||
raw = parser.get("distutils", "index-servers", fallback="pypi testpypi")
|
||||
return set(raw.split())
|
||||
|
||||
|
||||
def validate_token(token: str) -> list[str]:
|
||||
"""Problems with a pasted token. Empty and whitespace are fatal; the rest warn."""
|
||||
return [problem for problem in _token_problems(token) if problem.startswith("ERROR")]
|
||||
|
||||
|
||||
def token_warnings(token: str) -> list[str]:
|
||||
return [problem for problem in _token_problems(token) if problem.startswith("WARN")]
|
||||
|
||||
|
||||
def _token_problems(token: str) -> list[str]:
|
||||
problems: list[str] = []
|
||||
if not token:
|
||||
problems.append("ERROR the token is empty")
|
||||
return problems
|
||||
if any(char.isspace() for char in token):
|
||||
problems.append("ERROR the token contains whitespace (copy/paste artefact?)")
|
||||
if not token.startswith("pypi-"):
|
||||
problems.append(
|
||||
"WARN PyPI API tokens start with 'pypi-'; a legacy account password also "
|
||||
"works but needs username != '__token__'"
|
||||
)
|
||||
if len(token) < 20:
|
||||
problems.append("WARN the token looks too short; check the paste was complete")
|
||||
return problems
|
||||
|
||||
|
||||
def unlisted_sections(path: Path | None = None) -> list[str]:
|
||||
"""Known indexes whose section exists but is missing from ``index-servers``."""
|
||||
path = path or pypirc_path()
|
||||
if not path.is_file():
|
||||
return []
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
present = set(parse_pypirc(text))
|
||||
listed = index_servers(text)
|
||||
return [index for index in sorted(INDEXES) if index in present and index not in listed]
|
||||
|
||||
|
||||
def repair_index_servers(path: Path | None = None) -> list[str]:
|
||||
"""List sections that exist but are not in ``index-servers``.
|
||||
|
||||
This is the state a hand-edited file lands in: the section is present, so it
|
||||
looks configured, but twine ignores it completely. Returns the indexes added.
|
||||
"""
|
||||
path = path or pypirc_path()
|
||||
if not path.is_file():
|
||||
return []
|
||||
text = path.read_text(encoding="utf-8")
|
||||
present = set(parse_pypirc(text))
|
||||
added: list[str] = []
|
||||
for index in sorted(INDEXES):
|
||||
if index in present and index not in index_servers(text):
|
||||
text = ensure_index_server(text, index)
|
||||
added.append(index)
|
||||
if added:
|
||||
path.write_text(text, encoding="utf-8", newline="")
|
||||
_restrict_permissions(path)
|
||||
return added
|
||||
|
||||
|
||||
def upsert_token(text: str, index: str, token: str) -> str:
|
||||
"""Set ``password`` inside ``[index]``, creating the section if needed."""
|
||||
lines = text.splitlines()
|
||||
start = next((i for i, line in enumerate(lines) if line.strip() == f"[{index}]"), None)
|
||||
|
||||
if start is None:
|
||||
if lines and lines[-1].strip():
|
||||
lines.append("")
|
||||
lines.extend(
|
||||
[
|
||||
f"[{index}]",
|
||||
f"repository = {INDEXES[index]['upload_url']}",
|
||||
"username = __token__",
|
||||
f"password = {token}",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
end = len(lines)
|
||||
for offset in range(start + 1, len(lines)):
|
||||
stripped = lines[offset].strip()
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
end = offset
|
||||
break
|
||||
|
||||
insert_at = end
|
||||
for offset in range(start + 1, end):
|
||||
stripped = lines[offset].strip()
|
||||
if stripped.startswith("password"):
|
||||
lines[offset] = f"password = {token}"
|
||||
return "\n".join(lines) + "\n"
|
||||
if stripped.startswith(("username", "repository")):
|
||||
insert_at = offset + 1
|
||||
lines.insert(insert_at, f"password = {token}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def ensure_index_server(text: str, index: str) -> str:
|
||||
"""Make sure ``[distutils] index-servers`` lists ``index``."""
|
||||
if index in index_servers(text):
|
||||
return text
|
||||
|
||||
lines = text.splitlines()
|
||||
if "[distutils]" not in (line.strip() for line in lines):
|
||||
block = ["[distutils]", "index-servers =", f" {index}", ""]
|
||||
return "\n".join(block + lines) + "\n"
|
||||
|
||||
start = next(i for i, line in enumerate(lines) if line.strip() == "[distutils]")
|
||||
for offset in range(start + 1, len(lines)):
|
||||
if lines[offset].strip().startswith("index-servers"):
|
||||
lines.insert(offset + 1, f" {index}")
|
||||
return "\n".join(lines) + "\n"
|
||||
lines.insert(start + 1, f" {index}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _restrict_permissions(path: Path) -> None:
|
||||
"""Best effort: owner-only. Windows ACLs need icacls, not chmod."""
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def write_token(index: str, token: str, *, path: Path | None = None) -> Path:
|
||||
"""Write ``token`` into ``[index]`` of the pypirc file, preserving comments."""
|
||||
if index not in INDEXES:
|
||||
raise ValueError(f"unknown index {index!r}; expected one of {sorted(INDEXES)}")
|
||||
|
||||
path = path or pypirc_path()
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
else:
|
||||
text = DEFAULT_TEMPLATE
|
||||
text = upsert_token(text, index, token)
|
||||
text = ensure_index_server(text, index)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8", newline="")
|
||||
_restrict_permissions(path)
|
||||
return path
|
||||
|
||||
|
||||
def prompt_for_token(index: str, *, prompt_fn=None, out=None) -> str | None:
|
||||
"""Ask for a token with hidden input. Returns ``None`` if input is unavailable."""
|
||||
out = out or sys.stdout
|
||||
meta = INDEXES[index]
|
||||
print(f"PyPI API token for [{index}]", file=out)
|
||||
print(f" create one at: {meta['token_url']}", file=out)
|
||||
if meta.get("register_url"):
|
||||
print(f" needs an account first: {meta['register_url']}", file=out)
|
||||
print(" input is hidden and is written only to your pypirc file", file=out)
|
||||
|
||||
ask = prompt_fn or getpass.getpass
|
||||
try:
|
||||
return ask("token: ").strip()
|
||||
except (EOFError, KeyboardInterrupt, OSError):
|
||||
# OSError covers environments where stdin cannot be read at all (pytest
|
||||
# capture, a detached CI stdin): treat that as "cannot prompt", never as a
|
||||
# crash.
|
||||
print("", file=out)
|
||||
print("aborted: no token entered", file=out)
|
||||
return None
|
||||
|
||||
|
||||
def read_token_from_stdin() -> str:
|
||||
"""For CI: read the token from stdin instead of a prompt or the command line."""
|
||||
return (sys.stdin.readline() or "").strip()
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Command line for ``python build/run.py release``.
|
||||
|
||||
Commands: ``preflight`` (local gate), ``audit`` (single wheel), ``auth`` (PyPI
|
||||
credentials in ~/.pypirc), ``github`` (sync the public repo + publish a GitHub
|
||||
Release), ``pypi`` (TestPyPI then PyPI). The publish commands are dry-run unless
|
||||
``--apply`` is passed, and tokens are never accepted as command-line arguments.
|
||||
"""
|
||||
|
||||
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,
|
||||
verify_wheel_assets,
|
||||
verify_wheel_runtime,
|
||||
)
|
||||
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
||||
from release import auth, github, pypi # 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="accept a version that a git tag already marks as released",
|
||||
)
|
||||
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 and args.runtime:
|
||||
# One venv for both checks: building it (and downloading dependencies in
|
||||
# online mode) is the expensive part.
|
||||
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:
|
||||
record = verify_wheel(
|
||||
wheel,
|
||||
video=video,
|
||||
config_name=args.config,
|
||||
frames=args.frames,
|
||||
python_executable=args.python,
|
||||
offline=args.offline,
|
||||
)
|
||||
print(f"[ok ] {record['imports']}")
|
||||
print(f"[ok ] {record['assets']}")
|
||||
print(f"[ok ] runtime: frames={record['frames']} flow={record['flow_shape']}")
|
||||
except Exception as exc:
|
||||
print(f"[FAIL] isolated verification: {type(exc).__name__}: {exc}")
|
||||
exit_code = 1
|
||||
return exit_code
|
||||
|
||||
if args.assets:
|
||||
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 _cmd_github(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="build/run.py release github",
|
||||
description="Sync orisys-sdk-release, tag, and publish a GitHub Release.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", required=True, help="release version, e.g. <major>.<minor>.<patch>"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="actually mutate the release repo, push, and create the release",
|
||||
)
|
||||
parser.add_argument("--notes-file", type=Path, default=None, help="release notes markdown")
|
||||
parser.add_argument(
|
||||
"--attach-preflight",
|
||||
action="store_true",
|
||||
help="also upload the preflight report as a release asset (contains local paths)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-dirty-release",
|
||||
action="store_true",
|
||||
help="proceed even if the release repo has uncommitted changes",
|
||||
)
|
||||
parser.add_argument("--skip-fetch", action="store_true", help="skip `git fetch --tags`")
|
||||
parser.add_argument(
|
||||
"--skip-checks", action="store_true", help="skip the manifest's downstream checks"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-release-upload",
|
||||
action="store_true",
|
||||
help="stop after pushing the tag and print the `gh release create` command",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
return github.publish(
|
||||
github.Options(
|
||||
version=args.version,
|
||||
apply=args.apply,
|
||||
notes_file=args.notes_file,
|
||||
attach_preflight=args.attach_preflight,
|
||||
allow_dirty_release=args.allow_dirty_release,
|
||||
skip_fetch=args.skip_fetch,
|
||||
skip_checks=args.skip_checks,
|
||||
skip_release_upload=args.skip_release_upload,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cmd_auth(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="build/run.py release auth",
|
||||
description=(
|
||||
"Inspect or set up PyPI credentials in ~/.pypirc. "
|
||||
"Tokens are read from a hidden prompt or stdin, never from argv."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--set-token",
|
||||
choices=sorted(auth.INDEXES),
|
||||
default=None,
|
||||
help="store a token for this index (prompts unless --token-stdin)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token-stdin",
|
||||
action="store_true",
|
||||
help="read the token from stdin instead of prompting (for scripts/CI)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repair",
|
||||
action="store_true",
|
||||
help=(
|
||||
"add existing sections to [distutils] index-servers; twine ignores "
|
||||
"sections that are not listed there"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--index",
|
||||
choices=sorted(auth.INDEXES),
|
||||
default="pypi",
|
||||
help="index to report on when not setting a token (default: pypi)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.repair:
|
||||
path = auth.pypirc_path()
|
||||
added = auth.repair_index_servers(path)
|
||||
if added:
|
||||
print(f"[ok ] added {added} to [distutils] index-servers in {path}")
|
||||
else:
|
||||
print(f"[ok ] nothing to repair in {path}")
|
||||
for index in sorted(auth.INDEXES):
|
||||
state = auth.credential_state(index)
|
||||
flag = "[ok ]" if state.configured else "[-- ]"
|
||||
detail = state.source if state.configured else state.problem
|
||||
print(f"{flag} [{index}] {detail}")
|
||||
return 0 if auth.credential_state(args.index).configured else 1
|
||||
|
||||
if args.set_token:
|
||||
if args.token_stdin:
|
||||
token = auth.read_token_from_stdin()
|
||||
else:
|
||||
token = auth.prompt_for_token(args.set_token)
|
||||
if not token:
|
||||
print("[FAIL] no token entered")
|
||||
return 1
|
||||
for problem in auth.validate_token(token):
|
||||
print(f"[FAIL] {problem.removeprefix('ERROR ')}")
|
||||
return 1
|
||||
for warning in auth.token_warnings(token):
|
||||
print(f"[warn] {warning.removeprefix('WARN ')}")
|
||||
path = auth.write_token(args.set_token, token)
|
||||
print(f"[ok ] wrote [{args.set_token}] credentials to {path}")
|
||||
state = auth.credential_state(args.set_token)
|
||||
if not state.configured:
|
||||
print(f"[FAIL] the file still does not validate: {state.problem}")
|
||||
return 1
|
||||
print(f"[ok ] [{args.set_token}] credentials verified ({state.source})")
|
||||
return 0
|
||||
|
||||
path = auth.pypirc_path()
|
||||
print(f"config file: {path} ({'exists' if path.is_file() else 'missing'})")
|
||||
for index in sorted(auth.INDEXES):
|
||||
state = auth.credential_state(index)
|
||||
if state.configured:
|
||||
print(f"[ok ] [{index}] configured ({state.source})")
|
||||
else:
|
||||
print(f"[-- ] [{index}] not usable: {state.problem}")
|
||||
|
||||
unlisted = auth.unlisted_sections(path)
|
||||
if unlisted:
|
||||
print()
|
||||
print(
|
||||
f"[warn] {unlisted} have sections that [distutils] index-servers does not "
|
||||
"list; twine ignores those entirely"
|
||||
)
|
||||
print(" fix with: python build/run.py release auth --repair")
|
||||
|
||||
print()
|
||||
print("set a token with:")
|
||||
for index in sorted(auth.INDEXES):
|
||||
if not auth.credential_state(index).configured:
|
||||
print(f" python build/run.py release auth --set-token {index}")
|
||||
print(" (TestPyPI needs its own account; if you only hold a production token,")
|
||||
print(" skip it and publish with `release pypi --pypi-only`)")
|
||||
|
||||
return 0 if auth.credential_state(args.index).configured else 1
|
||||
|
||||
|
||||
def _cmd_pypi(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="build/run.py release pypi",
|
||||
description="Upload the certified wheels to TestPyPI and then PyPI.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", required=True, help="release version, e.g. <major>.<minor>.<patch>"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="actually upload (uploads are permanent and cannot be overwritten)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--confirm-pypi",
|
||||
action="store_true",
|
||||
help="after TestPyPI, also publish to the real PyPI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pypi-only",
|
||||
action="store_true",
|
||||
help=(
|
||||
"upload straight to PyPI, skipping the TestPyPI rehearsal "
|
||||
"(for when you only hold a production token)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt-credentials",
|
||||
action="store_true",
|
||||
help="if a token is missing, prompt for it and store it in ~/.pypirc",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token-stdin",
|
||||
action="store_true",
|
||||
help="read a missing token from stdin instead of prompting (scripts/CI)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-install-verify",
|
||||
action="store_true",
|
||||
help="skip fetching the artifact back out of the index",
|
||||
)
|
||||
parser.add_argument("--skip-check", action="store_true", help="skip `twine check`")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.pypi_only and args.confirm_pypi:
|
||||
parser.error("--pypi-only and --confirm-pypi are mutually exclusive")
|
||||
|
||||
if args.token_stdin and not args.prompt_credentials:
|
||||
parser.error("--token-stdin requires --prompt-credentials")
|
||||
|
||||
token = auth.read_token_from_stdin() if args.token_stdin else None
|
||||
|
||||
return pypi.publish(
|
||||
pypi.Options(
|
||||
version=args.version,
|
||||
apply=args.apply,
|
||||
confirm_pypi=args.confirm_pypi,
|
||||
verify_install=not args.skip_install_verify,
|
||||
skip_check=args.skip_check,
|
||||
pypi_only=args.pypi_only,
|
||||
prompt_credentials=args.prompt_credentials,
|
||||
token=token,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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 | auth | github | pypi")
|
||||
return 0
|
||||
|
||||
command, rest = argv[0], argv[1:]
|
||||
if command == "preflight":
|
||||
return _cmd_preflight(rest)
|
||||
if command == "audit":
|
||||
return _cmd_audit(rest)
|
||||
if command == "auth":
|
||||
return _cmd_auth(rest)
|
||||
if command == "github":
|
||||
return _cmd_github(rest)
|
||||
if command == "pypi":
|
||||
return _cmd_pypi(rest)
|
||||
|
||||
print(f"Unknown release command: {command!r}", file=sys.stderr)
|
||||
print("Choose from: preflight, audit, auth, github, pypi", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Sync the public release repo and publish a GitHub Release.
|
||||
|
||||
``python build/run.py release github`` runs, in order:
|
||||
|
||||
1. certified-wheels gate (a passing ``release preflight`` report must exist and
|
||||
every wheel must still hash to what it recorded)
|
||||
2. refuse if the version is already tagged or already has a GitHub Release
|
||||
3. sync the open-source subset of this checkout into ``orisys-sdk-release``
|
||||
(``tools/sync_downstreams.py`` + the manifest's ``checks``)
|
||||
4. stage ``SDK/<version>/*.whl`` into ``orisys-sdk-release/release/<version>/``
|
||||
5. commit, tag ``v<version>``, push branch + tag
|
||||
6. create the GitHub Release with the wheels attached, then verify the assets
|
||||
|
||||
Nothing mutates anything unless ``--apply`` is passed: the default is a dry run
|
||||
that prints every command it would run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
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 release.publish import GateResult, PublishGateError, load_gate # noqa: E402
|
||||
|
||||
MANIFEST_PATH = REPO_ROOT / "tools" / "downstream_sync_manifest.json"
|
||||
SYNC_TOOL = REPO_ROOT / "tools" / "sync_downstreams.py"
|
||||
RELEASE_TARGET = "orisys-sdk-release"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Options:
|
||||
version: str
|
||||
apply: bool = False
|
||||
notes_file: Path | None = None
|
||||
attach_preflight: bool = False
|
||||
allow_dirty_release: bool = False
|
||||
skip_fetch: bool = False
|
||||
skip_checks: bool = False
|
||||
skip_release_upload: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandStep:
|
||||
description: str
|
||||
argv: list[str]
|
||||
cwd: Path | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Plan:
|
||||
release_repo: Path
|
||||
branch: str
|
||||
tag: str
|
||||
assets: list[Path] = field(default_factory=list)
|
||||
steps: list[CommandStep] = field(default_factory=list)
|
||||
|
||||
|
||||
def manifest_entry() -> dict:
|
||||
"""The manifest entry for the public release repo."""
|
||||
payload = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
for item in payload.get("downstreams", []):
|
||||
if item.get("name") == RELEASE_TARGET:
|
||||
return item
|
||||
raise PublishGateError(f"{MANIFEST_PATH} has no downstream named {RELEASE_TARGET!r}")
|
||||
|
||||
|
||||
def release_repo_path() -> Path:
|
||||
return (REPO_ROOT / manifest_entry()["path"]).resolve()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str, check: bool = True) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and completed.returncode != 0:
|
||||
raise PublishGateError(
|
||||
f"git {' '.join(args)} failed in {repo}:\n{completed.stderr.strip()}"
|
||||
)
|
||||
return (completed.stdout or "").strip()
|
||||
|
||||
|
||||
def build_release_notes(version: str, wheels: list[Path], git_commit: str | None) -> str:
|
||||
lines = [
|
||||
f"orisys {version}",
|
||||
"",
|
||||
"Wheels (Python 3.10 / 3.11 / 3.12, Windows x64 and Linux x86_64):",
|
||||
]
|
||||
lines.extend(f"- `{wheel.name}`" for wheel in wheels)
|
||||
lines.append("")
|
||||
if git_commit:
|
||||
lines.append(
|
||||
f"Built from `{git_commit[:12]}` and certified by `release preflight`."
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def build_github_steps(
|
||||
*,
|
||||
version: str,
|
||||
branch: str,
|
||||
tag: str,
|
||||
assets: list[Path],
|
||||
notes_file: Path | None,
|
||||
create_tag: bool = True,
|
||||
title: str | None = None,
|
||||
) -> list[CommandStep]:
|
||||
"""The mutating commands, as pure data so they can be asserted in tests.
|
||||
|
||||
``create_tag=False`` is the resume path: the tag already exists (a previous
|
||||
run pushed it before failing), so only the remaining steps are emitted.
|
||||
"""
|
||||
release_argv = [
|
||||
"gh",
|
||||
"release",
|
||||
"create",
|
||||
tag,
|
||||
"--verify-tag",
|
||||
"--title",
|
||||
title or f"v{version}",
|
||||
"--latest",
|
||||
]
|
||||
if notes_file is not None:
|
||||
release_argv += ["--notes-file", str(notes_file)]
|
||||
release_argv += [str(asset) for asset in assets]
|
||||
|
||||
steps: list[CommandStep] = []
|
||||
if create_tag:
|
||||
steps.append(CommandStep("create tag", ["git", "tag", tag]))
|
||||
steps.append(CommandStep("push branch", ["git", "push", "origin", branch]))
|
||||
steps.append(CommandStep("push tag", ["git", "push", "origin", tag]))
|
||||
steps.append(CommandStep("create GitHub Release with assets", release_argv))
|
||||
return steps
|
||||
|
||||
|
||||
def non_github_remote_problem(repo: Path) -> str | None:
|
||||
"""Why ``gh`` cannot target this repo, or ``None`` if the remote is fine.
|
||||
|
||||
``gh`` resolves its repository from the working directory. Running it from
|
||||
this checkout instead of the release repo is a real failure mode: the SDK
|
||||
repo's origin is an internal host, and ``gh`` reports the confusing "none of
|
||||
the git remotes ... point to a known GitHub host".
|
||||
"""
|
||||
remote = _git(repo, "remote", "get-url", "origin", check=False)
|
||||
if "github.com" in remote:
|
||||
return None
|
||||
return (
|
||||
f"the release repo's origin is {remote!r}, which is not a GitHub host. "
|
||||
"`gh` resolves the repository from the working directory, so point origin "
|
||||
"at github.com before publishing a release"
|
||||
)
|
||||
|
||||
|
||||
def _check_gh(repo: Path) -> list[str]:
|
||||
"""Problems that would stop the release step, reported before any mutation."""
|
||||
problems: list[str] = []
|
||||
if shutil.which("gh") is None:
|
||||
problems.append(
|
||||
"the `gh` CLI is not on PATH. Install it (`winget install --id GitHub.cli`) "
|
||||
"and authenticate (`gh auth login`), or use --skip-release-upload"
|
||||
)
|
||||
return problems
|
||||
|
||||
remote_problem = non_github_remote_problem(repo)
|
||||
if remote_problem:
|
||||
problems.append(remote_problem)
|
||||
|
||||
completed = subprocess.run(
|
||||
["gh", "auth", "status"], capture_output=True, text=True, check=False
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
problems.append("`gh auth status` failed; run `gh auth login` first")
|
||||
return problems
|
||||
|
||||
|
||||
def _existing_release_tags(repo: Path, version: str) -> list[str]:
|
||||
wanted = {version, f"v{version}"}
|
||||
tags = set(_git(repo, "tag", "--list").splitlines())
|
||||
return sorted(wanted & tags)
|
||||
|
||||
|
||||
def _release_exists(repo: Path, tag: str) -> bool | None:
|
||||
"""Is there already a GitHub Release for ``tag``?
|
||||
|
||||
Returns ``None`` when that cannot be determined (``gh`` unavailable), so the
|
||||
caller can distinguish "not published yet" from "unknown" instead of treating
|
||||
a missing tool as proof that nothing was published.
|
||||
|
||||
Must run with ``cwd`` set to the release repo: `gh` resolves the target
|
||||
repository from the working directory, and this checkout's own remote is an
|
||||
internal host.
|
||||
"""
|
||||
if shutil.which("gh") is None:
|
||||
return None
|
||||
completed = subprocess.run(
|
||||
["gh", "release", "view", tag, "--json", "tagName"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(repo),
|
||||
)
|
||||
return completed.returncode == 0
|
||||
|
||||
|
||||
def _sync(*, run_checks: bool) -> None:
|
||||
"""Copy the public subset into the release repo and run its checks."""
|
||||
python = sys.executable
|
||||
for command in (
|
||||
[python, str(SYNC_TOOL), "apply", "--target", RELEASE_TARGET],
|
||||
*(
|
||||
[[python, str(SYNC_TOOL), "run-checks", "--target", RELEASE_TARGET]]
|
||||
if run_checks
|
||||
else []
|
||||
),
|
||||
):
|
||||
completed = subprocess.run(command, cwd=REPO_ROOT, check=False)
|
||||
if completed.returncode != 0:
|
||||
raise PublishGateError(
|
||||
f"`{' '.join(command[1:])}` failed with exit {completed.returncode}"
|
||||
)
|
||||
|
||||
|
||||
def _stage_wheels(wheels: list[Path], asset_dir: Path) -> None:
|
||||
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||
for wheel in wheels:
|
||||
shutil.copy2(wheel, asset_dir / wheel.name)
|
||||
|
||||
|
||||
def publish(options: Options) -> int:
|
||||
version = options.version
|
||||
mode = "APPLY" if options.apply else "DRY RUN"
|
||||
print(f"=== release github: orisys {version} ({mode}) ===")
|
||||
|
||||
# --- 1. gate -------------------------------------------------------
|
||||
try:
|
||||
gate: GateResult = load_gate(REPO_ROOT, version)
|
||||
except PublishGateError as exc:
|
||||
print(f"[FAIL] {exc}")
|
||||
return 1
|
||||
print(f"[ok ] preflight report: {gate.report_path}")
|
||||
print(f" certified commit: {gate.git_commit}")
|
||||
for wheel in gate.wheels:
|
||||
print(f" [ok] {wheel.name} sha256={wheel.sha256[:12]} {wheel.size} bytes")
|
||||
for warning in gate.warnings:
|
||||
print(f"[warn] {warning}")
|
||||
|
||||
# --- 2. target repo ------------------------------------------------
|
||||
try:
|
||||
repo = release_repo_path()
|
||||
except PublishGateError as exc:
|
||||
print(f"[FAIL] {exc}")
|
||||
return 1
|
||||
if not (repo / ".git").exists():
|
||||
print(f"[FAIL] {repo} is not a git repository")
|
||||
return 1
|
||||
|
||||
try:
|
||||
if not options.skip_fetch:
|
||||
_git(repo, "fetch", "--tags", "--quiet")
|
||||
print(f"[ok ] git fetch --tags in {repo}")
|
||||
branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
dirty = _git(repo, "status", "--porcelain")
|
||||
except PublishGateError as exc:
|
||||
print(f"[FAIL] {exc}")
|
||||
return 1
|
||||
|
||||
if dirty and not options.allow_dirty_release:
|
||||
preview = "\n".join(dirty.splitlines()[:10])
|
||||
print(
|
||||
f"[FAIL] {repo} has uncommitted changes; commit or stash them first "
|
||||
f"(or pass --allow-dirty-release):\n{preview}"
|
||||
)
|
||||
return 1
|
||||
print(f"[ok ] release repo: {repo} ({branch}) clean={not dirty}")
|
||||
|
||||
tag = f"v{version}"
|
||||
existing = _existing_release_tags(repo, version)
|
||||
# A tag that exists without a GitHub Release means a previous run got as far
|
||||
# as pushing the tag and then failed (or the upload was left manual). That is
|
||||
# exactly the resumable state, so continue instead of refusing.
|
||||
create_tag = not existing
|
||||
if existing:
|
||||
print(
|
||||
f"[warn] tag(s) {existing} already exist in {repo}; skipping tag creation "
|
||||
"and continuing to the release upload"
|
||||
)
|
||||
release_state = _release_exists(repo, tag)
|
||||
if release_state is True:
|
||||
print(f"[FAIL] GitHub Release {tag} already exists; nothing to publish")
|
||||
return 1
|
||||
if release_state is None:
|
||||
print(
|
||||
"[warn] cannot tell whether a GitHub Release for "
|
||||
f"{tag} already exists (`gh` not installed); --apply will stop at the gh check"
|
||||
)
|
||||
if create_tag:
|
||||
print(f"[ok ] tag {tag} does not exist in {repo}")
|
||||
elif create_tag:
|
||||
print(f"[ok ] no existing tag or GitHub Release for {tag}")
|
||||
else:
|
||||
print(f"[ok ] no GitHub Release for {tag} yet")
|
||||
|
||||
# A dry run is a preview of what would happen, so missing tooling is a warning
|
||||
# there and a hard stop only when we are about to mutate something.
|
||||
if options.skip_release_upload:
|
||||
print("[warn] --skip-release-upload: the GitHub Release will be left to you")
|
||||
else:
|
||||
gh_problems = _check_gh(repo)
|
||||
if gh_problems:
|
||||
for problem in gh_problems:
|
||||
print(f"[{'FAIL' if options.apply else 'warn'}] {problem}")
|
||||
if options.apply:
|
||||
return 1
|
||||
else:
|
||||
print("[ok ] gh CLI is installed and authenticated")
|
||||
|
||||
# --- 3. plan the mutating steps ------------------------------------
|
||||
wheels = [REPO_ROOT / "SDK" / version / record.name for record in gate.wheels]
|
||||
missing = [str(wheel) for wheel in wheels if not wheel.is_file()]
|
||||
if missing:
|
||||
print(f"[FAIL] wheels missing from disk: {missing}")
|
||||
return 1
|
||||
|
||||
asset_dir = repo / "release" / version
|
||||
assets = [asset_dir / wheel.name for wheel in wheels]
|
||||
notes_file = options.notes_file
|
||||
steps = build_github_steps(
|
||||
version=version,
|
||||
branch=branch,
|
||||
tag=tag,
|
||||
assets=assets,
|
||||
notes_file=notes_file,
|
||||
create_tag=create_tag,
|
||||
)
|
||||
|
||||
print()
|
||||
print(f"[plan] sync {len(manifest_entry().get('sync', []))} entry(ies) into {repo}")
|
||||
print(f"[plan] stage {len(wheels)} wheel(s) into {asset_dir}")
|
||||
if create_tag:
|
||||
print(f"[plan] commit {repo} and create tag {tag}")
|
||||
else:
|
||||
print(f"[plan] commit {repo} (tag {tag} already exists)")
|
||||
for step in steps:
|
||||
manual = options.skip_release_upload and step.description.startswith(
|
||||
"create GitHub Release"
|
||||
)
|
||||
prefix = "[plan] (left manual)" if manual else "[plan]"
|
||||
print(f"{prefix} {step.description}: {' '.join(step.argv)}")
|
||||
print()
|
||||
|
||||
if not options.apply:
|
||||
print("dry run: nothing was changed. Re-run with --apply to execute.")
|
||||
return 0
|
||||
|
||||
# --- 4. execute ----------------------------------------------------
|
||||
try:
|
||||
commit_before = _git(repo, "rev-parse", "HEAD")
|
||||
_sync(run_checks=not options.skip_checks)
|
||||
_stage_wheels(wheels, asset_dir)
|
||||
|
||||
if options.attach_preflight:
|
||||
shutil.copy2(gate.report_path, asset_dir / f"{version}-preflight.json")
|
||||
|
||||
_git(repo, "add", "-A")
|
||||
pending = _git(repo, "status", "--porcelain")
|
||||
if pending:
|
||||
_git(repo, "commit", "-m", f"release {version}")
|
||||
print(f"[ok ] committed {len(pending.splitlines())} change(s) in {repo}")
|
||||
else:
|
||||
print("[ok ] nothing new to commit in the release repo")
|
||||
|
||||
if create_tag:
|
||||
_git(repo, "tag", tag)
|
||||
# Pushing an already-up-to-date ref is a no-op, so resuming is safe.
|
||||
_git(repo, "push", "origin", branch)
|
||||
_git(repo, "push", "origin", tag)
|
||||
print(f"[ok ] pushed {branch} and {tag}")
|
||||
except PublishGateError as exc:
|
||||
print(f"[FAIL] {exc}")
|
||||
return 1
|
||||
|
||||
release_argv = next(
|
||||
step.argv for step in steps if step.description.startswith("create GitHub Release")
|
||||
)
|
||||
|
||||
if options.skip_release_upload:
|
||||
print()
|
||||
print("[todo] publish the release yourself:")
|
||||
print(" " + " ".join(release_argv))
|
||||
print()
|
||||
print("=== release github: repo synced and tagged; release upload left manual ===")
|
||||
return 0
|
||||
|
||||
# `gh` resolves its target repository from the working directory, so it must
|
||||
# run inside the release repo - this checkout's own origin is an internal host.
|
||||
completed = subprocess.run(release_argv, check=False, cwd=str(repo))
|
||||
if completed.returncode != 0:
|
||||
print(f"[FAIL] gh release create exited {completed.returncode}")
|
||||
return 1
|
||||
print(f"[ok ] GitHub Release {tag} created")
|
||||
|
||||
# --- 5. verify the published assets --------------------------------
|
||||
completed = subprocess.run(
|
||||
["gh", "release", "view", tag, "--json", "assets,tagName"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(repo),
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
print("[FAIL] could not read back the release to verify assets")
|
||||
return 1
|
||||
payload = json.loads(completed.stdout or "{}")
|
||||
published = {asset["name"] for asset in payload.get("assets", [])}
|
||||
expected = {asset.name for asset in assets}
|
||||
if options.attach_preflight:
|
||||
expected.add(f"{version}-preflight.json")
|
||||
if published != expected:
|
||||
print(
|
||||
f"[FAIL] release asset mismatch:\n expected {sorted(expected)}\n"
|
||||
f" published {sorted(published)}"
|
||||
)
|
||||
return 1
|
||||
print(f"[ok ] verified {len(published)} asset(s) on the release")
|
||||
print()
|
||||
print(f"=== release github PASSED (commit {commit_before[:12]} -> {_git(repo, 'rev-parse', 'HEAD')[:12]}) ===")
|
||||
return 0
|
||||
@@ -0,0 +1,570 @@
|
||||
"""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. ``version-strings`` - no stale version literal left in tracked prose
|
||||
4. ``duplicate-guard`` - already-released version (git tag, optionally PyPI)
|
||||
5. ``tests`` - pytest (slow/hardware markers excluded)
|
||||
6. ``bundle-precheck`` - every bundle item resolves; required assets present
|
||||
7. ``wheel-inspection`` - static metadata/content assertions per wheel
|
||||
8. ``wheel-runtime`` - one isolated venv per wheel: bundled assets load and
|
||||
real frames flow through the public pipeline
|
||||
|
||||
Output lands in ``build/release/reports/<version>/preflight.{json,txt}``, and is
|
||||
the contract the publish commands (``release github`` / ``release pypi``) consume:
|
||||
they refuse to ship anything this run did not certify.
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
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:
|
||||
# Not version-shaped on purpose: this is a sentinel for "unresolved",
|
||||
# and it must not look like a release version to the version-string scan.
|
||||
self.version = "unknown"
|
||||
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:
|
||||
"""Fail only when this version has already been *released*.
|
||||
|
||||
A populated ``SDK/<version>/`` is the normal input to preflight (it is
|
||||
what gets verified), so it is reported, not blocked. What must not happen
|
||||
silently is re-releasing a version that is already out: that is detected
|
||||
offline through git tags and, opt-in, against PyPI.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
lines: list[str] = []
|
||||
|
||||
existing = version_mod.sdk_wheels(self.repo_root, self.version)
|
||||
if existing:
|
||||
lines.append(
|
||||
f"SDK/{self.version}/ holds {len(existing)} staged wheel(s) to verify: "
|
||||
f"{[w.name for w in existing]}"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f"SDK/{self.version}/ is empty; nothing is staged yet "
|
||||
"(build with `python build/run.py wheel-win` / `manylinux`)"
|
||||
)
|
||||
|
||||
tags = version_mod.git_tags(self.repo_root)
|
||||
tag_hits = version_mod.released_tags_for(self.version, tags)
|
||||
if tag_hits:
|
||||
message = f"git tag(s) {tag_hits} already mark {self.version} as released"
|
||||
if self.options.force:
|
||||
lines.append(message + " (accepted via --force)")
|
||||
else:
|
||||
problems.append(
|
||||
message + "; pass --force to re-verify or intentionally re-release"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f"no local git tag marks {self.version} as released "
|
||||
f"({len(tags)} tag(s) checked in this repo and {version_mod.RELEASE_REPO_DIRNAME})"
|
||||
)
|
||||
|
||||
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: " + (", ".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 _version_strings(self) -> Step:
|
||||
"""Keep the version out of prose: one source, everything else derived.
|
||||
|
||||
Scans tracked text for version-looking literals that disagree with the
|
||||
release version. Dependency constraints are ignored by design; deliberate
|
||||
exceptions live in ``version.VERSION_STRING_ALLOWLIST``.
|
||||
"""
|
||||
findings = version_mod.find_version_literals(self.repo_root, self.version)
|
||||
if not findings:
|
||||
scanned = len(version_mod.tracked_text_files(self.repo_root))
|
||||
return Step(
|
||||
"version-strings",
|
||||
True,
|
||||
f"no stale version literal in {scanned} scanned file(s); "
|
||||
f"version {self.version} lives only where it is derived from",
|
||||
)
|
||||
|
||||
by_file: dict[str, list[str]] = {}
|
||||
for path, lineno, literal in findings:
|
||||
by_file.setdefault(path, []).append(f"{path}:{lineno} -> {literal}")
|
||||
detail = (
|
||||
f"{len(findings)} version literal(s) disagree with {self.version}. "
|
||||
"Update them, or make the file version-agnostic, or add a documented "
|
||||
"entry to build/release/version.py::VERSION_STRING_ALLOWLIST:\n"
|
||||
+ "\n".join(line for lines in by_file.values() for line in lines)
|
||||
)
|
||||
return Step("version-strings", False, detail, data={"count": len(findings)})
|
||||
|
||||
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:
|
||||
# One venv per wheel: it runs the import smoke test, the bundled
|
||||
# asset check and the frame pipeline together.
|
||||
record = verify_wheel(
|
||||
wheel,
|
||||
video=video,
|
||||
config_name=self.options.config_name,
|
||||
frames=self.options.frames,
|
||||
python_executable=executable,
|
||||
offline=self.options.offline,
|
||||
check_assets=not self.options.no_assets,
|
||||
)
|
||||
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}: {record['assets']} | frames={record['frames']} "
|
||||
f"flow={record['flow_shape']}"
|
||||
)
|
||||
self.runtime_records.append({"tag": tag, **record})
|
||||
|
||||
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._version_strings,
|
||||
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,158 @@
|
||||
"""Shared publish gate: certify a release from its preflight report.
|
||||
|
||||
Both publish commands (``release github`` and ``release pypi``) refuse to act
|
||||
unless a passing ``release preflight`` report exists for the exact version and
|
||||
every wheel still hashes to what that report recorded. The report is the contract
|
||||
between "we verified this" and "we shipped this"; re-deriving the checks here
|
||||
would just be a second, weaker opinion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
# Options that weaken the gate. A release must not be certified by a run that
|
||||
# waived any of them: re-run preflight on a clean commit instead.
|
||||
WAIVED_GUARD_KEYS = ("skip_tests", "allow_dirty", "force", "no_runtime", "no_assets")
|
||||
|
||||
|
||||
class PublishGateError(RuntimeError):
|
||||
"""The preflight report does not certify these wheels."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WheelRecord:
|
||||
name: str
|
||||
sha256: str
|
||||
size: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GateResult:
|
||||
version: str
|
||||
git_commit: str | None
|
||||
report_path: Path
|
||||
wheels: list[WheelRecord] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
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 evaluate_gate(
|
||||
payload: dict,
|
||||
version: str,
|
||||
wheels: dict[str, Path],
|
||||
*,
|
||||
head_commit: str | None = None,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Return ``(problems, warnings)`` for a report payload and the wheels on disk.
|
||||
|
||||
Pure function: no I/O beyond hashing the wheels, so it is cheap to test.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if str(payload.get("version")) != version:
|
||||
problems.append(
|
||||
f"report is for version {payload.get('version')!r}, not {version!r}"
|
||||
)
|
||||
if not payload.get("ok"):
|
||||
problems.append(
|
||||
"report result is FAIL - run `release preflight --keep-going` and fix "
|
||||
"everything before publishing"
|
||||
)
|
||||
if payload.get("aborted"):
|
||||
problems.append("report stopped early (aborted); it certifies nothing")
|
||||
|
||||
options = payload.get("options") or {}
|
||||
waived = sorted(key for key in WAIVED_GUARD_KEYS if options.get(key))
|
||||
if waived:
|
||||
problems.append(
|
||||
"report waived gate options "
|
||||
+ ", ".join(f"--{key.replace('_', '-')}" for key in waived)
|
||||
+ "; commit the tree and re-run `release preflight` without them"
|
||||
)
|
||||
|
||||
recorded = {item.get("name"): item.get("sha256") for item in payload.get("wheels") or []}
|
||||
if not recorded:
|
||||
problems.append("report lists no wheels")
|
||||
|
||||
missing = sorted(set(recorded) - set(wheels))
|
||||
if missing:
|
||||
problems.append(f"wheels in the report are missing from disk: {missing}")
|
||||
extra = sorted(set(wheels) - set(recorded))
|
||||
if extra:
|
||||
problems.append(f"wheels on disk are not covered by the report: {extra}")
|
||||
|
||||
for name in sorted(set(recorded) & set(wheels)):
|
||||
actual = sha256_file(wheels[name])
|
||||
if actual != recorded[name]:
|
||||
problems.append(
|
||||
f"{name} changed after preflight: report {recorded[name][:12]}, "
|
||||
f"disk {actual[:12]} - rebuild and re-run preflight"
|
||||
)
|
||||
|
||||
commit = payload.get("git", {}).get("commit") if isinstance(payload.get("git"), dict) else None
|
||||
if head_commit and commit and commit != head_commit:
|
||||
warnings.append(
|
||||
f"report certifies commit {commit[:12]} but HEAD is {head_commit[:12]}; "
|
||||
"the wheels are unchanged, so this is usually just a later commit"
|
||||
)
|
||||
return problems, warnings
|
||||
|
||||
|
||||
def load_gate(repo_root: Path, version: str) -> GateResult:
|
||||
"""Load and enforce the preflight report for ``version``.
|
||||
|
||||
Raises :class:`PublishGateError` with every problem found, so one run reports
|
||||
all of them instead of stopping at the first.
|
||||
"""
|
||||
repo_root = Path(repo_root)
|
||||
report_path = repo_root / "build" / "release" / "reports" / version / "preflight.json"
|
||||
if not report_path.is_file():
|
||||
raise PublishGateError(
|
||||
f"no preflight report at {report_path}. Run:\n"
|
||||
f" python build/run.py release preflight\n"
|
||||
f"(the publish commands only ship what preflight certified)"
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise PublishGateError(f"cannot read {report_path}: {exc}") from exc
|
||||
|
||||
wheel_dir = repo_root / "SDK" / version
|
||||
wheels = {path.name: path for path in sorted(wheel_dir.glob("*.whl"))}
|
||||
if not wheels:
|
||||
raise PublishGateError(
|
||||
f"no wheels in {wheel_dir}; build first (`python build/run.py wheel-win` / "
|
||||
"`manylinux`) and re-run preflight"
|
||||
)
|
||||
|
||||
problems, warnings = evaluate_gate(payload, version, wheels)
|
||||
if problems:
|
||||
raise PublishGateError(
|
||||
f"the preflight report at {report_path} does not certify orisys {version}:\n "
|
||||
+ "\n ".join(problems)
|
||||
)
|
||||
|
||||
git_payload = payload.get("git") if isinstance(payload.get("git"), dict) else {}
|
||||
return GateResult(
|
||||
version=version,
|
||||
git_commit=git_payload.get("commit"),
|
||||
report_path=report_path,
|
||||
wheels=[
|
||||
WheelRecord(name=name, sha256=sha256_file(path), size=path.stat().st_size)
|
||||
for name, path in wheels.items()
|
||||
],
|
||||
warnings=warnings,
|
||||
)
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Upload the certified wheels to TestPyPI, then (on confirmation) to PyPI.
|
||||
|
||||
``python build/run.py release pypi`` runs:
|
||||
|
||||
1. certified-wheels gate (same contract as ``release github``)
|
||||
2. ``twine check`` on every wheel - the wheel metadata is generated, so its
|
||||
long description and classifiers are worth validating before an upload that
|
||||
cannot be undone
|
||||
3. refuse if the version already exists on the target index (read-only probe)
|
||||
4. upload to TestPyPI, then verify by fetching it back from the index
|
||||
5. stop there unless ``--confirm-pypi`` is given; then repeat 3-4 against PyPI
|
||||
|
||||
PyPI uploads are permanent: a version number can never be reused, and a bad
|
||||
artifact cannot be replaced. Hence the TestPyPI stage, the read-only existence
|
||||
probe, and ``--apply`` being required even to reach TestPyPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
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 release import auth # noqa: E402
|
||||
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
|
||||
|
||||
INDEX_JSON = {name: str(meta["json_url"]) for name, meta in auth.INDEXES.items()}
|
||||
INDEX_SIMPLE = {name: str(meta["simple_url"]) for name, meta in auth.INDEXES.items()}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Options:
|
||||
version: str
|
||||
apply: bool = False
|
||||
confirm_pypi: bool = False
|
||||
verify_install: bool = True
|
||||
skip_check: bool = False
|
||||
# Upload straight to PyPI, skipping the TestPyPI rehearsal. Needed when you
|
||||
# only hold a production token, since the two indexes reject each other's.
|
||||
pypi_only: bool = False
|
||||
# Offer to enter a missing token now instead of just failing.
|
||||
prompt_credentials: bool = False
|
||||
# Token supplied non-interactively (e.g. --token-stdin), used once.
|
||||
token: str | None = None
|
||||
|
||||
|
||||
def upload_phases(options: Options) -> list[str]:
|
||||
"""The indexes to upload to, in order."""
|
||||
if options.pypi_only:
|
||||
return ["pypi"]
|
||||
return ["testpypi", "pypi"] if options.confirm_pypi else ["testpypi"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexState:
|
||||
versions: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def known(self) -> bool:
|
||||
return self.error is None
|
||||
|
||||
|
||||
def twine_check_argv(wheels: list[Path]) -> list[str]:
|
||||
return [sys.executable, "-m", "twine", "check", *[str(w) for w in wheels]]
|
||||
|
||||
|
||||
def twine_upload_argv(wheels: list[Path], repository: str | None) -> list[str]:
|
||||
argv = [sys.executable, "-m", "twine", "upload"]
|
||||
if repository:
|
||||
argv += ["--repository", repository]
|
||||
argv += [str(wheel) for wheel in wheels]
|
||||
return argv
|
||||
|
||||
|
||||
def credentials_hint(index: str) -> str | None:
|
||||
"""Guidance when ``index`` has no usable credentials, else ``None``.
|
||||
|
||||
More than "does the file exist": a placeholder or the wrong ``username`` is
|
||||
treated as missing, because twine would only find out at upload time.
|
||||
"""
|
||||
state = auth.credential_state(index)
|
||||
if state.configured:
|
||||
return None
|
||||
lines = [f"no usable credentials for [{index}]: {state.problem}"]
|
||||
lines.append(f" set it up with: python build/run.py release auth --set-token {index}")
|
||||
if index == "testpypi":
|
||||
lines.append(
|
||||
" (TestPyPI needs its own account and token; if you only hold a "
|
||||
"production token, use --pypi-only to skip the rehearsal)"
|
||||
)
|
||||
lines.append(
|
||||
" or set TWINE_USERNAME=__token__ and TWINE_PASSWORD for this session only"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _ensure_credentials(
|
||||
indexes: list[str], options: Options, *, interactive: bool | None = None
|
||||
) -> bool:
|
||||
"""Make sure every index that will be uploaded to has credentials.
|
||||
|
||||
Prompts for a missing token when there is a terminal to prompt on; a piped or
|
||||
CI run fails fast instead of hanging on a hidden-input prompt.
|
||||
"""
|
||||
missing = [index for index in indexes if not auth.credential_state(index).configured]
|
||||
if not missing:
|
||||
for index in indexes:
|
||||
state = auth.credential_state(index)
|
||||
print(f"[ok ] credentials for [{index}] ({state.source})")
|
||||
return True
|
||||
|
||||
if interactive is None:
|
||||
interactive = sys.stdin.isatty()
|
||||
allow_prompt = options.prompt_credentials or (interactive and options.apply)
|
||||
|
||||
if not allow_prompt:
|
||||
for index in missing:
|
||||
print(f"[{'FAIL' if options.apply else 'warn'}] {credentials_hint(index)}")
|
||||
if not options.apply:
|
||||
print(
|
||||
"[info] dry run continues so you can review the plan; "
|
||||
"--apply would stop here until the credentials exist"
|
||||
)
|
||||
return True
|
||||
if not interactive:
|
||||
print(
|
||||
"[info] stdin is not a terminal, so nothing was prompted; run "
|
||||
"`release auth --set-token <index>` or pass --prompt-credentials"
|
||||
)
|
||||
return False
|
||||
|
||||
for index in missing:
|
||||
print(f"[warn] {credentials_hint(index)}")
|
||||
token = options.token or auth.prompt_for_token(index)
|
||||
if not token:
|
||||
print(f"[FAIL] no token entered for [{index}]")
|
||||
return False
|
||||
for problem in auth.validate_token(token):
|
||||
print(f"[FAIL] {problem.removeprefix('ERROR ')}")
|
||||
return False
|
||||
for warning in auth.token_warnings(token):
|
||||
print(f"[warn] {warning.removeprefix('WARN ')}")
|
||||
path = auth.write_token(index, token)
|
||||
print(f"[ok ] wrote [{index}] credentials to {path}")
|
||||
options.token = None # only the first index may consume a piped token
|
||||
return True
|
||||
|
||||
|
||||
def index_state(index: str, *, timeout: float = 20.0) -> IndexState:
|
||||
"""Read the published versions of orisys from an index (read-only)."""
|
||||
url = INDEX_JSON[index]
|
||||
request = urllib.request.Request(
|
||||
url, headers={"User-Agent": "orisys-release-publish", "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 IndexState(versions=[])
|
||||
return IndexState(error=f"{index} returned HTTP {exc.code}")
|
||||
except Exception as exc: # network, DNS, TLS, JSON
|
||||
return IndexState(error=f"{index} lookup failed: {type(exc).__name__}: {exc}")
|
||||
|
||||
releases = payload.get("releases")
|
||||
if not isinstance(releases, dict):
|
||||
return IndexState(error=f"unexpected {index} response (no 'releases' mapping)")
|
||||
return IndexState(
|
||||
versions=sorted(name for name, files in releases.items() if files)
|
||||
)
|
||||
|
||||
|
||||
def index_files(
|
||||
index: str, version: str, *, timeout: float = 20.0
|
||||
) -> tuple[list[str] | None, str | None]:
|
||||
"""Filenames published for ``version``, as ``(filenames, error)``.
|
||||
|
||||
``(None, None)`` - the index does not serve this version (yet)
|
||||
``(None, "why")`` - the lookup itself failed, which is NOT evidence of absence
|
||||
``([...], None)`` - the version is published with those files
|
||||
|
||||
Keeping those apart matters: reporting a network hiccup as "not published
|
||||
yet" is how a successful upload gets mistaken for a failure.
|
||||
"""
|
||||
url = INDEX_JSON[index]
|
||||
request = urllib.request.Request(
|
||||
url, headers={"User-Agent": "orisys-release-publish", "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, None
|
||||
return None, f"{index} returned HTTP {exc.code}"
|
||||
except Exception as exc:
|
||||
return None, f"{index} lookup failed: {type(exc).__name__}: {exc}"
|
||||
|
||||
files = (payload.get("releases") or {}).get(version)
|
||||
if not isinstance(files, list) or not files:
|
||||
return None, None
|
||||
return sorted(str(item.get("filename", "")) for item in files), None
|
||||
|
||||
|
||||
def wait_for_release(
|
||||
index: str,
|
||||
version: str,
|
||||
*,
|
||||
attempts: int = 6,
|
||||
delay: float = 5.0,
|
||||
sleep=None,
|
||||
timeout: float = 20.0,
|
||||
) -> tuple[list[str] | None, str | None]:
|
||||
"""Poll until the index serves ``version`` (uploads are not instantly visible).
|
||||
|
||||
TestPyPI in particular lags the upload response by seconds. Returns the last
|
||||
``(filenames, error)`` observed.
|
||||
"""
|
||||
sleep = sleep or time.sleep
|
||||
last_error: str | None = None
|
||||
for attempt in range(1, max(1, attempts) + 1):
|
||||
filenames, error = index_files(index, version, timeout=timeout)
|
||||
if filenames is not None:
|
||||
return filenames, None
|
||||
last_error = error
|
||||
if attempt < attempts:
|
||||
print(
|
||||
f"[info] {index} does not serve {version} yet "
|
||||
f"({'lookup failed' if error else 'indexing'}), retry {attempt}/{attempts - 1}",
|
||||
flush=True,
|
||||
)
|
||||
sleep(delay)
|
||||
return None, last_error
|
||||
|
||||
|
||||
def existing_release_state(index: str, version: str, expected: set[str]) -> str:
|
||||
"""``"satisfied"`` | ``"conflict"`` | ``"absent"`` for this index and version.
|
||||
|
||||
An index version can never be replaced, so "already published with exactly
|
||||
these files" is the only successful terminal state; treating it as an error
|
||||
would make a re-run after a partial failure look like a new failure. A
|
||||
different file set is a genuine conflict and must stop the run.
|
||||
"""
|
||||
published, error = index_files(index, version)
|
||||
if published is None:
|
||||
if error:
|
||||
print(f"[warn] could not check {index} for an existing release: {error}")
|
||||
return "absent"
|
||||
if set(published) == expected:
|
||||
print(
|
||||
f"[ok ] {index} already serves orisys {version} with all "
|
||||
f"{len(published)} file(s)"
|
||||
)
|
||||
return "satisfied"
|
||||
extra = sorted(set(published) - expected)
|
||||
missing = sorted(expected - set(published))
|
||||
print(
|
||||
f"[FAIL] {index} already serves orisys {version} with a different file set"
|
||||
+ (f"; unexpected: {extra}" if extra else "")
|
||||
+ (f"; missing: {missing}" if missing else "")
|
||||
)
|
||||
return "conflict"
|
||||
|
||||
|
||||
def _upload(wheels: list[Path], repository: str | None) -> int:
|
||||
argv = twine_upload_argv(wheels, repository)
|
||||
print(f"[run ] {' '.join(str(part) for part in argv)}")
|
||||
completed = subprocess.run(argv, check=False)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
def _fetch_back(index: str, version: str, wheel: Path) -> int:
|
||||
"""Download the freshly uploaded wheel back out of the index."""
|
||||
with tempfile.TemporaryDirectory(prefix="orisys-fetch-") as tmp:
|
||||
target = Path(tmp) / "out"
|
||||
argv = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"download",
|
||||
"--no-deps",
|
||||
"--no-cache-dir",
|
||||
"--index-url",
|
||||
INDEX_SIMPLE[index],
|
||||
"--dest",
|
||||
str(target),
|
||||
f"orisys=={version}",
|
||||
]
|
||||
if index == "testpypi":
|
||||
# TestPyPI mirrors only what was uploaded there; deps live on PyPI.
|
||||
# --no-deps keeps this a pure "is the artifact fetchable" probe.
|
||||
argv += ["--extra-index-url", INDEX_SIMPLE["pypi"]]
|
||||
print(f"[run ] {' '.join(argv)}")
|
||||
completed = subprocess.run(argv, check=False)
|
||||
if completed.returncode != 0:
|
||||
return completed.returncode
|
||||
|
||||
fetched = sorted(target.glob(f"orisys-{version}*"))
|
||||
if not fetched:
|
||||
print(f"[FAIL] pip download produced nothing for orisys=={version}")
|
||||
return 1
|
||||
print(f"[ok ] fetched {[p.name for p in fetched]} from {index}")
|
||||
if wheel.name not in {path.name for path in fetched}:
|
||||
print(
|
||||
f"[warn] {wheel.name} was not the file fetched "
|
||||
"(the index may serve a different platform first)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def publish(options: Options) -> int:
|
||||
version = options.version
|
||||
mode = "APPLY" if options.apply else "DRY RUN"
|
||||
print(f"=== release pypi: orisys {version} ({mode}) ===")
|
||||
|
||||
# --- 1. gate -------------------------------------------------------
|
||||
try:
|
||||
gate: GateResult = load_gate(REPO_ROOT, version)
|
||||
except PublishGateError as exc:
|
||||
print(f"[FAIL] {exc}")
|
||||
return 1
|
||||
print(f"[ok ] preflight report: {gate.report_path}")
|
||||
for wheel in gate.wheels:
|
||||
print(f" [ok] {wheel.name} sha256={wheel.sha256[:12]}")
|
||||
for warning in gate.warnings:
|
||||
print(f"[warn] {warning}")
|
||||
|
||||
wheels = [REPO_ROOT / "SDK" / version / record.name for record in gate.wheels]
|
||||
missing = [str(wheel) for wheel in wheels if not wheel.is_file()]
|
||||
if missing:
|
||||
print(f"[FAIL] wheels missing from disk: {missing}")
|
||||
return 1
|
||||
|
||||
# --- 2. credentials + twine check (before anything irreversible) ----
|
||||
phases = upload_phases(options)
|
||||
if options.pypi_only:
|
||||
print(
|
||||
"[warn] --pypi-only: uploading straight to PyPI with no TestPyPI "
|
||||
"rehearsal. An index version can never be reused or replaced."
|
||||
)
|
||||
if not _ensure_credentials(phases, options):
|
||||
return 1
|
||||
|
||||
if not options.skip_check:
|
||||
argv = twine_check_argv(wheels)
|
||||
print(f"[run ] {' '.join(str(part) for part in argv)}")
|
||||
completed = subprocess.run(argv, check=False)
|
||||
if completed.returncode != 0:
|
||||
print("[FAIL] twine check reported problems; fix the wheel metadata first")
|
||||
return 1
|
||||
print("[ok ] twine check passed")
|
||||
|
||||
# --- 3. phase plan -------------------------------------------------
|
||||
expected = {wheel.name for wheel in wheels}
|
||||
remaining: list[str] = []
|
||||
for index in phases:
|
||||
state = existing_release_state(index, version, expected)
|
||||
if state == "conflict":
|
||||
return 1
|
||||
if state == "absent":
|
||||
remaining.append(index)
|
||||
phases = remaining
|
||||
|
||||
print()
|
||||
for index in phases:
|
||||
state = index_state(index)
|
||||
if not state.known:
|
||||
print(f"[FAIL] {state.error}; cannot safely check for duplicate uploads")
|
||||
return 1
|
||||
if version in state.versions:
|
||||
print(
|
||||
f"[FAIL] orisys {version} already exists on {index} "
|
||||
f"(published: {', '.join(state.versions) or 'none'}) - "
|
||||
"an index version cannot be reused"
|
||||
)
|
||||
return 1
|
||||
print(f"[ok ] {index}: {version} not present (published: {', '.join(state.versions) or 'none'})")
|
||||
|
||||
if not phases:
|
||||
print()
|
||||
print("=== release pypi: every requested index already has this release ===")
|
||||
return 0
|
||||
|
||||
print()
|
||||
for index in phases:
|
||||
print(f"[plan] upload {len(wheels)} wheel(s) to {index}")
|
||||
if phases == ["testpypi"]:
|
||||
print(
|
||||
"[plan] stop after TestPyPI. Re-run with "
|
||||
"`--confirm-pypi --apply` to publish to the real PyPI."
|
||||
)
|
||||
print()
|
||||
|
||||
if not options.apply:
|
||||
print("dry run: nothing was uploaded. Re-run with --apply to execute.")
|
||||
return 0
|
||||
|
||||
# --- 4. upload ----------------------------------------------------
|
||||
for index in phases:
|
||||
if _upload(wheels, index) != 0:
|
||||
print(f"[FAIL] twine upload to {index} failed")
|
||||
return 1
|
||||
print(f"[ok ] uploaded to {index}")
|
||||
|
||||
published, error = wait_for_release(index, version)
|
||||
if published is None:
|
||||
# The upload itself succeeded, so this is a confirmation problem, not
|
||||
# a publishing failure. Say so plainly and do not imply data loss.
|
||||
reason = f"lookup kept failing ({error})" if error else "the index has not indexed it yet"
|
||||
print(
|
||||
f"[warn] uploaded, but could not confirm it on {index} yet: {reason}.\n"
|
||||
f" check https://{'test.' if index == 'testpypi' else ''}pypi.org/project/orisys/{version}/"
|
||||
"\n re-running this command is safe: an already-published version is skipped"
|
||||
)
|
||||
print(f"\n=== release pypi: {index} upload SUCCEEDED (verification inconclusive) ===")
|
||||
return 0
|
||||
if set(published) != expected:
|
||||
print(
|
||||
f"[FAIL] {index} file set mismatch:\n expected {sorted(expected)}\n"
|
||||
f" got {sorted(published)}"
|
||||
)
|
||||
return 1
|
||||
print(f"[ok ] {index} serves all {len(published)} file(s) for {version}")
|
||||
|
||||
if options.verify_install:
|
||||
if _fetch_back(index, version, wheels[0]) != 0:
|
||||
print(
|
||||
f"[warn] {index} artifact is not fetchable yet; the upload is "
|
||||
"published, so this is a propagation delay rather than a failure"
|
||||
)
|
||||
else:
|
||||
print(f"[ok ] fetched the published artifact back from {index}")
|
||||
|
||||
if phases == ["testpypi"]:
|
||||
print()
|
||||
print("=== release pypi: TestPyPI stage PASSED; PyPI untouched ===")
|
||||
print("next: python build/run.py release pypi --confirm-pypi --apply")
|
||||
return 0
|
||||
|
||||
print()
|
||||
if options.pypi_only:
|
||||
print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
|
||||
else:
|
||||
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
|
||||
return 0
|
||||
@@ -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,272 @@
|
||||
"""Version consistency and duplicate-release guards for the release gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
PYPI_JSON_URL = "https://pypi.org/pypi/orisys/json"
|
||||
|
||||
# Sibling repository that carries the public GitHub releases and their tags.
|
||||
RELEASE_REPO_DIRNAME = "orisys-sdk-release"
|
||||
|
||||
# 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 git_tags(repo_root: Path) -> list[str]:
|
||||
"""Local git tags from this repo and the sibling release repo.
|
||||
|
||||
Tags are the only offline evidence that a version was already released
|
||||
(``v0.5.1`` in ``orisys-sdk-release`` marks a published GitHub release), so
|
||||
the duplicate guard works without network access.
|
||||
"""
|
||||
roots = [Path(repo_root)]
|
||||
sibling = Path(repo_root).parent / RELEASE_REPO_DIRNAME
|
||||
if sibling.is_dir():
|
||||
roots.append(sibling)
|
||||
|
||||
tags: set[str] = set()
|
||||
for root in roots:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(root), "tag", "--list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
if completed.returncode != 0:
|
||||
continue
|
||||
tags.update(
|
||||
line.strip() for line in (completed.stdout or "").splitlines() if line.strip()
|
||||
)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
def released_tags_for(version: str, tags: list[str]) -> list[str]:
|
||||
"""Tag spellings that mark ``version`` as already released."""
|
||||
return sorted({version, f"v{version}"} & set(tags))
|
||||
|
||||
|
||||
# Version literals that are deliberately not the package version. Keyed by
|
||||
# repo-relative path; each entry documents *why* the literal may stay.
|
||||
VERSION_STRING_ALLOWLIST: dict[str, frozenset[str]] = {
|
||||
# Historical release records: rewriting these would destroy the audit trail.
|
||||
"docs/publishing.md": frozenset({"0.4.2", "0.4.3", "0.4.4", "0.5.0", "0.5.1", "0.5.2"}),
|
||||
# Unrelated third-party/CUDA versions quoted in prose.
|
||||
"docs/get_started.md": frozenset({"12.9.0"}),
|
||||
}
|
||||
|
||||
# Paths never scanned: tests deliberately use synthetic versions, reports are
|
||||
# generated output, and this module's own allowlist/sentinel literals would
|
||||
# otherwise flag themselves.
|
||||
VERSION_SCAN_EXCLUDED_PREFIXES = (
|
||||
"tests/",
|
||||
"build/release/reports/",
|
||||
"build/nuitka/",
|
||||
"build/lib/",
|
||||
"dist/",
|
||||
"SDK/",
|
||||
"data/",
|
||||
)
|
||||
VERSION_SCAN_EXCLUDED_FILES = ("build/release/version.py",)
|
||||
|
||||
_VERSION_LITERAL_RE = re.compile(
|
||||
r"(?<![<>=!~\w.])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?)(?![0-9A-Za-z.])"
|
||||
)
|
||||
_SCANNED_SUFFIXES = (".md", ".py", ".txt", ".toml", ".cfg", ".ini", ".rst")
|
||||
|
||||
|
||||
def tracked_text_files(repo_root: Path) -> list[Path]:
|
||||
"""Tracked files worth scanning for version literals."""
|
||||
repo_root = Path(repo_root)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(repo_root), "ls-files", "-z"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
names = [n for n in (completed.stdout or "").split("\0") if n]
|
||||
except OSError:
|
||||
names = []
|
||||
|
||||
if not names:
|
||||
names = [
|
||||
str(path.relative_to(repo_root)).replace("\\", "/")
|
||||
for path in repo_root.rglob("*")
|
||||
if path.is_file()
|
||||
]
|
||||
|
||||
selected = []
|
||||
for name in names:
|
||||
if name.startswith(VERSION_SCAN_EXCLUDED_PREFIXES):
|
||||
continue
|
||||
if name in VERSION_SCAN_EXCLUDED_FILES:
|
||||
continue
|
||||
if not name.endswith(_SCANNED_SUFFIXES):
|
||||
continue
|
||||
selected.append(Path(name))
|
||||
return selected
|
||||
|
||||
|
||||
def find_version_literals(
|
||||
repo_root: Path, version: str
|
||||
) -> list[tuple[str, int, str]]:
|
||||
"""``(path, lineno, literal)`` for every version-looking string != ``version``.
|
||||
|
||||
Dependency constraints are skipped by the regex lookbehind (``numpy>=1.24.4``
|
||||
is not a version reference), and historical/third-party literals require an
|
||||
explicit entry in :data:`VERSION_STRING_ALLOWLIST`.
|
||||
"""
|
||||
repo_root = Path(repo_root)
|
||||
findings: list[tuple[str, int, str]] = []
|
||||
for relative in tracked_text_files(repo_root):
|
||||
absolute = repo_root / relative
|
||||
try:
|
||||
text = absolute.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
if "\0" in text:
|
||||
continue
|
||||
allowed = VERSION_STRING_ALLOWLIST.get(relative.as_posix(), frozenset())
|
||||
for lineno, line in enumerate(text.splitlines(), start=1):
|
||||
for match in _VERSION_LITERAL_RE.finditer(line):
|
||||
literal = match.group(1)
|
||||
if literal == version or literal in allowed:
|
||||
continue
|
||||
findings.append((relative.as_posix(), lineno, literal))
|
||||
return findings
|
||||
|
||||
|
||||
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 verify [wheel.whl]
|
||||
python build/run.py verify --gui-dist [--product ory_finger]
|
||||
python build/run.py release preflight [--version V]
|
||||
python build/run.py release audit <wheel.whl>
|
||||
|
||||
See build/README.md for details.
|
||||
"""
|
||||
@@ -74,15 +76,23 @@ def _cmd_verify(argv: list[str]) -> int:
|
||||
return verify_main(argv)
|
||||
|
||||
|
||||
def _cmd_release(argv: list[str]) -> int:
|
||||
from release.cli import main as release_main
|
||||
|
||||
return release_main(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
print("Commands: wheel | wheel-win | manylinux | gui | verify")
|
||||
print("Commands: wheel | wheel-win | manylinux | gui | verify | release")
|
||||
print("Examples:")
|
||||
print(" python build/run.py wheel --help")
|
||||
print(" python build/run.py gui finger")
|
||||
print(" python build/run.py manylinux -PythonVersions 3.10")
|
||||
print(" python build/run.py release preflight --version <version>")
|
||||
print(" python build/run.py release audit SDK\\<version>\\orisys-<version>-cp312-cp312-win_amd64.whl")
|
||||
return 0
|
||||
|
||||
command, rest = argv[0], argv[1:]
|
||||
@@ -92,10 +102,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"wheel-manylinux": _cmd_manylinux,
|
||||
"gui": _cmd_gui,
|
||||
"verify": _cmd_verify,
|
||||
"release": _cmd_release,
|
||||
}
|
||||
if command not in dispatch:
|
||||
print(f"Unknown command: {command!r}", file=sys.stderr)
|
||||
print("Choose from: wheel, wheel-win, manylinux, gui, verify", file=sys.stderr)
|
||||
print(
|
||||
"Choose from: wheel, wheel-win, manylinux, gui, verify, release",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
return dispatch[command](rest)
|
||||
|
||||
|
||||
+16
-5
@@ -30,7 +30,13 @@ if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repaired_wheel_dir, repo_path
|
||||
from common.pyproject_utils import bundle_items, extras_require, get_version, install_requires
|
||||
from common.pyproject_utils import (
|
||||
bundle_items,
|
||||
extras_require,
|
||||
get_version,
|
||||
install_requires,
|
||||
project_metadata,
|
||||
)
|
||||
from common.verify.wheel import verify_built_wheel
|
||||
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
|
||||
from wheel.layout import (
|
||||
@@ -41,6 +47,7 @@ from wheel.layout import (
|
||||
from wheel.package import (
|
||||
is_compliant_manylinux_wheel,
|
||||
publish_wheel_to_sdk,
|
||||
read_long_description,
|
||||
repair_linux_wheel,
|
||||
run_bdist_wheel,
|
||||
write_wheel_setup,
|
||||
@@ -111,10 +118,12 @@ def build(*, target: str = "internal", verify: bool | None = None) -> Path:
|
||||
# Bundle rules live in pyproject.toml → [[tool.orisys.bundle.items]]
|
||||
bundle = bundle_items(REPO_ROOT)
|
||||
copied_counts = copy_bundle_items(REPO_ROOT, pkg_dir, bundle)
|
||||
print(f"Copied {copied_counts['configs']} JSON config files")
|
||||
print(f"Copied {copied_counts['examples']} example files to orisys/examples/")
|
||||
print(f"Copied {copied_counts['docs']} doc files to orisys/docs/")
|
||||
print(f"Copied {copied_counts['sensors']} sensor bundle files to orisys/sensors/")
|
||||
for item in bundle:
|
||||
destination = f"orisys/{item['target']}/"
|
||||
print(
|
||||
f"Copied {copied_counts.get(item['name'], 0)} file(s) "
|
||||
f"for bundle item {item['name']!r} to {destination}"
|
||||
)
|
||||
|
||||
write_wheel_setup(
|
||||
NUITKA_WHEEL_DIR,
|
||||
@@ -122,6 +131,8 @@ def build(*, target: str = "internal", verify: bool | None = None) -> Path:
|
||||
package_data=package_data_from_bundle(ext_suffix, bundle),
|
||||
install_requires=install_requires(REPO_ROOT),
|
||||
extras_require=extras_require(REPO_ROOT),
|
||||
project=project_metadata(REPO_ROOT),
|
||||
long_description=read_long_description(REPO_ROOT),
|
||||
)
|
||||
|
||||
# --- 3. Build wheel, repair on Linux, publish to SDK/ ---
|
||||
|
||||
@@ -32,12 +32,20 @@ NUITKA_NOFOLLOW_IMPORTS = (
|
||||
"cv2",
|
||||
"numba",
|
||||
"orisys_blend",
|
||||
# Optional accelerators / GUIs: imported behind try/except, resolved from the
|
||||
# user's environment at runtime. Listing them here is not optional - without
|
||||
# it, Nuitka follows whatever happens to be installed in the *build*
|
||||
# environment, which blows up compile time and makes wheel contents depend on
|
||||
# ambient packages. tests/test_nuitka_nofollow.py enforces full coverage.
|
||||
"cupy",
|
||||
"cupyx",
|
||||
"pyopencl",
|
||||
"pyvkfft",
|
||||
"open3d",
|
||||
"PyQt5",
|
||||
"pyqtgraph",
|
||||
"torch",
|
||||
"torchvision",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,23 +4,51 @@
|
||||
FROM quay.io/pypa/manylinux_2_28_x86_64:latest
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_ROOT_USER_ACTION=ignore
|
||||
PIP_ROOT_USER_ACTION=ignore \
|
||||
ORISYS_WHEELHOUSE_ROOT=/opt/orisys-wheelhouse
|
||||
|
||||
# Only install compile/repair tooling here. Runtime deps (numpy/scipy/opencv/numba)
|
||||
# are declared in the wheel metadata and installed by pip when users install orisys.
|
||||
# Nuitka is configured with --nofollow-import-to for those packages.
|
||||
RUN for PYBIN in \
|
||||
/opt/python/cp310-cp310/bin \
|
||||
/opt/python/cp311-cp311/bin \
|
||||
/opt/python/cp312-cp312/bin; do \
|
||||
echo "=== Installing build tooling into ${PYBIN} ===" && \
|
||||
"${PYBIN}/python" -m pip install --upgrade pip setuptools wheel && \
|
||||
"${PYBIN}/python" -m pip install \
|
||||
"nuitka>=2.0" \
|
||||
ordered-set \
|
||||
zstandard \
|
||||
auditwheel; \
|
||||
done
|
||||
# Per-ABI build tooling plus a per-ABI wheelhouse of every package the release
|
||||
# flow installs at build time:
|
||||
#
|
||||
# * build tooling - nuitka/auditwheel etc. baked in, so the entrypoint does
|
||||
# not have to re-resolve them from PyPI on every container start
|
||||
# (ORISYS_UPDATE_BUILD_TOOLS=1 opts back in to that).
|
||||
# * wheelhouse - `build/wheel/build.py --target pypi-linux` force-enables
|
||||
# verification, which creates a throwaway venv and pip-installs the built
|
||||
# wheel plus its declared runtime deps. Runtime deps are deliberately NOT
|
||||
# installed into the image interpreter (users get them from the wheel
|
||||
# metadata), so without a wheelhouse every build re-downloaded
|
||||
# numpy/scipy/opencv/numba — roughly 200 MB, three times over, once per ABI.
|
||||
# The entrypoint points pip at the wheelhouse with
|
||||
# PIP_NO_INDEX/PIP_FIND_LINKS, which keeps the venv isolated and still
|
||||
# exercises real dependency resolution, just without the network.
|
||||
#
|
||||
# --only-binary=:all: makes a missing wheel a loud image-build failure instead of
|
||||
# a slow source build inside the container.
|
||||
RUN set -eux; \
|
||||
for TAG in cp310-cp310 cp311-cp311 cp312-cp312; do \
|
||||
PYBIN="/opt/python/${TAG}/bin"; \
|
||||
echo "=== build tooling for ${TAG} ==="; \
|
||||
"${PYBIN}/python" -m pip install --upgrade pip setuptools wheel; \
|
||||
"${PYBIN}/python" -m pip install \
|
||||
"nuitka>=2.0" \
|
||||
ordered-set \
|
||||
zstandard \
|
||||
auditwheel; \
|
||||
echo "=== wheelhouse for ${TAG} ==="; \
|
||||
"${PYBIN}/python" -m pip download \
|
||||
--only-binary=:all: \
|
||||
--dest "${ORISYS_WHEELHOUSE_ROOT}/${TAG}" \
|
||||
pip \
|
||||
setuptools \
|
||||
wheel \
|
||||
auditwheel \
|
||||
numpy \
|
||||
scipy \
|
||||
opencv-python \
|
||||
numba; \
|
||||
done; \
|
||||
du -sh "${ORISYS_WHEELHOUSE_ROOT}"/*
|
||||
|
||||
WORKDIR /src
|
||||
ENTRYPOINT ["/bin/bash", "/src/build/wheel/docker/entrypoint.sh"]
|
||||
|
||||
@@ -6,12 +6,18 @@ set -euo pipefail
|
||||
TARGET="${ORISYS_BUILD_TARGET:-pypi-linux}"
|
||||
# Comma-separated manylinux tags, e.g. "cp310-cp310,cp311-cp311,cp312-cp312"
|
||||
PYTHON_TAGS="${ORISYS_PYTHON_TAGS:-cp310-cp310,cp311-cp311,cp312-cp312}"
|
||||
WHEELHOUSE_ROOT="${ORISYS_WHEELHOUSE_ROOT:-/opt/orisys-wheelhouse}"
|
||||
# The image already bakes in pip/setuptools/wheel/nuitka/auditwheel. Re-resolving
|
||||
# them from PyPI on every container start costs a network round-trip per ABI and
|
||||
# lets the running container drift from the image it was built from.
|
||||
UPDATE_BUILD_TOOLS="${ORISYS_UPDATE_BUILD_TOOLS:-0}"
|
||||
|
||||
cd /src
|
||||
|
||||
echo "=== manylinux entrypoint ==="
|
||||
echo "Target: ${TARGET}"
|
||||
echo "Python tags: ${PYTHON_TAGS}"
|
||||
echo "Wheelhouse root: ${WHEELHOUSE_ROOT}"
|
||||
uname -a
|
||||
|
||||
IFS=',' read -r -a TAG_ARRAY <<< "${PYTHON_TAGS}"
|
||||
@@ -40,7 +46,27 @@ for tag in "${TAG_ARRAY[@]}"; do
|
||||
export PATH="${PYBIN}:${PATH}"
|
||||
export PYTHONPATH="/src/src${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
"${PYBIN}/python" -m pip install -q --upgrade pip setuptools wheel auditwheel
|
||||
# Point pip at the per-ABI wheelhouse baked into the image. The pypi-linux
|
||||
# target force-enables verification, which creates a throwaway venv and
|
||||
# pip-installs the built wheel plus its declared runtime deps; serving those
|
||||
# from the wheelhouse keeps the venv isolated (no --system-site-packages) while
|
||||
# removing a ~200 MB download per ABI. PIP_* is inherited by every pip
|
||||
# subprocess, including that venv.
|
||||
wheelhouse="${WHEELHOUSE_ROOT}/${tag}"
|
||||
if [[ -d "${wheelhouse}" ]]; then
|
||||
export PIP_NO_INDEX=1
|
||||
export PIP_FIND_LINKS="${wheelhouse}"
|
||||
echo "[info] pip is offline, installing from ${wheelhouse}"
|
||||
else
|
||||
echo "[warn] no wheelhouse at ${wheelhouse}; pip will use the network." >&2
|
||||
echo "[warn] rebuild the image without -SkipImageBuild to bake it in." >&2
|
||||
unset PIP_NO_INDEX PIP_FIND_LINKS || true
|
||||
fi
|
||||
|
||||
if [[ "${UPDATE_BUILD_TOOLS}" == "1" ]]; then
|
||||
echo "[info] refreshing build tooling (ORISYS_UPDATE_BUILD_TOOLS=1)"
|
||||
"${PYBIN}/python" -m pip install -q --upgrade pip setuptools wheel auditwheel
|
||||
fi
|
||||
|
||||
if "${PYBIN}/python" build/wheel/build.py --target "${TARGET}"; then
|
||||
BUILT+=("${tag}")
|
||||
@@ -48,6 +74,8 @@ for tag in "${TAG_ARRAY[@]}"; do
|
||||
echo "[ERROR] Build failed for ${tag}" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
unset PIP_NO_INDEX PIP_FIND_LINKS || true
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
+83
-3
@@ -19,6 +19,76 @@ _MANYLINUX_TAG_RE = re.compile(
|
||||
r"(?:manylinux2014|manylinux_2_(?:17|24|27|28|31|34|35))_x86_64"
|
||||
)
|
||||
|
||||
# Source of the PyPI project description (see read_long_description).
|
||||
PYPI_README_RELPATH = Path("docs") / "pypi_readme.md"
|
||||
|
||||
|
||||
def _license_text(project: dict) -> str | None:
|
||||
"""Extract free-text license from either pyproject license form."""
|
||||
declared = project.get("license")
|
||||
if isinstance(declared, str):
|
||||
return declared
|
||||
if isinstance(declared, dict):
|
||||
text = declared.get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _person_strings(entries) -> list[str]:
|
||||
"""Render PEP 621 author/maintainer tables as ``Name <email>`` strings."""
|
||||
rendered: list[str] = []
|
||||
for entry in entries or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = (entry.get("name") or "").strip()
|
||||
email = (entry.get("email") or "").strip()
|
||||
if name and email:
|
||||
rendered.append(f"{name} <{email}>")
|
||||
elif name or email:
|
||||
rendered.append(name or email)
|
||||
return rendered
|
||||
|
||||
|
||||
def metadata_kwargs(project: dict) -> dict:
|
||||
"""Translate a pyproject ``[project]`` table into ``setup()`` keywords.
|
||||
|
||||
The Nuitka wheel is built by a generated ``setup.py`` outside the normal
|
||||
setuptools pipeline, so every PyPI-visible field has to be passed through
|
||||
explicitly. Omitting them is what left the early PyPI releases with no
|
||||
``Requires-Python``, classifiers, project URLs or license on the project page.
|
||||
"""
|
||||
authors = _person_strings(project.get("authors"))
|
||||
maintainers = _person_strings(project.get("maintainers"))
|
||||
candidates = {
|
||||
"description": project.get("description"),
|
||||
"python_requires": project.get("requires-python"),
|
||||
"classifiers": list(project.get("classifiers") or []),
|
||||
"project_urls": dict(project.get("urls") or {}),
|
||||
"license": _license_text(project),
|
||||
"author": authors[0] if authors else None,
|
||||
"author_email": None,
|
||||
"maintainer": maintainers[0] if maintainers else None,
|
||||
}
|
||||
return {key: value for key, value in candidates.items() if value}
|
||||
|
||||
|
||||
def read_long_description(repo_root: Path) -> str | None:
|
||||
"""Return the PyPI long description, or ``None`` when it is missing.
|
||||
|
||||
Deliberately not the repository ``README.md``: that file documents the
|
||||
closed-source build/publishing layout, which should not end up on the PyPI
|
||||
project page.
|
||||
"""
|
||||
path = Path(repo_root) / PYPI_README_RELPATH
|
||||
if not path.is_file():
|
||||
print(
|
||||
f"[WARN] {PYPI_README_RELPATH.as_posix()} not found; the wheel will be "
|
||||
"published without a long description."
|
||||
)
|
||||
return None
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def write_wheel_setup(
|
||||
wheel_dir: Path,
|
||||
@@ -27,7 +97,17 @@ def write_wheel_setup(
|
||||
package_data: dict[str, list[str]],
|
||||
install_requires: list[str],
|
||||
extras_require: dict[str, list[str]],
|
||||
project: dict | None = None,
|
||||
long_description: str | None = None,
|
||||
) -> Path:
|
||||
metadata_lines = ""
|
||||
if project:
|
||||
for key, value in sorted(metadata_kwargs(project).items()):
|
||||
metadata_lines += f" {key}={value!r},\n"
|
||||
if long_description:
|
||||
metadata_lines += " long_description=_LONG_DESCRIPTION,\n"
|
||||
metadata_lines += ' long_description_content_type="text/markdown",\n'
|
||||
|
||||
setup_py_path = wheel_dir / "setup.py"
|
||||
setup_content = f"""
|
||||
from setuptools import setup, Distribution
|
||||
@@ -36,12 +116,12 @@ class BinaryDistribution(Distribution):
|
||||
def has_ext_modules(self):
|
||||
return True
|
||||
|
||||
{'_LONG_DESCRIPTION = ' + repr(long_description) if long_description else ''}
|
||||
|
||||
setup(
|
||||
name="orisys",
|
||||
version="{version}",
|
||||
description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)",
|
||||
author="Orisys",
|
||||
packages=['orisys', 'orisys.examples', 'orisys_blend'],
|
||||
{metadata_lines} packages=['orisys', 'orisys.examples', 'orisys_blend'],
|
||||
package_dir={{'': 'wheel_root'}},
|
||||
package_data={package_data},
|
||||
include_package_data=True,
|
||||
|
||||
Binary file not shown.
+21
-1
@@ -17,7 +17,7 @@ description: Orisys SDK Python API 中文参考
|
||||
|
||||
* **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。
|
||||
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `box` 或 `./config/box.json`。
|
||||
* **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。
|
||||
* **cal_path** (`str`,可选): 显式的 `force_calibration.json` 路径;传入后优先于 `sensors/<type>/force_calibration.json`。不传时按 `config_name` 自动加载产品包中的标定文件。
|
||||
* **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。
|
||||
* **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。
|
||||
|
||||
@@ -577,6 +577,26 @@ finally:
|
||||
|
||||
* 导出内置的`config_name`配置参数字典至`path`
|
||||
|
||||
### `export_sensor_bundle` / `export_sensor_bundles` 方法
|
||||
|
||||
#### 描述
|
||||
将已安装的 `sensors/<name>/` 传感器包复制到目标目录,供下游应用(如桌面 App)做本地可编辑配置,无需硬编码 wheel 路径。解析顺序与 `resolve_sensor_bundle` 相同(含 `importlib.resources` 打包布局)。
|
||||
|
||||
#### 输入参数
|
||||
* **name** / **names**: 单个名字,或名字列表;`export_sensor_bundles` 省略 `names` 时导出所有可解析的产品包(`box` / `mini` / `finger`)
|
||||
* **dest_dir**: 目标根目录;实际写入 `dest_dir/<name>/`
|
||||
* **include_assets**: 默认 `False`,仅复制 `sensor.json` 以及存在的 `force_calibration.json` / `manifest.json`;为 `True` 时复制完整目录树(含 `assets/`)
|
||||
* **overwrite**: 默认 `True`;为 `False` 且目标已存在时抛出 `FileExistsError`
|
||||
|
||||
#### 返回
|
||||
* `Path` 或 `list[Path]`:导出后的包目录路径
|
||||
|
||||
#### CLI
|
||||
```bash
|
||||
python -m orisys.export_sensors --dest ./out/sensors --names box,mini
|
||||
python -m orisys.export_sensors --dest ./out/sensors --include-assets
|
||||
```
|
||||
|
||||
## 常见问题解答 (FAQ)
|
||||
### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`?
|
||||
A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
# 滤波:三级、预设与可追溯性
|
||||
|
||||
`json.load` 不支持注释(见 `src/orisys/configs.py`),所以"建议的滤波"**不写在 JSON 注释里**,
|
||||
而是写成**代码里的预设**(`src/orisys/sensor.py::FILTER_PRESETS`)+ 这份文档 +
|
||||
`examples/filters/` 里可直接复制的配置。预设是可执行、可回显、可审计的;注释不是。
|
||||
|
||||
---
|
||||
|
||||
## 1. 三级滤波,位置不同、性质不同
|
||||
|
||||
| 级 | 配置键 | 作用位置 | 性质 |
|
||||
|---|---|---|---|
|
||||
| 光流时间 EMA | `flow_ema_alpha` | `compute_flow()` 内,**HHD 之前** | **改变测量**:改 `vnormal`/接触统计与 `_force_raw` 的值。两个 `alpha` 不同的录制**不可比** |
|
||||
| 力中值 | `force_median_window` | `_apply_force_filter()`:raw → 中值 → 低通 → 标定 | **读数滤波**:删除单点尖峰(因果滑动中值) |
|
||||
| 力低通 | `force_lowpass_cutoff_hz` / `_order` / `_sample_rate_hz` | 同上(中值之后) | **读数滤波**:因果 Butterworth IIR(`sosfilt` + 持久状态) |
|
||||
|
||||
因此 `filters_state()` 把它们分成两组:`measurement`(测量身份)与 `readout`(读数滤波)。
|
||||
标定/可比性检查应比对 `measurement`,记录里两者都记。
|
||||
|
||||
## 2. 取值规则(自描述,无隐藏覆盖)
|
||||
|
||||
- **每个级由自己的值开关**:`flow_ema_alpha >= 1`、`force_median_window <= 1`、
|
||||
`force_lowpass_cutoff_hz <= 0` 都是"关"。
|
||||
- **配置没写的键 = 关**:缺省不是"我们觉得合理的那档",而是"不滤波"。因此**没有 `filters`
|
||||
段的配置文件什么都不滤**,出厂 bundle 也把三个值显式写成全关 —— 想让哪一级跑起来,
|
||||
必须在文件里写出来(数值或 `preset`)。这样"文件说的"和"设备跑的"才总是一致。
|
||||
- **非法值 → 关**,绝不回落到"默认开"。
|
||||
- `filters.enabled` 是**废弃别名**,显式 `false` 表示"三级全关";没有任何开关能覆盖已写的值。
|
||||
- `filters.preset` 可引用下面的预设;**显式键优先于预设值**(可从预设出发只改一个旋钮)。
|
||||
- 任何时刻的**真值**是 `Sensor.filters_state()`,不是配置文件 —— 运行时 `configure()` 可以改它。
|
||||
|
||||
## 3. 预设(`FILTER_PRESETS`)
|
||||
|
||||
| 预设 | `flow_ema_alpha` | `force_median_window` | `force_lowpass_cutoff_hz` | 延迟 @30fps | 用途 |
|
||||
|---|---|---|---|---|---|
|
||||
| `off` | 1.0 | 1 | 0.0 | 0 帧 | 原始读数;文件说什么就跑什么 |
|
||||
| `low_latency` | 1.0 | 3 | 0.0 | ≈1 帧(33 ms) | 延迟敏感,只去尖峰 |
|
||||
| `balanced` | 0.5 | 3 | 0.0 | ≈2 帧(67 ms) | 建议起步档:EMA 干掉力级滤波够不到的光流抖动 |
|
||||
| `low_noise` | 0.3 | 3 | 10.0 | ≈3 帧(100 ms) | 底噪优先,读数再低通一道 |
|
||||
|
||||
延迟换算 @60fps 减半;数字来自 `_perf/`(对照表见 `orisys-sdk-perf-plan.md`)。
|
||||
≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来",
|
||||
无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。
|
||||
|
||||
## 4. 怎么用
|
||||
|
||||
```jsonc
|
||||
// sensors/mini/sensor.json(片段;完整可复制版本见 examples/filters/)
|
||||
"filters": { "preset": "balanced" }
|
||||
```
|
||||
```python
|
||||
# 或运行时(UI 的齿轮就走这条路;不重连相机)
|
||||
sensor.configure(flow_ema_alpha=0.5, force_median_window=3, lowpass_cutoff_hz=0.0)
|
||||
print(sensor.filters_state()) # 实际生效值 + preset + applied 标志
|
||||
```
|
||||
UI 想列出可选档位:`from orisys.sensor import available_filter_presets`。
|
||||
|
||||
## 5. 两个 buffer:滤波前 / 滤波后
|
||||
|
||||
```python
|
||||
t = sensor.force_triplets()
|
||||
t["filtered"]["raw"], t["filtered"]["newton"] # 记录与显示用的值(同一套)
|
||||
t["unfiltered"]["raw"], t["unfiltered"]["newton"] # 中值/低通之前;同帧、同标定系数
|
||||
```
|
||||
- `filtered.raw` = 链的输出(标定前),`unfiltered.raw` = 设备计数;
|
||||
- 两个 `newton` 用同一份 `force_calibration.json`,差异**只**来自滤波链;
|
||||
- 实测:注入 `10,10,10,999,10,10`(中值窗口 3)→ `filtered.raw` 在尖峰帧为 `10.0`、
|
||||
`unfiltered.raw` 为 `999.0`。
|
||||
|
||||
## 6. 可追溯性清单
|
||||
|
||||
1. 录制元数据写 `sensor.filters_state()` 的**生效值**(不是配置文件内容),并含 `preset` 与
|
||||
`"source": "sensor.filters_state"` + `sdk_version`。
|
||||
2. 界面显示"实际生效"而不是"我填的值";两者不一致时以实际为准并提示。
|
||||
3. 记录里主列 = 滤波后(与界面一致),未滤波列另存;列结构与格式版本可区分。
|
||||
4. 标定/可比性指纹用 `measurement` 组;`readout` 组变化只记录、不拦截。
|
||||
5. 双份数据在同一帧、同一份标定系数下产生,差异可完全归因于滤波链。
|
||||
|
||||
## 7. 指纹:标定文件里怎么记(已实现)
|
||||
|
||||
`orisys.calibration` 负责把滤波设置写进标定文件的 `meta`,并在读回时还原:
|
||||
|
||||
```python
|
||||
from orisys.calibration import filters_fingerprint, filter_mismatches, pipeline_from_sensor_json
|
||||
|
||||
pipeline = pipeline_from_sensor_json(config_path, filters_state=sensor.filters_state())
|
||||
# {"stitch_resolution": …, "dis_mode": …, "hhd_downsample": …, "config_ref": …,
|
||||
# "filters": {"measurement": {"flow_ema_alpha": 0.5},
|
||||
# "readout": {"force_median_window": 3, "force_lowpass_cutoff_hz": 0.0},
|
||||
# "preset": "balanced", "source": "sensor.filters_state"}}
|
||||
```
|
||||
|
||||
写入的文件长这样(`meta` 里同时保留扁平键供旧读者按名取用):
|
||||
|
||||
```json
|
||||
"meta": {
|
||||
"filters": {
|
||||
"measurement": {"flow_ema_alpha": 0.5},
|
||||
"readout": {"force_median_window": 3, "force_lowpass_cutoff_hz": 0.0},
|
||||
"preset": "balanced",
|
||||
"source": "sensor.filters_state"
|
||||
},
|
||||
"flow_ema_alpha": 0.5,
|
||||
"force_median_window": 3
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **生效值优先**:传了 `filters_state=` 就用它;没传才读文件的 `filters` 段,并标成
|
||||
`"source": "sensor.json.filters"`(那是"意图",不一定等于设备正在做的事)。
|
||||
- **不猜**:来源没写的键**不出现**在指纹里。`{}` 表示"没有任何记录",绝不表示"大概是关的"。
|
||||
旧文档写的扁平键(`meta.flow_ema_alpha` …)在读回时会被折进对应的组,因此仍可比。
|
||||
- **`enabled: false`** 只作为"全部关"的废弃别名保留(`flow_ema_alpha=1`、中值 `1`、低通 `0`)。
|
||||
- **分组判读**:`calibration.check(doc, pipeline=…)` 把两组的差异分开报告 —— measurement 组的
|
||||
差异进 `warnings`(措辞含 `not comparable`,即"两份记录不可比"),readout 组的差异进
|
||||
`notes`(只记录,不拦截)。`CheckResult.notes` 是为此新增的字段。
|
||||
|
||||
+4
-6
@@ -5,8 +5,6 @@ description: 安装并运行 Orisys SDK 的基础步骤
|
||||
|
||||
# 快速开始
|
||||
|
||||
版本:0.5.0
|
||||
|
||||
## 简介
|
||||
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
|
||||
支持操作系统:Windows、Ubuntu
|
||||
@@ -84,9 +82,9 @@ pip install --upgrade orisys
|
||||
### 使用 .whl 文件安装(离线或指定版本)
|
||||
从官方渠道获取 .whl 文件,文件名格式通常为:
|
||||
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
|
||||
例如:orisys-0.5.0-cp310-cp310-win_amd64.whl 表示:
|
||||
例如 `orisys-<版本>-cp310-cp310-win_amd64.whl` 表示:
|
||||
|
||||
版本:0.5.0
|
||||
版本:`<版本>`(以实际下载的文件名为准,可用 `python -c "import orisys; print(orisys.__version__)"` 确认)
|
||||
Python 版本:3.10(cp310)
|
||||
操作系统:Windows(win)
|
||||
硬件架构:64位(amd64)
|
||||
@@ -97,7 +95,7 @@ Python 版本:3.10(cp310)
|
||||
|
||||
```bash
|
||||
# 请将文件名替换为您实际下载的文件名
|
||||
pip install orisys-0.5.0-cp310-cp310-win_amd64.whl
|
||||
pip install orisys-<版本>-cp310-cp310-win_amd64.whl
|
||||
```
|
||||
|
||||
### 安装 CuPy(可选)
|
||||
@@ -139,7 +137,7 @@ python
|
||||
import orisys
|
||||
print(orisys.__version__)
|
||||
```
|
||||
如果输出版本号(例如 0.5.0),则说明安装成功。
|
||||
如果打印出版本号,则说明安装成功。
|
||||
|
||||
## 使用方法
|
||||
### 运行示例程序
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
# 发布流程(构建 → 校验 → 发布)
|
||||
|
||||
本文档说明如何构建 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
|
||||
# --version 可省略:默认取 pyproject 的 dynamic version(即 src/orisys/__init__.py 里的 __version__)
|
||||
python build/run.py release preflight
|
||||
```
|
||||
|
||||
按顺序执行 8 个步骤,任一步失败即整体失败(`--keep-going` 可改为收集全部失败):
|
||||
|
||||
| 步骤 | 检查内容 |
|
||||
| --- | --- |
|
||||
| `repo-state` | 是 git 仓库、工作树干净、记录 HEAD(`--allow-dirty` 可豁免,用于提交前预检) |
|
||||
| `version-consistency` | `src/orisys/__init__.py::__version__` == pyproject 的 dynamic attr == `SDK/<version>/` 中 wheel 文件名里的版本 |
|
||||
| `version-strings` | 追踪的文本文件里没有与当前版本不符的版本字面量(见 §5.3) |
|
||||
| `duplicate-guard` | 该版本是否**已经发布过**:本地 git tag(`v<version>` / `<version>`,含 sibling `orisys-sdk-release` 仓库)命中即拒绝;`--check-pypi` 时再查 PyPI。`SDK/<version>/` 里已有 wheel 只作信息展示——那是 preflight 的正常输入,不是错误 |
|
||||
| `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` | 每个 wheel **一个** venv,在里面依次做:导入冒烟 → 内置 configs/sensors 资产可加载 → 对真实视频跑 40 帧公开流水线(无需相机、无 GUI) |
|
||||
|
||||
正常顺序是**先构建、再 preflight**,不需要额外开关:
|
||||
|
||||
```powershell
|
||||
python build/run.py wheel-win
|
||||
python build/run.py manylinux
|
||||
python build/run.py release preflight --all-wheel-pythons --check-pypi
|
||||
```
|
||||
|
||||
提交前想先看结果(此时工作树通常还是脏的):加 `--allow-dirty --keep-going`。
|
||||
|
||||
产物报告:
|
||||
|
||||
- `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\<version>\orisys-<version>-cp312-cp312-win_amd64.whl
|
||||
python build/run.py release audit dist\repaired\orisys-<version>-cp310-cp310-win_amd64.whl --strict-bundle
|
||||
|
||||
# 追加运行时校验(--assets 与 --runtime 同时给时共用一个 venv,只装一次依赖)
|
||||
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. 发布(两条命令)
|
||||
|
||||
先决条件:`release preflight` PASS,**而且是在干净提交上跑的**。两个发布命令都只发布「preflight 认证过的东西」——它们会读取 `build/release/reports/<version>/preflight.json`,逐个核对 wheel 的 sha256,并拒绝使用带豁免项(`--skip-tests` / `--allow-dirty` / `--no-runtime` / `--force`)的报告。所以正确顺序是:
|
||||
|
||||
```powershell
|
||||
git add -A; git commit -m "..."
|
||||
python build/run.py wheel-win && python build/run.py manylinux
|
||||
python build/run.py release preflight --all-wheel-pythons --check-pypi # 干净树 + 无豁免
|
||||
```
|
||||
|
||||
两个命令**默认都是 dry run**,只打印将要执行的动作;必须显式加 `--apply` 才会真正动手。GitHub 的 `gh release create`、`git push`,以及 PyPI 上传(版本号永久占用、不可覆盖)都属于不可撤销操作。
|
||||
|
||||
### 3.1 GitHub:同步公开仓库 + 上传 Release
|
||||
|
||||
```powershell
|
||||
python build/run.py release github --version 0.5.2 # 预览
|
||||
python build/run.py release github --version 0.5.2 --apply # 执行
|
||||
```
|
||||
|
||||
执行顺序:
|
||||
|
||||
1. 认证书校验(上面那份报告 + 逐 wheel sha256)
|
||||
2. `git fetch --tags`,若 `v0.5.2` 或 `0.5.2` 已存在则拒绝;`gh release view` 已存在也拒绝
|
||||
3. 按 `tools/downstream_sync_manifest.json` 同步公开内容到 `../orisys-sdk-release`,并运行 manifest 里的 `checks`
|
||||
4. 把 `SDK/0.5.2/*.whl` 放到 `orisys-sdk-release/release/0.5.2/`(该目录在 release 仓库里是 gitignore 的,只用于上传附件)
|
||||
5. 提交 → 打 tag `v0.5.2` → push 分支与 tag
|
||||
6. `gh release create v0.5.2 --verify-tag --latest <6 个 wheel>`,然后回读 `gh release view --json assets` 核对附件齐全
|
||||
|
||||
需要:`gh` 已安装并登录(`winget install --id GitHub.cli` 然后 `gh auth login`)。没装 gh 时:
|
||||
|
||||
- dry run 仍会完整打印计划(只告警)
|
||||
- `--apply` 会失败,或改用 `--skip-release-upload`:同步、提交、打 tag、push 都自动完成,然后打印出你自己要跑的 `gh release create`
|
||||
|
||||
其他开关:`--notes-file`(自定义 release notes)、`--attach-preflight`(把 `preflight.json` 也作为附件上传,便于审计;注意报告里有本机绝对路径)、`--allow-dirty-release`、`--skip-fetch`、`--skip-checks`。
|
||||
|
||||
> tag 拼写沿用历史风格 `v<version>`(注意历史里那个 typo tag `0.50` 指向 `v0.5.1`)。命令会在动手前拒绝已存在的 tag,所以不会重复发布。**如果 tag 已经 push 但 Release 上传失败(例如 `gh` 没配好),直接重跑同一条 `--apply` 命令即可续跑**:它会跳过创建 tag,只补做剩下的步骤;只有「GitHub Release 已存在」才拒绝。
|
||||
|
||||
### 3.2 PyPI:TestPyPI 先行,确认后再上正式仓库
|
||||
|
||||
```powershell
|
||||
python build/run.py release pypi --version 0.5.2 # 预览
|
||||
python build/run.py release pypi --version 0.5.2 --apply # 只发 TestPyPI
|
||||
python build/run.py release pypi --version 0.5.2 --confirm-pypi --apply # TestPyPI + 正式 PyPI
|
||||
|
||||
# 只有正式 token、没有 TestPyPI 账号时:跳过彩排直接发正式库
|
||||
python build/run.py release pypi --version 0.5.2 --pypi-only --apply
|
||||
```
|
||||
|
||||
`--pypi-only` 与 `--confirm-pypi` 互斥。**注意它的代价**:没有 TestPyPI 彩排,而正式库的版本号一旦占用就无法复用、无法替换。它仍然会做完整门禁、`twine check`、上传前查重、上传后回读索引核对文件、以及把产物重新拉回来验证。
|
||||
|
||||
执行顺序:
|
||||
|
||||
1. 认证书校验(与 `release github` 同一份报告)
|
||||
2. `twine check` 全部 wheel(校验 METADATA/long description,上传前就能发现问题)
|
||||
3. 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的)
|
||||
4. 上传 → **轮询**索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取
|
||||
5. 未加 `--confirm-pypi` 时只发 TestPyPI,然后停在这里并打印后续命令
|
||||
|
||||
两个针对「索引不即时一致」的设计(TestPyPI 尤其明显,上传成功后 JSON API 可能滞后若干秒):
|
||||
|
||||
- **上传后的确认会重试**(默认 6 次 × 5 秒)。确认不了也**不算失败**:twine 上传成功就说明产物已发布,命令会打印警告、给出项目页链接并以 0 退出,不会把一个成功的上传报成失败。真正的失败只有两种:twine 上传本身失败,或索引上的文件集与本地不一致。
|
||||
- **重复运行是安全的**:若索引上已经存在该版本且文件集完全一致,该阶段会被视为「已完成」而跳过(`[ok] testpypi already serves orisys 0.5.2 with all 6 file(s)`),不会重传、也不报错。这让你在确认不明确时可以直接重跑。如果文件集**不一致**(索引上是另一次构建),才会失败并停下。
|
||||
|
||||
所以「上传后确认失败」不等于要重新上传——索引版本号不可覆盖,重跑只会做一次只读核对。
|
||||
|
||||
### 3.3 凭证:`release auth` 与 `~/.pypirc`
|
||||
|
||||
```powershell
|
||||
python build/run.py release auth # 查看两个索引的凭证状态
|
||||
python build/run.py release auth --set-token pypi # 隐藏输入,写入 ~/.pypirc
|
||||
python build/run.py release auth --set-token testpypi
|
||||
python build/run.py release auth --repair # 修「有段但没列进 index-servers」
|
||||
```
|
||||
|
||||
`--set-token` 的 token 只从**隐藏输入**或 `--token-stdin`(供脚本/CI)读取,**不接受命令行参数**——避免落进 shell 历史和进程列表。写入采用定点文本编辑,文件里的说明注释会保留;同时会把该索引补进 `[distutils] index-servers`(这一步很关键,见下)。
|
||||
|
||||
`release auth` 只有在你真的运行 `--set-token` 时才写文件;不带参数时只读并报告,状态判断比「文件是否存在」严格:
|
||||
|
||||
```
|
||||
config file: C:\Users\cheun\.pypirc (exists)
|
||||
[-- ] [pypi] not usable: [pypi] still contains a placeholder token
|
||||
[-- ] [testpypi] not usable: C:\Users\cheun\.pypirc has no [testpypi] section
|
||||
```
|
||||
|
||||
**占位符、空密码、`username` 不是 `__token__`,都会被判定为「不可用」**——否则 twine 只会在真正上传时才发现,而那时已经动过索引了。
|
||||
|
||||
`release pypi` 在 `--apply` 时若凭证缺失:有终端就地提示输入(隐藏),无终端则直接失败并打印 `release auth --set-token <index>`;也可显式加 `--prompt-credentials`。dry run 只告警并继续展示计划,不会卡在输入上。
|
||||
|
||||
#### twine 直接读取的格式
|
||||
|
||||
默认路径 `~/.pypirc`(Windows 上即 `C:\Users\<你>\.pypirc`,由 `USERPROFILE` 展开)。**没有**环境变量可以改这个路径;要用别的文件就传 `--config-file`。
|
||||
|
||||
```ini
|
||||
[distutils]
|
||||
index-servers =
|
||||
pypi
|
||||
testpypi
|
||||
|
||||
[pypi]
|
||||
repository = https://upload.pypi.org/legacy/
|
||||
username = __token__
|
||||
password = pypi-你的正式token
|
||||
|
||||
[testpypi]
|
||||
repository = https://test.pypi.org/legacy/
|
||||
username = __token__
|
||||
password = pypi-你的TestPyPI token
|
||||
```
|
||||
|
||||
容易搞错的点:
|
||||
|
||||
- **`[distutils] index-servers` 必须列出该索引,否则 twine 完全忽略那一节** —— 这是最常见的坑:文件里明明有 `[testpypi]` 段,twine 却报 `InvalidConfiguration: Missing 'testpypi' section`。用 `release auth --repair` 修(把已存在但未列出的段补进 `index-servers`)。
|
||||
- 更细的语义差别:`index-servers =` **值为空**表示「一个都不认」;只有**整行缺失**时 twine 才会回退到默认的 `pypi testpypi`。所以手工编辑时别把名字删空。
|
||||
- `username` 就是字面量 `__token__`,不是账号名或邮箱
|
||||
- 两类 token **都以 `pypi-` 开头**,但**不能互换**:拿正式 token 打 `test.pypi.org` 会被 403 拒绝
|
||||
- TestPyPI 是**独立服务、独立账号**,注册在 <https://test.pypi.org/account/register/>,你的 PyPI 账号在那里不存在
|
||||
- 正式 token 建议限定到 `orisys` 项目(项目已存在,可以建 project-scoped token)
|
||||
- 该文件必须在**任何 git 仓库之外**(放在用户目录里);token 一旦提交进仓库,即使后来删文件也还在历史里——那属于安全事故
|
||||
- 临时替代方案:会话内设 `TWINE_USERNAME=__token__` + `TWINE_PASSWORD=<token>`,命令会识别这种情形
|
||||
- PyPI token **没有只读的校验方式**,有效性只能在真正上传时才会被索引确认;所以所有本地检查、查重都排在写操作之前
|
||||
|
||||
`release auth` 的「可用」判定**直接问 twine 自己**(`twine.utils.get_config` 解析出的 username/password),而不是自己解析 ini,这样报告结果与 twine 的行为不会分叉。twine 未安装时才回退到本地复刻同一套规则。
|
||||
|
||||
只需要 TestPyPI 时才把 `testpypi` 写进 `index-servers` 并填对应 token;只发正式库时 `--pypi-only` 用不到 `[testpypi]` 段。
|
||||
|
||||
### 3.4 已知的历史发布空洞
|
||||
|
||||
| 版本 | 本地 `SDK/` | PyPI | GitHub Release |
|
||||
| --- | --- | --- | --- |
|
||||
| 0.4.2 | — | 有 | 有 |
|
||||
| 0.4.3 | — | 有 | 有 |
|
||||
| 0.4.4 | 有 | **无** | **无** |
|
||||
| 0.5.0 | 有 | **无** | **无** |
|
||||
| 0.5.1 | 有 | 有 | 有(tag `v0.5.1` + typo tag `0.50`) |
|
||||
| 0.5.2 | 有 | **待发布** | **待发布**(`release preflight --check-pypi` 确认 PyPI 尚无 0.5.2) |
|
||||
|
||||
> 本表按 2026 年 0.5.2 构建时的实际状态填写。`0.5.1` 这一行曾被一次全局替换误改成 `0.5.2` 并丢失,请勿再用「全局把 0.5.1 替换成新版本号」的方式改本文件——历史事实不应该被改。
|
||||
|
||||
---
|
||||
|
||||
## 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(会联网安装依赖)。
|
||||
|
||||
### 5.3 版本号只有一个地方,其他都是推导出来的
|
||||
|
||||
版本号的唯一来源是 **`src/orisys/__init__.py::__version__`**。以下全部由它推导,不需要手改:
|
||||
|
||||
| 产物 | 推导方式 |
|
||||
| --- | --- |
|
||||
| pyproject 的 `[project] version` | `[tool.setuptools.dynamic] version = {attr = "orisys.__version__"}` |
|
||||
| wheel 文件名里的版本 | `build/wheel/build.py` 调 `get_version()` |
|
||||
| wheel `METADATA` 的 `Version:` | 生成的 `setup.py` 里写入同一个 `version` |
|
||||
| `SDK/<version>/` 目录名 | 同上 |
|
||||
| `importlib.metadata.version("orisys")` | 从 wheel METADATA 读出 |
|
||||
|
||||
因此**不要**再往文档、示例、测试里写具体版本号:
|
||||
|
||||
- 用法示例写 `SDK/<version>/...`、`--version <version>`,不要写具体版本
|
||||
- 文档里不要写「版本:x.y.z」;需要时让用户用 `python -c "import orisys; print(orisys.__version__)"` 自己看
|
||||
- 示例脚本的 docstring 不要再写 `SDK version: x.y.z`
|
||||
- 测试里需要「一个版本」时用明显是假的版本号(如 `1.x.y` 这种不会与真实版本混淆的值),不要用当前版本 —— 否则每次发版都要改测试,而且一次全局替换就会悄悄改坏断言
|
||||
|
||||
`release preflight` 的 `version-strings` 步骤强制这条规则:扫描所有被追踪的文本文件,任何与当前版本不符的版本字面量都会导致失败(`tests/` 与生成物除外;依赖约束如 `numpy>=1.24.4` 由正则排除)。
|
||||
|
||||
历史记录属于例外,需显式登记在 `build/release/version.py::VERSION_STRING_ALLOWLIST` 里——登记本身就是「该字面量故意不是包版本」的文档。**不要用全局替换修改含历史版本的文件**:本文件的发布记录表就曾被一次全局替换改错(`0.50` typo tag 被挂到错误的版本上,`0.5.1` 那一行被覆盖丢失)。
|
||||
|
||||
### 5.4 wheel 里有什么、没有什么
|
||||
|
||||
打包内容由 `pyproject.toml` 的 `[[tool.orisys.bundle.items]]` 单点定义:
|
||||
|
||||
| 内容 | 是否进 wheel | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `config/*.json` | 是 | `orisys/configs/`,运行时配置解析要用 |
|
||||
| `sensors/**` | 是 | `orisys/sensors/`,含 finger 的 npz/npy 资产,运行时与 GUI 都要用 |
|
||||
| `examples/**` | 是 | `orisys/examples/`,用户装完就能跑示例 |
|
||||
| `docs/**` | **否** | 只随 GitHub 仓库与 PyPI 项目页提供。`docs/publishing.md` 是内部流程文档;`docs/pypi_readme.md` 仅在构建时被读取作为 long description |
|
||||
|
||||
因为 docs 不进 wheel,**改文档不会让已构建的 wheel 失效**,不需要重新构建。(改 `examples/`、`config/`、`sensors/` 会——它们确实在 wheel 里,`wheel-inspection` 的 `bundle-fidelity` 会报出来。)
|
||||
|
||||
如果以后需要离线 API 参考,加一个只包含 `docs/api.md` 的窄条目即可,不要整个 `docs/` 都塞进去。
|
||||
|
||||
---
|
||||
|
||||
## 6. 缩短 manylinux 构建时间
|
||||
|
||||
`python build/run.py manylinux` 走 `build/wheel/docker/run.ps1` → 容器内 `entrypoint.sh`,对 `cp310/cp311/cp312` **串行**各做一次 Nuitka 编译 + 打 wheel + auditwheel repair + 隔离 venv 校验。
|
||||
|
||||
### 6.1 镜像里已经烘好的东西(所以每次构建不必再拉)
|
||||
|
||||
`Dockerfile.manylinux` 在每个 ABI 下预置了两类内容:
|
||||
|
||||
1. **构建工具**:`pip`/`setuptools`/`wheel`/`nuitka`/`ordered-set`/`zstandard`/`auditwheel`。
|
||||
因此 `entrypoint.sh` 默认**不再**在每个 ABI 上重跑 `pip install --upgrade pip setuptools wheel auditwheel` —— 那是每次构建 3 次多余的 PyPI 往返,还会让容器里的工具版本与镜像不一致。需要用最新工具时显式开启:
|
||||
|
||||
```powershell
|
||||
docker run ... -e ORISYS_UPDATE_BUILD_TOOLS=1 ...
|
||||
```
|
||||
|
||||
2. **每个 ABI 一个 wheelhouse**(`/opt/orisys-wheelhouse/<tag>`),内含 `numpy`/`scipy`/`opencv-python`/`numba` 及其依赖闭包,外加 `pip`/`setuptools`/`wheel`/`auditwheel` 的 wheel。
|
||||
|
||||
`--target pypi-linux` 会**强制**校验:为每个 ABI 建一个一次性 venv,再 `pip install` 刚构建的 wheel 和它声明的运行期依赖。镜像故意不把运行期依赖装进解释器(用户是凭 wheel 元数据装的),所以以前每次构建都要现下约 200 MB × 3。`entrypoint.sh` 现在用 `PIP_NO_INDEX=1` + `PIP_FIND_LINKS=<wheelhouse>` 把 pip 指向本地 wheelhouse:**venv 仍然是隔离的**(没有 `--system-site-packages`),依赖解析与安装依然真实发生,只是不走网络。
|
||||
|
||||
找不到 wheelhouse 时会打印警告并回退到联网,不会静默失败。镜像因此增大约 400–500 MB(一次性代价,换每次构建省三次下载)。
|
||||
|
||||
### 6.2 不要为了省事直接重建镜像
|
||||
|
||||
只改了源码(`src/`、`config/`、`sensors/`、`examples/`、`pyproject.toml`)时加 `-SkipImageBuild`:
|
||||
|
||||
```powershell
|
||||
.\build\wheel\docker\run.ps1 -SkipImageBuild
|
||||
```
|
||||
|
||||
Dockerfile 的 `RUN` 层要装 3 份工具链并下载 3 个 wheelhouse,缓存未命中就是数分钟。**改了 `Dockerfile.manylinux` 或需要更新 wheelhouse 时才重建镜像**。
|
||||
|
||||
### 6.3 Nuitka 的排除列表必须完整
|
||||
|
||||
`build/wheel/compile.py::NUITKA_NOFOLLOW_IMPORTS` 列出所有**不打进 wheel、运行期从用户环境解析**的第三方包。漏一个意味着 Nuitka 会尝试把它跟进来:编译时间暴涨,而且 wheel 内容开始取决于构建机上恰好装了什么 —— `torch`、`torchvision`(`src/orisys/tracker.py` 的 RAFT 追踪器)和 `pyvkfft`(`backend_availability.py`)就是这样漏掉的,之前只是靠「构建机上没装」才没出事。
|
||||
|
||||
`tests/test_nuitka_nofollow.py` 会扫描 `src/orisys` 的全部第三方导入,任何不在排除列表里的都会让测试失败。**新增可选依赖时同步更新列表。**
|
||||
|
||||
### 6.4 想要并行?先读这一段
|
||||
|
||||
`--jobs` 的默认值是**全部 CPU 数**,单次编译的 C 阶段已经吃满所有核心。真正能靠并行省下的是 Nuitka 的单线程前端(分析/优化/代码生成)、scons 启动、`auditwheel` 与 venv 校验 —— 做法是 3 路并行 + 每个 build 限 `--jobs=<核数/3>`。
|
||||
|
||||
但**现在不能直接并行**:`build/common/paths.py` 里的工作目录是写死的共享路径
|
||||
|
||||
```
|
||||
build/nuitka/compile
|
||||
build/nuitka/wheel
|
||||
build/nuitka/wheel/repaired
|
||||
```
|
||||
|
||||
而 `build/wheel/build.py` 每次开头都 `_reset_dir()`(`rmtree`)这两个目录,`auditwheel repair` 又会再 `rmtree` 一次 `repaired`。仓库是挂载进容器的(`-v ${RepoRoot}:/src`),所以无论「一个容器 3 路并行」还是「3 个容器并行」,只要共享同一个挂载点,三个 build 就会互相清空暂存目录 —— 表现为随机失败或 wheel 内容错乱。
|
||||
|
||||
要并行必须先让工作目录带上 ABI 标签(如 `build/nuitka/<tag>/compile`)。输出路径不用动:`SDK/<version>/`、`dist/repaired/` 的文件名本来就按 ABI 区分,下游 `release preflight` 不受影响。另外 3 个 Nuitka 并行很吃内存,先把 Docker Desktop 内存调到 16 GB 以上再试。
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Orisys SDK
|
||||
|
||||
叠动科技视触觉传感器配套的 Python SDK,提供图像采集、拼接、稠密光流、Helmholtz-Hodge 分解、接触与深度估计,以及可运行的示例。
|
||||
|
||||
本文件是 PyPI 项目描述(`long_description`)的源文件:构建时由 `docs/publishing.md` 所述流程从**检出目录**读取(`build/wheel/package.py::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,6 @@ ORY-BOX:单传感器基础流程
|
||||
3. 按需计算接触区域
|
||||
4. 读取并可视化结果
|
||||
|
||||
SDK version: 0.5.0
|
||||
"""
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
@@ -21,6 +20,7 @@ def main():
|
||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
||||
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
|
||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
|
||||
parser.add_argument("--cal_path","-cal", type=str, default=None, help="标定文件路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 步骤 1:创建传感器对象(必需)
|
||||
@@ -29,9 +29,9 @@ def main():
|
||||
|
||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||
if args.video.isdigit():
|
||||
sensor = orisys.Sensor(int(args.video), config_name=args.config, verbose=args.verbose)
|
||||
sensor = orisys.Sensor(int(args.video), config_name=args.config, verbose=args.verbose, cal_path=args.cal_path)
|
||||
else:
|
||||
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
|
||||
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose, cal_path=args.cal_path)
|
||||
|
||||
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# 滤波预设:可直接复制的 `filters` 段
|
||||
|
||||
`json.load` 不支持注释,所以这个目录就是"在配置里写说明"的替代品:**把下面任意一段整块替换**
|
||||
到 `sensors/<name>/sensor.json` 的 `"filters"` 段即可。原有的 `filters` 段请整段替换,
|
||||
不要只改其中一行(避免出现 `preset` 与数值混写的歧义——显式键优先于预设值,混写容易看错)。
|
||||
|
||||
配置里**没写**的滤波键等于"关":整段删掉就是什么都不滤,和 `off` 那段等价。
|
||||
|
||||
背景、代价与可追溯性约定见 `docs/filters.md`;延迟/噪声实测见 `_perf/` 与
|
||||
`orisys-sdk-perf-plan.md`。
|
||||
|
||||
下面每一段都有一个同目录的机器可读副本 `filters_<预设名>.json`(四个文件),
|
||||
`tests/test_filter_presets.py` 会核对它们与 `FILTER_PRESETS` 完全一致 —— 所以改预设时
|
||||
这些文件会一起红,不会悄悄过时。
|
||||
|
||||
---
|
||||
|
||||
## `off` —— 原始读数(默认)
|
||||
|
||||
```json
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
## `low_latency` —— 只去尖峰(≈1 帧 @30fps)
|
||||
|
||||
```json
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
## `balanced` —— 建议默认(≈2 帧 @30fps)
|
||||
|
||||
```json
|
||||
"filters": {
|
||||
"flow_ema_alpha": 0.5,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
## `low_noise` —— 底噪优先(≈3 帧 @30fps)
|
||||
|
||||
```json
|
||||
"filters": {
|
||||
"flow_ema_alpha": 0.3,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 10.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
## 用预设名(等价写法,数值由代码展开)
|
||||
|
||||
```json
|
||||
"filters": { "preset": "balanced" }
|
||||
```
|
||||
|
||||
`off / low_latency / balanced / low_noise` 四个名字来自
|
||||
`src/orisys/sensor.py::FILTER_PRESETS`(单一真值);想从预设出发只改一个旋钮,就在同一段里写
|
||||
那一个键,例如:
|
||||
|
||||
```json
|
||||
"filters": { "preset": "balanced", "force_lowpass_cutoff_hz": 10.0 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 复制后请核对"实际生效值"
|
||||
|
||||
配置只是**意图**;`Sensor.configure()` 可以在运行时再改,所以审计与记录一律看生效值:
|
||||
|
||||
```python
|
||||
from orisys.sensor import Sensor
|
||||
s = Sensor("external", config_name="mini", verbose=False) # 无需硬件即可核对
|
||||
print(s.filters_state()["preset"], s.filters_state()["measurement"], s.filters_state()["readout"])
|
||||
```
|
||||
|
||||
`flow_ema_applied` / `force_median_applied` / `force_lowpass_applied` 三个布尔就是
|
||||
"这一级到底有没有在滤"的答案;`flow_ema_alpha` 属于 **measurement**(改变测量,
|
||||
不同值的两次录制不可比),中值与低通属于 **readout**(只影响读数呈现)。
|
||||
|
||||
导出标定/写记录时把同一个状态交给指纹函数,文件里就会留下可核对的设置:
|
||||
|
||||
```python
|
||||
from orisys.calibration import pipeline_from_sensor_json
|
||||
pipeline = pipeline_from_sensor_json("sensors/mini/sensor.json", filters_state=s.filters_state())
|
||||
print(pipeline["filters"]) # measurement / readout / preset / source
|
||||
```
|
||||
|
||||
详见 `docs/filters.md` 第 7 节。
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"flow_ema_alpha": 0.5,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"flow_ema_alpha": 0.3,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 10.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
@@ -7,7 +7,6 @@ ORY-MINI:单传感器基础流程
|
||||
3. 按需计算接触区域
|
||||
4. 读取并可视化结果
|
||||
|
||||
SDK version: 0.5.0
|
||||
"""
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Multi-sensor real-time example: N streams, one process each.
|
||||
|
||||
This is the deployment shape the multi-sensor throughput numbers assume. Each
|
||||
stream owns a process and pipelines the per-frame work into three concurrent
|
||||
stages (capture+unroll -> DIS flow -> HHD+readout), so a stream's rate is bounded
|
||||
by its slowest stage instead of the sum of all three.
|
||||
|
||||
# five finger streams from a video file (benchmark / smoke test)
|
||||
python examples/multi_stream.py --streams 5 -c finger -v data/dd02-0703/1.mp4
|
||||
|
||||
# five live cameras, camera ids 0..4
|
||||
python examples/multi_stream.py --streams 5 -c finger --camera-base 0
|
||||
|
||||
# single box sensor
|
||||
python examples/multi_stream.py --streams 1 -c box -v data/long.mp4
|
||||
|
||||
# five streams, additionally recording an arrow-overlay video per stream
|
||||
# plus one 5-up mosaic, without stalling the pipeline
|
||||
python examples/multi_stream.py --streams 5 -c finger -v data/dd02-0703/1.mp4 \
|
||||
--seconds 10 --record outputs/arrows --mosaic
|
||||
|
||||
Notes
|
||||
-----
|
||||
* One process per stream. Threads inside a single process share the GIL and,
|
||||
measured on a 9950X, five finger streams reach ~150 FPS aggregate as threads
|
||||
versus ~626 FPS as processes.
|
||||
* ``cv2.setNumThreads(2)`` per process: at five streams that beats 1, 4 and 8.
|
||||
* Run one stream per process even when the sensor count is small; the process
|
||||
boundary also isolates a crashed camera from the rest.
|
||||
* ``--record`` draws the DIS flow with ``orisys.util.draw_arrows`` (plus the
|
||||
contact overlay with ``--contact``) and encodes it with ``cv2.VideoWriter``.
|
||||
Both happen on a dedicated thread fed by a bounded drop-oldest queue, so the
|
||||
pipeline never waits on the encoder; frames the writer cannot keep up with are
|
||||
counted in the ``rec`` column as ``written/dropped``. Keep ``--record`` for
|
||||
verification runs - the encoder still competes for CPU with the pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import statistics as st
|
||||
import sys
|
||||
import time
|
||||
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
import orisys
|
||||
from orisys import StreamPipeline, StreamRecorder, configs
|
||||
|
||||
|
||||
def _make_stream(args, index):
|
||||
"""Return (sensor, produce, close) for one stream."""
|
||||
source = args.video
|
||||
if args.camera_base is not None:
|
||||
source = int(args.camera_base) + index
|
||||
if source is None:
|
||||
raise ValueError("pass --video PATH or --camera-base ID")
|
||||
|
||||
cfg = configs.load_config(args.config)
|
||||
if str(cfg.get("type", "")).lower() == "single_camera":
|
||||
from orisys.finger import FingerRuntime
|
||||
|
||||
runtime = FingerRuntime.open(config=args.config, video=source,
|
||||
verbose=False)
|
||||
return runtime.tactile.raw_sensor, _finger_produce(runtime), runtime.close
|
||||
|
||||
sensor = orisys.Sensor(source, config_name=args.config, isstitch=True,
|
||||
cuda=False, backend="cpu", verbose=False)
|
||||
|
||||
def produce():
|
||||
return sensor.get_img()
|
||||
|
||||
return sensor, produce, sensor.disconnect
|
||||
|
||||
|
||||
def _finger_produce(runtime):
|
||||
def produce():
|
||||
ok, frame = runtime.grab_and_process(color=False)
|
||||
return frame if ok else None
|
||||
|
||||
return produce
|
||||
|
||||
|
||||
def worker(args, index, out_path):
|
||||
"""One stream: own process, three-stage pipeline."""
|
||||
import cv2
|
||||
|
||||
cv2.setNumThreads(int(args.threads))
|
||||
sensor, produce, close = _make_stream(args, index)
|
||||
|
||||
frames = []
|
||||
stamps = []
|
||||
started = time.perf_counter()
|
||||
|
||||
def collect(snap):
|
||||
frames.append(snap.age_ms)
|
||||
stamps.append(time.perf_counter())
|
||||
|
||||
# Recording is entirely off the hot path: ``on_frame`` only pushes onto a
|
||||
# bounded drop-oldest queue, and a separate thread does the drawing and the
|
||||
# H.264/MPEG-4 encoding. The pipeline never blocks on the encoder.
|
||||
recorder = None
|
||||
on_frame = collect
|
||||
if args.record:
|
||||
recorder = StreamRecorder(
|
||||
os.path.join(args.record, f"stream_{index}.mp4"),
|
||||
fps=args.record_fps,
|
||||
every=args.record_every,
|
||||
fourcc=args.record_fourcc,
|
||||
valid_mask=lambda: getattr(sensor, "valid_mask", None),
|
||||
grid_spacing=args.record_grid,
|
||||
threshold=args.record_threshold,
|
||||
arrow_scale=args.record_arrow_scale,
|
||||
draw_contact=args.contact,
|
||||
label=f"#{index}",
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
def on_frame(snap, _collect=collect, _rec=recorder):
|
||||
_collect(snap)
|
||||
_rec.on_frame(snap)
|
||||
|
||||
if args.no_arrays:
|
||||
include = ()
|
||||
elif recorder is not None:
|
||||
include = ("image", "flow") # what the arrow overlay needs
|
||||
else:
|
||||
include = ("image",)
|
||||
|
||||
pipe = StreamPipeline(sensor, produce, on_frame=on_frame,
|
||||
include=include, contact=args.contact, depth=args.depth)
|
||||
pipe.start()
|
||||
record_stats = None
|
||||
try:
|
||||
deadline = started + args.seconds if args.seconds else None
|
||||
while True:
|
||||
if not pipe.running:
|
||||
break
|
||||
if args.frames and pipe.stats.published >= args.frames:
|
||||
break
|
||||
if deadline is not None and time.perf_counter() >= deadline:
|
||||
break
|
||||
time.sleep(0.002)
|
||||
finally:
|
||||
try:
|
||||
pipe.stop()
|
||||
except BaseException as exc: # surface a stage failure
|
||||
print(f"[stream {index}] pipeline error: {type(exc).__name__}: {exc}",
|
||||
file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
if recorder is not None:
|
||||
record_stats = recorder.close()
|
||||
close()
|
||||
|
||||
wall = time.perf_counter() - started
|
||||
# Steady-state rate: skip the first frames, which pay the JIT / HHD-cache /
|
||||
# DIS-reference-frame warm-up and would drag a short run's average down.
|
||||
warm = min(30, max(0, len(stamps) - 2))
|
||||
span = (stamps[-1] - stamps[warm]) if len(stamps) > warm + 1 else 0.0
|
||||
steady = (len(stamps) - 1 - warm) / span if span > 0 else 0.0
|
||||
payload = {
|
||||
"index": index,
|
||||
"published": pipe.stats.published,
|
||||
"wall": wall,
|
||||
"fps": pipe.stats.published / wall if wall else 0.0,
|
||||
"steady_fps": steady,
|
||||
"stats": pipe.stats.as_dict(),
|
||||
"age_p95_ms": float(st.quantiles(frames, n=20)[18]) if len(frames) >= 20 else 0.0,
|
||||
"record": record_stats,
|
||||
}
|
||||
with open(out_path, "w") as fh:
|
||||
json.dump(payload, fh)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--streams", "-n", type=int, default=5)
|
||||
ap.add_argument("--config", "-c", default="finger")
|
||||
ap.add_argument("--video", "-v", default=None, help="video file shared by all streams")
|
||||
ap.add_argument("--camera-base", type=int, default=None,
|
||||
help="first camera id; stream i uses base + i")
|
||||
ap.add_argument("--frames", type=int, default=0, help="stop after N frames (0 = until EOF)")
|
||||
ap.add_argument("--seconds", type=float, default=10.0, help="max wall time per stream")
|
||||
ap.add_argument("--threads", type=int, default=2, help="cv2.setNumThreads per stream")
|
||||
ap.add_argument("--depth", type=int, default=2, help="queue depth between stages")
|
||||
ap.add_argument("--contact", action="store_true", help="also run compute_contact")
|
||||
ap.add_argument("--no-arrays", action="store_true",
|
||||
help="scalars only (fastest; skips the snapshot image copy)")
|
||||
ap.add_argument("--out-dir", default=None)
|
||||
ap.add_argument("--record", default=None, metavar="DIR",
|
||||
help="write one arrow-overlay mp4 per stream into DIR "
|
||||
"(drawing+encoding run on a separate thread, off the "
|
||||
"pipeline's critical path)")
|
||||
ap.add_argument("--record-fps", type=float, default=30.0,
|
||||
help="playback fps of the recorded files")
|
||||
ap.add_argument("--record-every", type=int, default=1,
|
||||
help="keep one frame in N (raise it if the writer falls behind)")
|
||||
ap.add_argument("--record-fourcc", default="mp4v",
|
||||
help="mp4v (built in) or MJPG/avi for maximum compatibility")
|
||||
ap.add_argument("--record-grid", type=int, default=14, help="arrow grid spacing in px")
|
||||
ap.add_argument("--record-threshold", type=float, default=0.1,
|
||||
help="displacement (px) below which a dot is drawn instead of an arrow")
|
||||
ap.add_argument("--record-arrow-scale", type=float, default=0.15,
|
||||
help="draw_arrows divides the displacement by this; 0.15 magnifies "
|
||||
"the finger's ~1-3 px field ~6.7x so directions are legible")
|
||||
ap.add_argument("--mosaic", action="store_true",
|
||||
help="after the run, tile the recordings into one grid video")
|
||||
ap.add_argument("--mosaic-cols", type=int, default=0, help="mosaic columns (0 = square-ish)")
|
||||
ap.add_argument("--mosaic-scale", type=float, default=1.0, help="mosaic downscale factor")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.record and args.no_arrays:
|
||||
ap.error("--record needs the frame and flow arrays; drop --no-arrays")
|
||||
if args.mosaic and not args.record:
|
||||
ap.error("--mosaic needs --record DIR")
|
||||
|
||||
if args.record:
|
||||
args.record = os.path.abspath(args.record)
|
||||
os.makedirs(args.record, exist_ok=True)
|
||||
|
||||
out_dir = args.out_dir or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "outputs", "multi_stream")
|
||||
out_dir = os.path.abspath(out_dir)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
ctx = mp.get_context("spawn")
|
||||
procs, paths = [], []
|
||||
for i in range(args.streams):
|
||||
path = os.path.join(out_dir, f"stream_{i}.json")
|
||||
paths.append(path)
|
||||
procs.append(ctx.Process(target=worker, args=(args, i, path)))
|
||||
for p in procs:
|
||||
p.start()
|
||||
for p in procs:
|
||||
p.join()
|
||||
|
||||
rows = []
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path) as fh:
|
||||
rows.append(json.load(fh))
|
||||
except Exception:
|
||||
pass
|
||||
if not rows:
|
||||
print("no stream produced results", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
wall = max(r["wall"] for r in rows)
|
||||
total = sum(r["published"] for r in rows)
|
||||
steady_rows = [r for r in rows if r.get("steady_fps")]
|
||||
steady = st.mean(r["steady_fps"] for r in steady_rows) if steady_rows else 0.0
|
||||
print(f"\nstreams={len(rows)} frames={total} wall={wall:.2f}s")
|
||||
print(f"aggregate throughput : {total / wall:.1f} FPS (incl. warm-up)")
|
||||
print(f"per-stream throughput : {total / wall / len(rows):.1f} FPS (incl. warm-up)")
|
||||
print(f"per-stream steady-state : {steady:.1f} FPS")
|
||||
recording = args.record is not None
|
||||
print(f"\n{'stream':>7}{'published':>11}{'FPS':>9}{'steady':>9}{'produce':>10}"
|
||||
f"{'flow':>9}{'hhd':>9}{'age':>9}{'age p95':>10}"
|
||||
+ (f"{'rec':>13}" if recording else ""))
|
||||
for r in sorted(rows, key=lambda x: x["index"]):
|
||||
s = r["stats"]
|
||||
rec = ""
|
||||
if recording:
|
||||
rs = r.get("record") or {}
|
||||
rec = f"{rs.get('written', 0)}/{rs.get('dropped', 0)}"
|
||||
if rs.get("error"):
|
||||
rec = "ERR"
|
||||
print(f"{r['index']:>7}{r['published']:>11}{r['fps']:>9.1f}"
|
||||
f"{r.get('steady_fps', 0.0):>9.1f}"
|
||||
f"{s['produce_ms']:>10.2f}{s['flow_ms']:>9.2f}{s['hhd_ms']:>9.2f}"
|
||||
f"{s['age_ms']:>9.2f}{r['age_p95_ms']:>10.2f}"
|
||||
+ (f"{rec:>13}" if recording else ""))
|
||||
if recording:
|
||||
print("rec column = frames written / frames dropped by the writer thread")
|
||||
for r in sorted(rows, key=lambda x: x["index"]):
|
||||
rs = r.get("record") or {}
|
||||
if rs.get("error"):
|
||||
print(f" stream {r['index']}: {rs['error']}")
|
||||
|
||||
if args.mosaic and recording:
|
||||
streams = sorted(r["index"] for r in rows)
|
||||
srcs = [os.path.join(args.record, f"stream_{i}.mp4") for i in streams]
|
||||
missing = [p for p in srcs if not os.path.isfile(p)]
|
||||
if missing:
|
||||
print(f"mosaic skipped, missing {missing}", file=sys.stderr)
|
||||
else:
|
||||
out = os.path.join(args.record, "mosaic.mp4")
|
||||
# No tile labels: each recorded frame already carries the "#N" HUD.
|
||||
info = orisys.mosaic_videos(srcs, out, cols=args.mosaic_cols or None,
|
||||
scale=args.mosaic_scale,
|
||||
fps=args.record_fps)
|
||||
print(f"\nmosaic -> {info['path']} {info['size'][0]}x{info['size'][1]} "
|
||||
f"{info['grid'][0]}x{info['grid'][1]} grid {info['frames']} frames "
|
||||
f"@ {info['fps']:g} fps")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -18,7 +18,6 @@
|
||||
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
||||
- 每个传感器进程都会占用独立的 GPU 显存
|
||||
|
||||
SDK version: 0.5.0
|
||||
"""
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
|
||||
+8
-5
@@ -99,9 +99,12 @@ target = "examples"
|
||||
patterns = ["*.py", "README.md", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"]
|
||||
preserve_tree = true
|
||||
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "docs"
|
||||
source = "docs"
|
||||
target = "docs"
|
||||
patterns = ["*.md", "*.pdf"]
|
||||
# NOTE: docs/ is deliberately NOT bundled into the wheel.
|
||||
# Documentation ships with the GitHub repository / PyPI project page only:
|
||||
# - docs/publishing.md is an internal process document
|
||||
# - docs/pypi_readme.md is read from the checkout at build time as the PyPI
|
||||
# long description (build/wheel/package.py::PYPI_README_RELPATH), never shipped
|
||||
# - keeping docs out means editing a doc no longer invalidates built wheels
|
||||
# Should an offline API reference be needed later, add a narrower item that ships
|
||||
# only docs/api.md.
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
# Run against the checkout without requiring `pip install -e .`, so a stale
|
||||
# orisys in site-packages cannot silently shadow src/orisys.
|
||||
pythonpath = src
|
||||
markers =
|
||||
slow: long-running tests for release validation
|
||||
hardware: requires a physical sensor/camera attached to the machine
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Measure normal-force zero-point drift, live or from a recording.
|
||||
|
||||
Phase 0 of the zero-point plan: quantify the drift before changing the pipeline.
|
||||
The number this prints is what the acceptance thresholds get set from, so it is
|
||||
deliberately explicit about which rows it used and what it could not conclude.
|
||||
|
||||
Usage
|
||||
-----
|
||||
Live capture (needs a sensor; writes a recording directory when --out is given):
|
||||
|
||||
python scripts/measure_zero_drift.py --live --seconds 1800 --out D:\\tmp\\idle
|
||||
|
||||
From an existing recording (the directory holding forces.csv):
|
||||
|
||||
python scripts/measure_zero_drift.py --recording <dir> --unit both
|
||||
python scripts/measure_zero_drift.py --recording <dir> --warmup 5 --start 10 --stop 60
|
||||
|
||||
What it reports
|
||||
---------------
|
||||
Per axis, over the analysed window:
|
||||
|
||||
* mean / median / stdev / peak-to-peak, in raw counts and (with --unit N) newtons
|
||||
* a least-squares slope in counts/s -- the ramp that "zero drift" actually means
|
||||
* a drift ratio (end-third mean minus start-third mean), which is insensitive to
|
||||
whether the drift is linear or a slow wander
|
||||
* the band left after removing the window mean, i.e. what a perfect single-point
|
||||
re-zero would achieve -- this separates "the zero moved" from "the noise is wide"
|
||||
|
||||
The raw columns are ``FN_raw, FX_raw, FY_raw`` and the calibrated ones are
|
||||
``FN_N, FX_N, FY_N`` (see ``FORCES_HEADER`` in
|
||||
``orisys_app_shared.record.experiment``); rows with ``valid != 1`` are excluded
|
||||
rather than read as zeros.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
RAW_AXES = ("FN_raw", "FX_raw", "FY_raw")
|
||||
NEWTON_AXES = ("FN_N", "FX_N", "FY_N")
|
||||
TIME_COLUMN = "t_rel_s"
|
||||
VALID_COLUMN = "valid"
|
||||
|
||||
|
||||
def _load_rows(source: Path) -> list[dict[str, str]]:
|
||||
path = source / "forces.csv" if source.is_dir() else source
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"no forces.csv at {path}")
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
return list(csv.DictReader(handle))
|
||||
|
||||
|
||||
def _column(rows: Sequence[dict[str, str]], name: str) -> np.ndarray:
|
||||
values = []
|
||||
for row in rows:
|
||||
raw = row.get(name)
|
||||
try:
|
||||
values.append(float(raw)) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
values.append(np.nan)
|
||||
return np.asarray(values, dtype=np.float64)
|
||||
|
||||
|
||||
def _window(
|
||||
rows: list[dict[str, str]],
|
||||
*,
|
||||
start: float | None,
|
||||
stop: float | None,
|
||||
warmup: float,
|
||||
) -> tuple[np.ndarray, list[dict[str, str]]]:
|
||||
times = _column(rows, TIME_COLUMN)
|
||||
valid = _column(rows, VALID_COLUMN)
|
||||
mask = np.ones(times.shape, dtype=bool)
|
||||
if times.size:
|
||||
mask &= times == times # drop NaN timestamps
|
||||
if start is not None:
|
||||
mask &= times >= float(start)
|
||||
if stop is not None:
|
||||
mask &= times <= float(stop)
|
||||
# ``valid`` is authoritative: an invalid row is not a zero reading.
|
||||
mask &= (valid == 1) | np.isnan(valid)
|
||||
kept = [row for row, keep in zip(rows, mask) if keep]
|
||||
if not kept:
|
||||
return np.asarray([], dtype=np.float64), []
|
||||
times = np.asarray([float(row[TIME_COLUMN]) for row in kept], dtype=np.float64)
|
||||
if warmup > 0 and times.size:
|
||||
t0 = times[0]
|
||||
kept = [row for row in kept if float(row[TIME_COLUMN]) - t0 >= warmup]
|
||||
times = np.asarray([float(row[TIME_COLUMN]) for row in kept], dtype=np.float64)
|
||||
return times, kept
|
||||
|
||||
|
||||
def _describe(label: str, values: np.ndarray, times: np.ndarray) -> dict[str, float]:
|
||||
finite = values[np.isfinite(values)]
|
||||
if finite.size == 0:
|
||||
return {"axis": label, "n": 0}
|
||||
mean = float(finite.mean())
|
||||
median = float(np.median(finite))
|
||||
# A min-to-max span does not shrink when a constant is removed, so it cannot
|
||||
# answer "what would a re-zero achieve". The robust band and the inter-
|
||||
# percentile width can, and they are what the noise is measured against in
|
||||
# the existing perf analysis.
|
||||
centred = finite - median
|
||||
band_1sigma = float(
|
||||
(np.percentile(centred, 84.1) - np.percentile(centred, 15.9)) / 2.0
|
||||
)
|
||||
width_99 = float(np.percentile(finite, 99.5) - np.percentile(finite, 0.5))
|
||||
slope = float("nan")
|
||||
if times.size == finite.size and times.size > 2 and float(times[-1] - times[0]) > 0:
|
||||
slope = float(np.polyfit(times - times[0], finite, 1)[0])
|
||||
third = max(1, finite.size // 3)
|
||||
drift_ratio = float(finite[-third:].mean() - finite[:third].mean())
|
||||
return {
|
||||
"axis": label,
|
||||
"n": int(finite.size),
|
||||
"mean": mean,
|
||||
"median": median,
|
||||
"stdev": float(finite.std(ddof=0)),
|
||||
"p2p": float(finite.max() - finite.min()),
|
||||
"band_1sigma": band_1sigma,
|
||||
"width_99": width_99,
|
||||
"slope_per_s": slope,
|
||||
"drift_ratio": drift_ratio,
|
||||
}
|
||||
|
||||
|
||||
def _print_table(title: str, stats: list[dict[str, float]], unit: str) -> None:
|
||||
usable = [s for s in stats if s.get("n")]
|
||||
print(f"\n=== {title} ===")
|
||||
if not usable:
|
||||
print(" no rows in the analysed window")
|
||||
return
|
||||
print(
|
||||
f" {'axis':<8} {'n':>7} {'mean':>13} {'median':>13} {'stdev':>12} "
|
||||
f"{'band1s':>11} {'w99':>11} {'slope/s':>11} {'drift':>11} [{unit}]"
|
||||
)
|
||||
for s in usable:
|
||||
print(
|
||||
f" {s['axis']:<8} {s['n']:>7} {s['mean']:>13.3f} {s['median']:>13.3f} "
|
||||
f"{s['stdev']:>12.3f} {s['band_1sigma']:>11.3f} {s['width_99']:>11.3f} "
|
||||
f"{s['slope_per_s']:>11.4f} {s['drift_ratio']:>11.3f}"
|
||||
)
|
||||
print(
|
||||
" slope/s = least-squares ramp (the drift rate)\n"
|
||||
" band1s = robust 1-sigma half-width about the median\n"
|
||||
" w99 = 0.5-99.5 percentile width (tail-resistant spread)\n"
|
||||
" drift = mean(last third) - mean(first third): moved, not just noisy"
|
||||
)
|
||||
|
||||
|
||||
def _analyse(rows: list[dict[str, str]], args: argparse.Namespace) -> int:
|
||||
times, kept = _window(
|
||||
rows, start=args.start, stop=args.stop, warmup=args.warmup
|
||||
)
|
||||
print(f"recording rows : {len(rows)}")
|
||||
print(f"analysed rows : {len(kept)}")
|
||||
if times.size:
|
||||
dt = np.diff(times)
|
||||
print(
|
||||
f"analysed window : {times[0]:.3f} .. {times[-1]:.3f} s "
|
||||
f"({times[-1] - times[0]:.3f} s)"
|
||||
)
|
||||
if dt.size and dt[dt > 0].size:
|
||||
rate = 1.0 / float(np.median(dt[dt > 0]))
|
||||
print(f"effective sample rate : {rate:.2f} Hz")
|
||||
if len(kept) < 30:
|
||||
print(
|
||||
"\nNOTE: fewer than 30 analysed rows. This cannot characterise a slow\n"
|
||||
" zero drift -- capture an idle run of at least several minutes."
|
||||
)
|
||||
|
||||
if args.unit in ("raw", "both"):
|
||||
_print_table(
|
||||
"raw counts (pre-calibration)",
|
||||
[_describe(axis, _column(kept, axis), times) for axis in RAW_AXES],
|
||||
"counts",
|
||||
)
|
||||
if args.unit in ("N", "both"):
|
||||
_print_table(
|
||||
"newtons (what the operator sees)",
|
||||
[_describe(axis, _column(kept, axis), times) for axis in NEWTON_AXES],
|
||||
"N",
|
||||
)
|
||||
|
||||
if args.out:
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(
|
||||
["axis", "unit", "n", "mean", "median", "stdev", "band_1sigma",
|
||||
"width_99", "slope_per_s", "drift_ratio"]
|
||||
)
|
||||
groups = []
|
||||
if args.unit in ("raw", "both"):
|
||||
groups.append((RAW_AXES, "counts"))
|
||||
if args.unit in ("N", "both"):
|
||||
groups.append((NEWTON_AXES, "N"))
|
||||
for axes, unit in groups:
|
||||
for axis in axes:
|
||||
s = _describe(axis, _column(kept, axis), times)
|
||||
if not s.get("n"):
|
||||
continue
|
||||
writer.writerow([
|
||||
s["axis"], unit, s["n"], f"{s['mean']:.6f}",
|
||||
f"{s['median']:.6f}", f"{s['stdev']:.6f}",
|
||||
f"{s['band_1sigma']:.6f}", f"{s['width_99']:.6f}",
|
||||
f"{s['slope_per_s']:.6f}", f"{s['drift_ratio']:.6f}",
|
||||
])
|
||||
print(f"\nwrote {out}")
|
||||
return 0
|
||||
|
||||
|
||||
def _capture_live(args: argparse.Namespace) -> int:
|
||||
"""Sample a real sensor with no load applied.
|
||||
|
||||
Uses only the public ``read_info``/``disconnect`` surface, so this keeps
|
||||
working if the internals move.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
from orisys import Sensor
|
||||
|
||||
sensor = Sensor(
|
||||
"external",
|
||||
config_name=args.config,
|
||||
verbose=args.verbose,
|
||||
backend=args.backend,
|
||||
)
|
||||
seconds = float(args.seconds)
|
||||
period = 1.0 / float(args.sample_rate)
|
||||
rows: list[tuple[float, float, float, float, float, float, float]] = []
|
||||
try:
|
||||
print(
|
||||
f"capturing {seconds:.0f}s at ~{args.sample_rate:.0f} Hz from config "
|
||||
f"{args.config!r} ({args.backend}); keep the sensor unloaded"
|
||||
)
|
||||
t0 = _time.monotonic()
|
||||
while True:
|
||||
elapsed = _time.monotonic() - t0
|
||||
if elapsed >= seconds:
|
||||
break
|
||||
raw = sensor.read_info(
|
||||
sensor.info.FNORMAL_RAW,
|
||||
sensor.info.FSHEARX_RAW,
|
||||
sensor.info.FSHEARY_RAW,
|
||||
)
|
||||
newtons = sensor.read_info(
|
||||
sensor.info.FNORMAL,
|
||||
sensor.info.FSHEARX,
|
||||
sensor.info.FSHEARY,
|
||||
)
|
||||
rows.append((elapsed, *[float(v) for v in raw], *[float(v) for v in newtons]))
|
||||
_time.sleep(max(0.0, period - ((_time.monotonic() - t0) - elapsed)))
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
if not rows:
|
||||
print("no samples captured (external-frame mode may need a frame source)")
|
||||
return 1
|
||||
|
||||
times = np.asarray([row[0] for row in rows], dtype=np.float64)
|
||||
print(f"\ncaptured {len(rows)} samples over {times[-1]:.1f}s")
|
||||
|
||||
if args.out:
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
path = out / "forces.csv"
|
||||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(
|
||||
["session_id", "frame_id", TIME_COLUMN, *RAW_AXES, *NEWTON_AXES,
|
||||
VALID_COLUMN, "calibration_id"]
|
||||
)
|
||||
for index, row in enumerate(rows):
|
||||
t_rel, *rest = row
|
||||
writer.writerow(
|
||||
[0, index, f"{t_rel:.6f}", *[f"{v:.6f}" for v in rest], 1, ""]
|
||||
)
|
||||
print(f"wrote {path}")
|
||||
return 0
|
||||
|
||||
_print_table(
|
||||
"live raw counts",
|
||||
[
|
||||
_describe(axis, np.asarray([row[1 + i] for row in rows]), times)
|
||||
for i, axis in enumerate(RAW_AXES)
|
||||
],
|
||||
"counts",
|
||||
)
|
||||
_print_table(
|
||||
"live newtons",
|
||||
[
|
||||
_describe(axis, np.asarray([row[4 + i] for row in rows]), times)
|
||||
for i, axis in enumerate(NEWTON_AXES)
|
||||
],
|
||||
"N",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Measure normal-force zero-point drift (Phase 0)."
|
||||
)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--recording", type=Path,
|
||||
help="recording directory or forces.csv path")
|
||||
source.add_argument("--live", action="store_true",
|
||||
help="capture from a Sensor instead (external-frame mode)")
|
||||
parser.add_argument("--seconds", type=float, default=1800.0,
|
||||
help="live capture length (default 1800)")
|
||||
parser.add_argument("--sample-rate", type=float, default=30.0,
|
||||
help="live polling rate Hz (default 30)")
|
||||
parser.add_argument("--config", default="box", help="sensor config for --live")
|
||||
parser.add_argument("--backend", default="cpu", help="poisson backend for --live")
|
||||
parser.add_argument("--unit", choices=("raw", "N", "both"), default="both")
|
||||
parser.add_argument("--start", type=float, default=None, help="window start (s)")
|
||||
parser.add_argument("--stop", type=float, default=None, help="window stop (s)")
|
||||
parser.add_argument("--warmup", type=float, default=0.0,
|
||||
help="discard this many seconds from the window start")
|
||||
parser.add_argument("--out", default=None,
|
||||
help="write the per-axis table to this CSV path, or the "
|
||||
"capture directory when used with --live")
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.live:
|
||||
return _capture_live(args)
|
||||
return _analyse(_load_rows(args.recording), args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {
|
||||
@@ -14,8 +14,22 @@
|
||||
[62000.0, 2.377]
|
||||
]
|
||||
},
|
||||
"shear": {
|
||||
"shear_x": {
|
||||
"method": "piecewise_linear",
|
||||
"raw_key": "fshearx",
|
||||
"input": "components",
|
||||
"signed": true,
|
||||
"extrapolate": true,
|
||||
"clamp_output_min": 0.0,
|
||||
"points": [
|
||||
[1000.0, 0.0],
|
||||
[36000.0, 1.19],
|
||||
[68000.0, 2.06]
|
||||
]
|
||||
},
|
||||
"shear_y": {
|
||||
"method": "piecewise_linear",
|
||||
"raw_key": "fsheary",
|
||||
"input": "components",
|
||||
"signed": true,
|
||||
"extrapolate": true,
|
||||
|
||||
@@ -29,10 +29,17 @@
|
||||
"loop_closure_interval": 1,
|
||||
"algorithm": "dis",
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 3,
|
||||
"dis_mode": 1,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 3
|
||||
},
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold":5,
|
||||
|
||||
@@ -22,7 +22,14 @@
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 1,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 1
|
||||
"hhd_downsample": 2
|
||||
},
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
|
||||
@@ -29,10 +29,17 @@
|
||||
"loop_closure_interval": 1,
|
||||
"algorithm": "dis",
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 3,
|
||||
"dis_mode": 1,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 3
|
||||
},
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold":5,
|
||||
|
||||
+20
-1
@@ -5,15 +5,18 @@ A Python library for processing and analyzing optical tactile sensor data,
|
||||
including image stitching, optical flow tracking, and deformation decomposition.
|
||||
"""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.5.3"
|
||||
__author__ = "Orisys"
|
||||
|
||||
from .sensor import Sensor
|
||||
from . import util
|
||||
from . import configs
|
||||
from . import calibration
|
||||
from . import finger
|
||||
from . import signal_processing
|
||||
from . import capture
|
||||
from .pipeline import FrameSnapshot, PipelineStats, StreamPipeline
|
||||
from .export import StreamRecorder, mosaic_videos
|
||||
from .frame_processors import FFCFrameProcessor
|
||||
|
||||
CompoundEyeSensor = Sensor
|
||||
@@ -22,6 +25,8 @@ CameraSensor = Sensor
|
||||
# Re-export list_configs as list_config for convenience
|
||||
list_config = configs.list_config
|
||||
export_config = configs.export_config
|
||||
export_sensor_bundle = configs.export_sensor_bundle
|
||||
export_sensor_bundles = configs.export_sensor_bundles
|
||||
|
||||
__all__ = [
|
||||
# class
|
||||
@@ -41,9 +46,23 @@ __all__ = [
|
||||
# capture helpers
|
||||
'capture',
|
||||
|
||||
# pipelined multi-stream processing
|
||||
'StreamPipeline',
|
||||
'FrameSnapshot',
|
||||
'PipelineStats',
|
||||
|
||||
# offline recording of arrow overlays
|
||||
'StreamRecorder',
|
||||
'mosaic_videos',
|
||||
|
||||
# force calibration
|
||||
'calibration',
|
||||
|
||||
# configs utilities functions
|
||||
'list_config',
|
||||
'export_config',
|
||||
'export_sensor_bundle',
|
||||
'export_sensor_bundles',
|
||||
|
||||
# test
|
||||
'FFCFrameProcessor'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+245
-18
@@ -1,4 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from importlib.resources import as_file, files
|
||||
@@ -19,34 +22,145 @@ def _resource_path(*parts: str) -> Path | None:
|
||||
"""Resolve a package resource to a filesystem path for wheel-safe loaders."""
|
||||
try:
|
||||
resource = files("orisys").joinpath(*parts)
|
||||
return _RESOURCE_STACK.enter_context(as_file(resource))
|
||||
resolved = _RESOURCE_STACK.enter_context(as_file(resource))
|
||||
except Exception:
|
||||
return None
|
||||
# ``as_file`` is not contractually a ``pathlib.Path``: a Nuitka-compiled
|
||||
# build hands back its own ``nuitka_resource_reader_files``, which is
|
||||
# path-like but has no ``.resolve()``. Normalise here so every caller can
|
||||
# treat the result as a Path.
|
||||
try:
|
||||
return Path(os.fspath(resolved))
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_config_dirs() -> list[Path]:
|
||||
def _unique_paths(paths) -> list[Path]:
|
||||
"""Ordered, de-duplicated paths (Windows comparison is case-insensitive)."""
|
||||
unique: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in paths:
|
||||
path = Path(path) if not isinstance(path, Path) else path
|
||||
try:
|
||||
key = str(path.resolve()).casefold()
|
||||
except (OSError, ValueError): # pragma: no cover - defensive
|
||||
key = str(path).casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(path)
|
||||
return unique
|
||||
|
||||
|
||||
def _is_sdk_runtime_root(path: Path) -> bool:
|
||||
"""Whether ``path`` carries the runnable SDK layout.
|
||||
|
||||
The anchor is the **folder layout**, never the folder's name: a distribution
|
||||
folder may be called anything (a release checkout, a renamed copy, an
|
||||
unpacked zip), but it always ships ``examples/`` beside ``sensors/``. That is
|
||||
what distinguishes a run root from an arbitrary directory, and it is why a
|
||||
renamed root keeps working while ``dist/**`` (``sensors/`` without
|
||||
``examples/``) is not mistaken for one.
|
||||
"""
|
||||
Candidate config directories for editable installs and packaged wheels.
|
||||
try:
|
||||
if not path.is_dir():
|
||||
return False
|
||||
return (path / "examples").is_dir() and (path / "sensors").is_dir()
|
||||
except OSError: # pragma: no cover - defensive: unreadable path
|
||||
return False
|
||||
|
||||
|
||||
def _runtime_roots() -> list[Path]:
|
||||
"""Runnable SDK roots for this process, nearest to the launcher first.
|
||||
|
||||
Evaluated once at import time, so a process that changes directory later
|
||||
keeps the answer it started with.
|
||||
"""
|
||||
anchors: list[Path] = []
|
||||
|
||||
def add(path: Path) -> None:
|
||||
if path not in anchors:
|
||||
anchors.append(path)
|
||||
|
||||
cwd = Path.cwd()
|
||||
add(cwd)
|
||||
|
||||
# A script run from a subdirectory (``<root>/examples/foo.py``, or a caller
|
||||
# that launched from ``<root>/scripts``) still names its root through argv[0].
|
||||
script_dir: Path | None = None
|
||||
try:
|
||||
argv0 = sys.argv[0] if sys.argv else ""
|
||||
if argv0:
|
||||
candidate = Path(argv0).expanduser()
|
||||
script_dir = candidate.resolve().parent if candidate.is_file() else None
|
||||
except (OSError, ValueError): # pragma: no cover - defensive
|
||||
script_dir = None
|
||||
|
||||
# Walk up from both, so the root is found however deep the launcher sits.
|
||||
for start in (cwd, script_dir):
|
||||
if start is None:
|
||||
continue
|
||||
for candidate in (start, *start.parents):
|
||||
add(candidate)
|
||||
|
||||
# Roots that put themselves on sys.path (``python <root>/examples/foo.py``
|
||||
# with the root as cwd, ``PYTHONPATH=<root>``, and similar).
|
||||
for entry in list(sys.path):
|
||||
if not entry:
|
||||
continue
|
||||
try:
|
||||
add(Path(entry).expanduser())
|
||||
except (OSError, ValueError): # pragma: no cover - defensive
|
||||
continue
|
||||
|
||||
anchors = _unique_paths(anchors)
|
||||
# The run root is the *nearest* runnable directory, walking up from the
|
||||
# launcher: starting at the cwd, then the script's directory, then the
|
||||
# entries sys.path. Order carries the meaning here -- containment does not.
|
||||
# Scanning every anchor for the shortest match instead picked the shallowest
|
||||
# ancestor, so a workspace that carried ``examples/`` alongside ``sensors/``
|
||||
# absorbed the real root below it and its bundle shadowed that root's own.
|
||||
roots: list[Path] = []
|
||||
for path in anchors:
|
||||
if not _is_sdk_runtime_root(path):
|
||||
continue
|
||||
if any(existing.is_relative_to(path) or path.is_relative_to(existing) for existing in roots):
|
||||
# Same tree: only the nearest root counts.
|
||||
continue
|
||||
roots.append(path)
|
||||
return roots
|
||||
|
||||
|
||||
def _candidate_config_dirs() -> list[Path]:
|
||||
"""Candidate config directories, the running SDK root first.
|
||||
|
||||
A run root's ``config/`` outranks the packaged copy so a bundle's
|
||||
``sensor.json`` and its ``force_calibration.json`` come from the same place.
|
||||
"""
|
||||
here = Path(__file__).resolve().parent
|
||||
candidates = [
|
||||
return _unique_paths([
|
||||
*(root / "config" for root in _runtime_roots()),
|
||||
here / "configs", # packaged wheel layout: orisys/configs/*.json
|
||||
here.parent.parent / "config", # editable install from repo: repo_root/config/*.json
|
||||
here.parent / "config", # fallback
|
||||
Path.cwd() / "config", # script launched from repo root
|
||||
]
|
||||
|
||||
unique: list[Path] = []
|
||||
for path in candidates:
|
||||
if path not in unique:
|
||||
unique.append(path)
|
||||
return unique
|
||||
])
|
||||
|
||||
|
||||
def _candidate_sensor_roots() -> list[Path]:
|
||||
"""Candidate roots that contain per-sensor bundle folders."""
|
||||
"""Candidate roots that contain per-sensor bundle folders.
|
||||
|
||||
Lookup order:
|
||||
|
||||
1. the ``sensors/`` of every runnable SDK root found at startup, nearest
|
||||
first -- so the bundle under the directory that is actually running wins,
|
||||
whatever that directory is called;
|
||||
2. the packaged resource ``orisys/sensors`` (wheel installs);
|
||||
3. the module-relative candidates (editable checkout, packaged tree);
|
||||
4. the current working directory, last, as a safety net.
|
||||
"""
|
||||
here = Path(__file__).resolve().parent
|
||||
candidates = []
|
||||
candidates = [root / "sensors" for root in _runtime_roots()]
|
||||
packaged = _resource_path("sensors")
|
||||
if packaged is not None:
|
||||
candidates.append(packaged) # packaged wheel layout: orisys/sensors/
|
||||
@@ -56,11 +170,7 @@ def _candidate_sensor_roots() -> list[Path]:
|
||||
here.parent / "sensors",
|
||||
Path.cwd() / "sensors",
|
||||
])
|
||||
unique: list[Path] = []
|
||||
for path in candidates:
|
||||
if path not in unique:
|
||||
unique.append(path)
|
||||
return unique
|
||||
return _unique_paths(candidates)
|
||||
|
||||
|
||||
def _get_config_dir() -> Path:
|
||||
@@ -129,6 +239,44 @@ def resolve_sensor_bundle(name: str) -> SensorBundle | None:
|
||||
return None
|
||||
|
||||
|
||||
def describe_bundle_resolution(name: str) -> dict:
|
||||
"""Explain how ``name`` resolved, so a missing calibration stops being silent.
|
||||
|
||||
A bundle can resolve and still carry no ``force_calibration.json``: the
|
||||
lookup stops at the first root that has ``<name>/sensor.json`` and never
|
||||
asks whether that root also has the coefficients. Reporting which bundle was
|
||||
found -- and which roots were passed over -- is what makes that case
|
||||
distinguishable from "no bundle at all", which is otherwise what the caller
|
||||
sees.
|
||||
|
||||
Returns ``name``, the ``searched`` roots in order, and the first root that
|
||||
supplied ``<name>/sensor.json`` together with whether it carried a
|
||||
``force_calibration.json``.
|
||||
"""
|
||||
requested = CONFIG_ALIASES.get(str(name), str(name))
|
||||
searched = _candidate_sensor_roots()
|
||||
for root in searched:
|
||||
sensor_json = root / requested / "sensor.json"
|
||||
if not sensor_json.is_file():
|
||||
continue
|
||||
return {
|
||||
"name": requested,
|
||||
"searched": searched,
|
||||
"found_root": sensor_json.parent,
|
||||
"sensor_json": sensor_json,
|
||||
"force_calibration_json": (
|
||||
sensor_json.parent / "force_calibration.json"
|
||||
).is_file(),
|
||||
}
|
||||
return {
|
||||
"name": requested,
|
||||
"searched": searched,
|
||||
"found_root": None,
|
||||
"sensor_json": None,
|
||||
"force_calibration_json": False,
|
||||
}
|
||||
|
||||
|
||||
def resolve_sensor_config_path(name: str) -> Path | None:
|
||||
"""Return ``sensor.json`` for a resolved sensor bundle."""
|
||||
bundle = resolve_sensor_bundle(name)
|
||||
@@ -166,6 +314,85 @@ def load_force_calibration(name: str) -> dict | None:
|
||||
return _load_json(bundle.force_calibration_json)
|
||||
|
||||
|
||||
_BUNDLE_JSON_FILES = ("sensor.json", "force_calibration.json", "manifest.json")
|
||||
|
||||
|
||||
def export_sensor_bundle(
|
||||
name: str,
|
||||
dest_dir: str | Path,
|
||||
*,
|
||||
include_assets: bool = False,
|
||||
overwrite: bool = True,
|
||||
) -> Path:
|
||||
"""Copy one resolved ``sensors/<name>/`` bundle into ``dest_dir/<name>/``.
|
||||
|
||||
By default only JSON metadata is copied (``sensor.json``, and when present
|
||||
``force_calibration.json`` / ``manifest.json``). Pass ``include_assets=True``
|
||||
to copy the full tree (including ``assets/``) for packaging finger meshes.
|
||||
"""
|
||||
bundle = resolve_sensor_bundle(name)
|
||||
if bundle is None:
|
||||
available = ", ".join(list_sensor_bundles()) or "none"
|
||||
raise FileNotFoundError(
|
||||
f"Sensor bundle not found: {name!r}. Available: {available}"
|
||||
)
|
||||
|
||||
dest_root = Path(dest_dir).expanduser().resolve()
|
||||
dest = dest_root / bundle.name
|
||||
if dest.exists():
|
||||
if not overwrite:
|
||||
raise FileExistsError(f"Destination already exists: {dest}")
|
||||
if dest.is_dir():
|
||||
shutil.rmtree(dest)
|
||||
else:
|
||||
dest.unlink()
|
||||
|
||||
if include_assets:
|
||||
shutil.copytree(bundle.root, dest)
|
||||
return dest
|
||||
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for filename in _BUNDLE_JSON_FILES:
|
||||
src = bundle.root / filename
|
||||
if src.is_file():
|
||||
shutil.copy2(src, dest / filename)
|
||||
if not (dest / "sensor.json").is_file():
|
||||
raise FileNotFoundError(f"sensor.json missing after export from {bundle.root}")
|
||||
return dest
|
||||
|
||||
|
||||
def export_sensor_bundles(
|
||||
dest_dir: str | Path,
|
||||
*,
|
||||
names: list[str] | tuple[str, ...] | None = None,
|
||||
include_assets: bool = False,
|
||||
overwrite: bool = True,
|
||||
) -> list[Path]:
|
||||
"""Export several sensor bundles into ``dest_dir``.
|
||||
|
||||
When ``names`` is omitted, exports every name in ``PRODUCT_BUNDLES`` that
|
||||
currently resolves via :func:`resolve_sensor_bundle`.
|
||||
"""
|
||||
if names is None:
|
||||
selected = [name for name in PRODUCT_BUNDLES if resolve_sensor_bundle(name) is not None]
|
||||
else:
|
||||
selected = [str(name) for name in names]
|
||||
if not selected:
|
||||
raise FileNotFoundError("No sensor bundles selected for export")
|
||||
|
||||
exported: list[Path] = []
|
||||
for name in selected:
|
||||
exported.append(
|
||||
export_sensor_bundle(
|
||||
name,
|
||||
dest_dir,
|
||||
include_assets=include_assets,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
)
|
||||
return exported
|
||||
|
||||
|
||||
def load_config(config_name_or_path):
|
||||
"""
|
||||
Load sensor configuration from a JSON file path or config name.
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Record tactile arrow overlays to video without stalling the pipeline.
|
||||
|
||||
Drawing the arrow grid costs a few milliseconds per frame (hundreds of
|
||||
``cv2.arrowedLine`` calls) and encoding costs a few more, so doing it inline
|
||||
would cut a stream's throughput. Instead:
|
||||
|
||||
* :meth:`StreamRecorder.on_frame` is designed to be the pipeline's ``on_frame``
|
||||
callback: it only performs a bounded, drop-oldest queue push, so the slowest
|
||||
pipeline stage is unaffected and the run never blocks on the encoder;
|
||||
* a dedicated writer thread does the drawing and the ``cv2.VideoWriter`` calls;
|
||||
* when the writer falls behind, the newest frames win and the rest are counted
|
||||
in :meth:`StreamRecorder.stats` so a recording gap is never silent.
|
||||
|
||||
Typical use::
|
||||
|
||||
recorder = StreamRecorder("run/stream_0.mp4", label="#0",
|
||||
valid_mask=lambda: runtime.valid_mask)
|
||||
pipe = StreamPipeline(sensor, produce, include=("image", "flow"),
|
||||
on_frame=recorder.on_frame)
|
||||
pipe.start()
|
||||
...
|
||||
print(recorder.close()) # written / dropped / fps
|
||||
|
||||
Per-stream files are the natural unit because each stream is its own process;
|
||||
:func:`mosaic_videos` tiles them into one grid afterwards, which costs nothing
|
||||
during the run.
|
||||
|
||||
Arrow parameters
|
||||
----------------
|
||||
``draw_arrows`` divides the sampled displacement by ``arrow_scale``, so the
|
||||
defaults assume the finger's UV domain, where the active-region displacement is
|
||||
only ~1-3 px: ``grid_spacing=14, arrow_scale=0.15, threshold=0.1`` magnifies it
|
||||
~6.7x, which is what makes the direction field legible instead of a scatter of
|
||||
dots (the ``draw_arrows`` defaults of ``arrow_scale=1.0, threshold=5`` render the
|
||||
raw pixel displacement and are tuned for large-motion scenes). Length saturates
|
||||
at ``grid_spacing * margin`` px, so colour - not length - carries the magnitude.
|
||||
A box/mini stream at 365x365 needs its own calibration of ``arrow_scale``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .util import draw_arrows, draw_contact
|
||||
|
||||
__all__ = ["StreamRecorder", "mosaic_videos"]
|
||||
|
||||
|
||||
class StreamRecorder:
|
||||
"""Draw arrow overlays from snapshots and encode them to a video file."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
fps: float = 30.0,
|
||||
every: int = 1,
|
||||
fourcc: str = "mp4v",
|
||||
valid_mask: np.ndarray | Callable[[], np.ndarray | None] | None = None,
|
||||
grid_spacing: int = 14,
|
||||
threshold: float = 0.1,
|
||||
arrow_scale: float = 0.15,
|
||||
margin: float = 0.8,
|
||||
draw_contact: bool = False,
|
||||
label: str | None = None,
|
||||
overlay_values: bool = True,
|
||||
queue_depth: int = 8,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
self.path = str(path)
|
||||
self.fps = max(1.0, float(fps))
|
||||
self.every = max(1, int(every))
|
||||
self.fourcc = fourcc
|
||||
# A callable is resolved once per frame: the SDK rebuilds
|
||||
# ``Sensor.valid_mask`` during ``get_img``, so holding an array taken
|
||||
# before the first frame would capture ``None``.
|
||||
self.valid_mask = valid_mask
|
||||
self.grid_spacing = int(grid_spacing)
|
||||
self.threshold = float(threshold)
|
||||
self.arrow_scale = float(arrow_scale)
|
||||
self.margin = float(margin)
|
||||
self.draw_contact_flag = bool(draw_contact)
|
||||
self.label = label
|
||||
self.overlay_values = bool(overlay_values)
|
||||
self.verbose = bool(verbose)
|
||||
|
||||
self._queue: queue.Queue = queue.Queue(maxsize=max(1, int(queue_depth)))
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._seen = 0
|
||||
self._written = 0
|
||||
self._dropped = 0
|
||||
self._skipped = 0
|
||||
self._error: BaseException | None = None
|
||||
self._writer: cv2.VideoWriter | None = None
|
||||
self._size: tuple[int, int] | None = None
|
||||
|
||||
# ------------------------------------------------------------------ ingest
|
||||
def on_frame(self, snap: Any) -> None:
|
||||
"""Pipeline callback: enqueue one snapshot. Never blocks."""
|
||||
self._seen += 1
|
||||
if self.every > 1 and (self._seen - 1) % self.every:
|
||||
self._skipped += 1
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
self._queue.put_nowait(snap)
|
||||
break
|
||||
except queue.Full:
|
||||
# Drop the oldest so the newest frame is always kept.
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
self._dropped += 1
|
||||
except queue.Empty:
|
||||
pass
|
||||
self._ensure_thread()
|
||||
|
||||
def _ensure_thread(self) -> None:
|
||||
if self._thread is None:
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True,
|
||||
name="orisys-recorder")
|
||||
self._thread.start()
|
||||
|
||||
# ------------------------------------------------------------------ writer
|
||||
def _open(self, frame: np.ndarray) -> None:
|
||||
h, w = frame.shape[:2]
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or ".", exist_ok=True)
|
||||
fourcc = cv2.VideoWriter_fourcc(*self.fourcc) if len(self.fourcc) == 4 else 0
|
||||
writer = cv2.VideoWriter(self.path, fourcc, self.fps, (w, h), True)
|
||||
if not writer.isOpened():
|
||||
raise RuntimeError(
|
||||
f"cv2.VideoWriter could not open {self.path!r} with fourcc={self.fourcc!r}"
|
||||
)
|
||||
self._writer = writer
|
||||
self._size = (w, h)
|
||||
if self.verbose:
|
||||
print(f"[recorder] {self.path} {w}x{h} @ {self.fps:g} fps fourcc={self.fourcc}")
|
||||
|
||||
def _render(self, snap: Any) -> np.ndarray:
|
||||
if snap.image is None or snap.flow is None:
|
||||
raise RuntimeError(
|
||||
"recording needs image and flow in the snapshot; build the pipeline "
|
||||
'with include=("image", "flow")'
|
||||
)
|
||||
mask = self.valid_mask() if callable(self.valid_mask) else self.valid_mask
|
||||
image = draw_arrows(
|
||||
snap.image,
|
||||
snap.flow,
|
||||
threshold=self.threshold,
|
||||
grid_spacing=self.grid_spacing,
|
||||
arrow_scale=self.arrow_scale,
|
||||
margin=self.margin,
|
||||
valid_mask=mask,
|
||||
)
|
||||
if self.draw_contact_flag and snap.contour is not None and snap.centroid is not None:
|
||||
image = draw_contact(image, snap.contour, snap.centroid)
|
||||
if self.overlay_values or self.label:
|
||||
self._draw_hud(image, snap)
|
||||
return image
|
||||
|
||||
def _draw_hud(self, image: np.ndarray, snap: Any) -> None:
|
||||
lines = []
|
||||
if self.label:
|
||||
lines.append(str(self.label))
|
||||
if self.overlay_values:
|
||||
lines.append(f"{snap.unit} fn={snap.normal_force:.3g} "
|
||||
f"fx={snap.shear_x:.3g} fy={snap.shear_y:.3g}")
|
||||
lines.append(f"{snap.fps:.0f} FPS {snap.age_ms:.1f} ms")
|
||||
y = 14
|
||||
for text in lines:
|
||||
cv2.putText(image, text, (4, y), cv2.FONT_HERSHEY_SIMPLEX, 0.38,
|
||||
(0, 0, 0), 3, cv2.LINE_AA)
|
||||
cv2.putText(image, text, (4, y), cv2.FONT_HERSHEY_SIMPLEX, 0.38,
|
||||
(255, 255, 255), 1, cv2.LINE_AA)
|
||||
y += 14
|
||||
|
||||
def _loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
snap = self._queue.get(timeout=0.05)
|
||||
except queue.Empty:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
continue
|
||||
if snap is None:
|
||||
break
|
||||
try:
|
||||
frame = self._render(snap)
|
||||
if self._writer is None:
|
||||
self._open(frame)
|
||||
self._writer.write(frame)
|
||||
self._written += 1
|
||||
except BaseException as exc:
|
||||
self._error = exc
|
||||
break
|
||||
finally:
|
||||
if self._writer is not None:
|
||||
self._writer.release()
|
||||
self._writer = None
|
||||
|
||||
# ------------------------------------------------------------------- close
|
||||
def close(self, timeout: float = 30.0) -> dict:
|
||||
"""Drain, stop the writer thread and release the file."""
|
||||
if self._thread is not None:
|
||||
self._stop.set()
|
||||
# The stop flag is the real mechanism - the writer drains what is
|
||||
# queued and exits on its next empty poll. The sentinel only makes
|
||||
# that immediate, so it must never block: if the writer died on an
|
||||
# error the queue may be full and nobody would ever drain it.
|
||||
try:
|
||||
self._queue.put_nowait(None)
|
||||
except queue.Full:
|
||||
pass
|
||||
self._thread.join(timeout=timeout)
|
||||
alive = self._thread.is_alive()
|
||||
self._thread = None
|
||||
else:
|
||||
alive = False
|
||||
stats = self.stats()
|
||||
stats["thread_alive"] = alive
|
||||
if self._error is not None:
|
||||
stats["error"] = f"{type(self._error).__name__}: {self._error}"
|
||||
if self.verbose:
|
||||
print(f"[recorder] {self.path}: {stats['error']}")
|
||||
return stats
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"path": self.path,
|
||||
"seen": self._seen,
|
||||
"written": self._written,
|
||||
"dropped": self._dropped,
|
||||
"skipped": self._skipped,
|
||||
"fps": self.fps,
|
||||
"fourcc": self.fourcc,
|
||||
}
|
||||
|
||||
def __enter__(self) -> "StreamRecorder":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
self.close()
|
||||
return False
|
||||
|
||||
|
||||
def mosaic_videos(
|
||||
paths: Sequence[str],
|
||||
out_path: str,
|
||||
*,
|
||||
cols: int | None = None,
|
||||
labels: Iterable[str] | None = None,
|
||||
scale: float = 1.0,
|
||||
gap: int = 4,
|
||||
fps: float | None = None,
|
||||
fourcc: str = "mp4v",
|
||||
) -> dict:
|
||||
"""Tile per-stream recordings into one grid video.
|
||||
|
||||
Post-processing only: run it after the streams have finished, so it costs
|
||||
nothing while they are running. Shorter inputs hold their last frame.
|
||||
"""
|
||||
paths = [str(p) for p in paths]
|
||||
if not paths:
|
||||
raise ValueError("mosaic_videos needs at least one input")
|
||||
for path in paths:
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
caps = [cv2.VideoCapture(p) for p in paths]
|
||||
try:
|
||||
for path, cap in zip(paths, caps):
|
||||
if not cap.isOpened():
|
||||
raise RuntimeError(f"cannot open {path!r}")
|
||||
tiles = []
|
||||
for cap in caps:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
raise RuntimeError("an input video has no frames")
|
||||
tiles.append(frame)
|
||||
|
||||
if scale != 1.0:
|
||||
tiles = [cv2.resize(t, None, fx=scale, fy=scale,
|
||||
interpolation=cv2.INTER_AREA) for t in tiles]
|
||||
th, tw = tiles[0].shape[:2]
|
||||
n = len(tiles)
|
||||
cols = int(cols) if cols else int(np.ceil(np.sqrt(n)))
|
||||
cols = max(1, min(cols, n))
|
||||
rows = int(np.ceil(n / cols))
|
||||
label_list = [str(x) for x in labels] if labels is not None else []
|
||||
|
||||
out_h = rows * th + (rows - 1) * gap
|
||||
out_w = cols * tw + (cols - 1) * gap
|
||||
canvas = np.zeros((out_h, out_w, 3), dtype=np.uint8)
|
||||
positions = []
|
||||
for i in range(n):
|
||||
r, c = divmod(i, cols)
|
||||
positions.append((r * (th + gap), c * (tw + gap)))
|
||||
# Frame 0 is already in hand; seed the canvas with it so the output is
|
||||
# never short by one frame.
|
||||
for i, tile in enumerate(tiles):
|
||||
y, x = positions[i]
|
||||
canvas[y:y + th, x:x + tw] = tile
|
||||
|
||||
out_fps = float(fps) if fps else float(caps[0].get(cv2.CAP_PROP_FPS) or 30.0)
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
|
||||
writer = cv2.VideoWriter(out_path, cv2.VideoWriter_fourcc(*fourcc), out_fps,
|
||||
(out_w, out_h), True)
|
||||
if not writer.isOpened():
|
||||
raise RuntimeError(f"cannot open {out_path!r} for writing")
|
||||
|
||||
def emit() -> None:
|
||||
# Labels go on the canvas, not on the tiles: the read loop below
|
||||
# overwrites each tile region every frame.
|
||||
for i, text in enumerate(label_list):
|
||||
if i >= n:
|
||||
break
|
||||
y, x = positions[i]
|
||||
org = (x + 4, y + th - 6)
|
||||
cv2.putText(canvas, text, org, cv2.FONT_HERSHEY_SIMPLEX, 0.45,
|
||||
(0, 0, 0), 3, cv2.LINE_AA)
|
||||
cv2.putText(canvas, text, org, cv2.FONT_HERSHEY_SIMPLEX, 0.45,
|
||||
(255, 255, 255), 1, cv2.LINE_AA)
|
||||
writer.write(canvas)
|
||||
|
||||
written = 0
|
||||
try:
|
||||
# One frame per input per iteration; a spent input keeps its last
|
||||
# frame so the grid stays full until every input has run out.
|
||||
while True:
|
||||
emit()
|
||||
written += 1
|
||||
progressed = False
|
||||
for i, cap in enumerate(caps):
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
continue
|
||||
progressed = True
|
||||
if scale != 1.0:
|
||||
frame = cv2.resize(frame, (tw, th), interpolation=cv2.INTER_AREA)
|
||||
y, x = positions[i]
|
||||
canvas[y:y + th, x:x + tw] = frame
|
||||
if not progressed:
|
||||
break
|
||||
finally:
|
||||
writer.release()
|
||||
finally:
|
||||
for cap in caps:
|
||||
cap.release()
|
||||
|
||||
return {
|
||||
"path": out_path,
|
||||
"frames": written,
|
||||
"fps": out_fps,
|
||||
"size": (out_w, out_h),
|
||||
"grid": (rows, cols),
|
||||
"scale": scale,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""CLI: export packaged sensor bundles to a destination folder.
|
||||
|
||||
Examples::
|
||||
|
||||
python -m orisys.export_sensors --dest ./out/sensors --names box,mini
|
||||
python -m orisys.export_sensors --dest ./out/sensors --include-assets
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .configs import export_sensor_bundles, list_sensor_bundles
|
||||
|
||||
|
||||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Export orisys sensors/<name>/ bundles to a destination directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dest",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Destination directory (creates <dest>/<name>/ for each bundle)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--names",
|
||||
default="",
|
||||
help="Comma-separated bundle names (default: all resolvable product bundles)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-assets",
|
||||
action="store_true",
|
||||
help="Copy full bundle trees including assets/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-overwrite",
|
||||
action="store_true",
|
||||
help="Fail if a destination bundle folder already exists",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(argv)
|
||||
names = [part.strip() for part in str(args.names).split(",") if part.strip()] or None
|
||||
try:
|
||||
exported = export_sensor_bundles(
|
||||
args.dest,
|
||||
names=names,
|
||||
include_assets=bool(args.include_assets),
|
||||
overwrite=not bool(args.no_overwrite),
|
||||
)
|
||||
except (FileNotFoundError, FileExistsError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
available = list_sensor_bundles()
|
||||
if available:
|
||||
print(f"available bundles: {', '.join(available)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for path in exported:
|
||||
print(f"exported {path}")
|
||||
print(f"exported {len(exported)} bundle(s) to {Path(args.dest).resolve()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -293,6 +293,15 @@ class FingerRuntime:
|
||||
capture = capture_source.cap
|
||||
|
||||
sensor = Sensor("external", config_name=config, verbose=verbose, mirror=mirror)
|
||||
if not is_live_camera(video):
|
||||
# File playback: wall-clock cadence is not signal time, so pin the
|
||||
# force-filter design rate to the configured camera fps.
|
||||
try:
|
||||
fps = float(geometry.config.get("camera", {}).get("fps", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
fps = 0.0
|
||||
if fps > 0:
|
||||
sensor.configure(lowpass_sample_rate_hz=fps)
|
||||
frame_processor = FrameUnwarper(
|
||||
domain.remap,
|
||||
camera_matrix=geometry.source_K if geometry.dist_coeffs is not None else None,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
@@ -12,11 +14,24 @@ class CompoundEyeFrameProcessor:
|
||||
|
||||
def __init__(self, stitcher):
|
||||
self.stitcher = stitcher
|
||||
# Per-call sub-stage timings (seconds), read by Sensor.get_img() for
|
||||
# _last_stage_times. Keys: units, extract_tiles, stitch.
|
||||
self.last_timings = {}
|
||||
|
||||
def process(self, frame):
|
||||
t0 = time.perf_counter()
|
||||
self.stitcher.get_stitch_unit(frame)
|
||||
t1 = time.perf_counter()
|
||||
self.stitcher.extract_tiles()
|
||||
return self.stitcher.stitch_rt_optimized()
|
||||
t2 = time.perf_counter()
|
||||
stitched = self.stitcher.stitch_rt_optimized()
|
||||
t3 = time.perf_counter()
|
||||
self.last_timings = {
|
||||
"units": t1 - t0,
|
||||
"extract_tiles": t2 - t1,
|
||||
"stitch": t3 - t2,
|
||||
}
|
||||
return stitched
|
||||
|
||||
|
||||
class SingleCameraFrameProcessor:
|
||||
|
||||
@@ -73,8 +73,49 @@ class nHHD(object):
|
||||
raise ValueError("nHHD Solver works only for 2 and 3 dimensions")
|
||||
|
||||
self._force_cpu_cache = None
|
||||
self._force_ref_shape = None
|
||||
ref_shape = kwargs.get('force_reference_grid')
|
||||
if ref_shape is not None:
|
||||
try:
|
||||
ref_h, ref_w = int(ref_shape[0]), int(ref_shape[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
ref_h = ref_w = 0
|
||||
if ref_h > 0 and ref_w > 0:
|
||||
self._force_ref_shape = (ref_h, ref_w)
|
||||
self.psolver.prepare(False)
|
||||
|
||||
def _force_scale(self, shape):
|
||||
"""Area/linear weights mapping grid sums at ``shape`` to the reference grid.
|
||||
|
||||
``Sn``/``Stx``/``Sty``/``Stor`` are *sums over grid pixels*, and the
|
||||
torque term additionally uses pixel-index moment arms. When the flow is
|
||||
downsampled by ``hhd_downsample=k`` before decomposition, the same
|
||||
physical deformation therefore reports roughly ``1/k^2`` of the force
|
||||
magnitude (and ``1/k^3`` of the torque) unless the sums are rescaled
|
||||
back onto the reference grid.
|
||||
|
||||
returns: (area_scale, linear_scale); (1.0, 1.0) when no reference grid
|
||||
was configured or the current grid already is the reference.
|
||||
"""
|
||||
ref = self._force_ref_shape
|
||||
if ref is None or (int(shape[0]), int(shape[1])) == ref:
|
||||
return 1.0, 1.0
|
||||
cur_area = float(shape[0]) * float(shape[1])
|
||||
if cur_area <= 0.0:
|
||||
return 1.0, 1.0
|
||||
area_scale = (float(ref[0]) * float(ref[1])) / cur_area
|
||||
return area_scale, float(numpy.sqrt(area_scale))
|
||||
|
||||
def _apply_force_scale(self, shape):
|
||||
"""Rescale aggregate force sums from a downsampled grid to the reference grid."""
|
||||
area_scale, linear_scale = self._force_scale(shape)
|
||||
if area_scale == 1.0 and linear_scale == 1.0:
|
||||
return
|
||||
self.Sn = float(self.Sn) * area_scale
|
||||
self.Stx = float(self.Stx) * area_scale
|
||||
self.Sty = float(self.Sty) * area_scale
|
||||
self.Stor = float(self.Stor) * area_scale * linear_scale
|
||||
|
||||
def _normalize_mask(self, valid_mask, shape):
|
||||
if valid_mask is None:
|
||||
return None
|
||||
@@ -402,6 +443,8 @@ class nHHD(object):
|
||||
cross_n = numpy.where(mask, cross_n, 0)
|
||||
self.Stor = numpy.sum(cross_p) + numpy.sum(cross_n)
|
||||
|
||||
self._apply_force_scale(shape)
|
||||
|
||||
# Optimized: pre-allocate output array with explicit dtype
|
||||
output = numpy.array([self.Sn, self.Stx, self.Sty, self.Stor], dtype=numpy.float32)
|
||||
|
||||
@@ -515,6 +558,8 @@ class nHHD(object):
|
||||
))
|
||||
|
||||
self.Sn, self.Stx, self.Sty, self.Stor = Sn, Stx, Sty, Stor
|
||||
self._apply_force_scale(shape)
|
||||
Sn, Stx, Sty, Stor = self.Sn, self.Stx, self.Sty, self.Stor
|
||||
output = numpy.array([Sn, Stx, Sty, Stor], dtype=numpy.float32)
|
||||
|
||||
# Keep stable CPU copies for get_normal()/get_shear() so callers get this frame's
|
||||
|
||||
+59
-10
@@ -29,6 +29,39 @@ def compute_boundary_mask(active):
|
||||
return active & ~fully_surrounded
|
||||
|
||||
|
||||
def build_masked_factor(lap):
|
||||
"""Factorize the masked Laplacian for fast repeated triangular solves.
|
||||
|
||||
The reduced system is a symmetric M-matrix (graph Laplacian of the active
|
||||
pixels with an extra Dirichlet pin), so SuperLU can skip partial pivoting
|
||||
(``SymmetricMode`` + ``diag_pivot_thresh=0``), which is ~2x faster than the
|
||||
default pivoting factorization. Factoring in float32 halves the memory
|
||||
traffic of the triangular solves; relative error against the float64
|
||||
reference stays around 1e-7 for these systems.
|
||||
|
||||
``rhs_dtype`` is returned because ``SuperLU.solve`` refuses to cast a
|
||||
float64 right-hand side down to a float32 factor.
|
||||
|
||||
lap: (N, N) symmetric sparse Laplacian (any scipy dtype)
|
||||
returns: (solve_callable, rhs_dtype)
|
||||
"""
|
||||
lap_csc = lap.tocsc()
|
||||
lap32 = lap_csc.astype(numpy.float32, copy=False)
|
||||
for diag_pivot_thresh in (0.0, 1.0):
|
||||
try:
|
||||
lu = sparse_linalg.splu(
|
||||
lap32,
|
||||
permc_spec="MMD_AT_PLUS_A",
|
||||
diag_pivot_thresh=diag_pivot_thresh,
|
||||
options=dict(SymmetricMode=True),
|
||||
)
|
||||
return lu.solve, numpy.float32
|
||||
except Exception:
|
||||
continue
|
||||
# Last resort: the original pivoting LU on the float64 matrix.
|
||||
return sparse_linalg.factorized(lap_csc), numpy.float64
|
||||
|
||||
|
||||
def sample_boundary_coords(boundary_coords, config: MaskedPoissonConfig):
|
||||
"""Subsample boundary pixel coordinates for capacitance correction."""
|
||||
if boundary_coords.size == 0:
|
||||
@@ -53,6 +86,7 @@ class MaskedDomain:
|
||||
"mask",
|
||||
"index_map",
|
||||
"factor",
|
||||
"factor_dtype",
|
||||
"lap",
|
||||
"num_active",
|
||||
"pinned",
|
||||
@@ -75,11 +109,13 @@ class MaskedDomain:
|
||||
pinned,
|
||||
active_coords,
|
||||
active_linear_indices,
|
||||
factor_dtype=numpy.float32,
|
||||
):
|
||||
self.shape = shape
|
||||
self.mask = mask
|
||||
self.index_map = index_map
|
||||
self.factor = factor
|
||||
self.factor_dtype = numpy.dtype(factor_dtype)
|
||||
self.lap = lap
|
||||
self.num_active = num_active
|
||||
self.pinned = pinned
|
||||
@@ -156,7 +192,7 @@ class MaskedDomain:
|
||||
data.append(diag)
|
||||
|
||||
lap = sparse.csc_matrix((data, (rows, cols)), shape=(num_active, num_active), dtype=numpy.float64)
|
||||
factor = sparse_linalg.factorized(lap)
|
||||
factor, factor_dtype = build_masked_factor(lap)
|
||||
return cls(
|
||||
shape=active.shape,
|
||||
mask=active.copy(),
|
||||
@@ -167,6 +203,7 @@ class MaskedDomain:
|
||||
pinned=pinned,
|
||||
active_coords=active_coords,
|
||||
active_linear_indices=active_linear_indices,
|
||||
factor_dtype=factor_dtype,
|
||||
)
|
||||
|
||||
def matches(self, mask):
|
||||
@@ -179,13 +216,20 @@ class MaskedDomain:
|
||||
|
||||
f: (H, W) scalar source field
|
||||
returns: (N,) RHS on active pixels; empty if no active pixels
|
||||
|
||||
The returned dtype matches the factorization (see ``factor_dtype``):
|
||||
``SuperLU.solve`` will not downcast a float64 RHS for a float32 factor.
|
||||
"""
|
||||
if self.num_active == 0:
|
||||
return numpy.zeros(0, dtype=numpy.float64)
|
||||
if self._rhs_buffer is None or self._rhs_buffer.shape != (self.num_active,):
|
||||
self._rhs_buffer = numpy.empty(self.num_active, dtype=numpy.float64)
|
||||
return numpy.zeros(0, dtype=self.factor_dtype)
|
||||
if (
|
||||
self._rhs_buffer is None
|
||||
or self._rhs_buffer.shape != (self.num_active,)
|
||||
or self._rhs_buffer.dtype != self.factor_dtype
|
||||
):
|
||||
self._rhs_buffer = numpy.empty(self.num_active, dtype=self.factor_dtype)
|
||||
numpy.take(
|
||||
numpy.asarray(f, dtype=numpy.float64).ravel(),
|
||||
numpy.asarray(f, dtype=self.factor_dtype).ravel(),
|
||||
self.active_linear_indices,
|
||||
out=self._rhs_buffer,
|
||||
)
|
||||
@@ -197,11 +241,15 @@ class MaskedDomain:
|
||||
def prepare_rhs_pair(self, f0, f1):
|
||||
"""Extract two gauge-fixed RHS vectors into one reusable (N, 2) buffer."""
|
||||
if self.num_active == 0:
|
||||
return numpy.zeros((0, 2), dtype=numpy.float64)
|
||||
if self._rhs_pair_buffer is None or self._rhs_pair_buffer.shape != (self.num_active, 2):
|
||||
self._rhs_pair_buffer = numpy.empty((self.num_active, 2), dtype=numpy.float64)
|
||||
flat0 = numpy.asarray(f0, dtype=numpy.float64).ravel()
|
||||
flat1 = numpy.asarray(f1, dtype=numpy.float64).ravel()
|
||||
return numpy.zeros((0, 2), dtype=self.factor_dtype)
|
||||
if (
|
||||
self._rhs_pair_buffer is None
|
||||
or self._rhs_pair_buffer.shape != (self.num_active, 2)
|
||||
or self._rhs_pair_buffer.dtype != self.factor_dtype
|
||||
):
|
||||
self._rhs_pair_buffer = numpy.empty((self.num_active, 2), dtype=self.factor_dtype)
|
||||
flat0 = numpy.asarray(f0, dtype=self.factor_dtype).ravel()
|
||||
flat1 = numpy.asarray(f1, dtype=self.factor_dtype).ravel()
|
||||
numpy.take(flat0, self.active_linear_indices, out=self._rhs_pair_buffer[:, 0])
|
||||
numpy.take(flat1, self.active_linear_indices, out=self._rhs_pair_buffer[:, 1])
|
||||
self._rhs_pair_buffer -= self._rhs_pair_buffer.mean(axis=0, keepdims=True)
|
||||
@@ -255,6 +303,7 @@ class MaskedDomain:
|
||||
"mask": self.mask,
|
||||
"index_map": self.index_map,
|
||||
"factor": self.factor,
|
||||
"factor_dtype": self.factor_dtype,
|
||||
"lap": self.lap,
|
||||
"num_active": self.num_active,
|
||||
"pinned": self.pinned,
|
||||
|
||||
@@ -33,7 +33,11 @@ class MaskedSparseSolver:
|
||||
try:
|
||||
sol_pair = -domain.factor(rhs_pair) # (N, 2) reduced-system potentials
|
||||
except Exception:
|
||||
# Some factorizations need contiguous 1-D right-hand sides.
|
||||
sol_pair = -numpy.column_stack(
|
||||
[domain.factor(rhs_pair[:, 0]), domain.factor(rhs_pair[:, 1])]
|
||||
[
|
||||
domain.factor(numpy.ascontiguousarray(rhs_pair[:, 0])),
|
||||
domain.factor(numpy.ascontiguousarray(rhs_pair[:, 1])),
|
||||
]
|
||||
)
|
||||
return domain.scatter_pair(sol_pair, f0)
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Three-stage per-stream pipeline for the SDK's per-frame work.
|
||||
|
||||
A single ``Sensor`` frame costs the sum of three stages::
|
||||
|
||||
produce -> capture + (stitch | UV unroll) + grey/resize (Sensor.get_img)
|
||||
flow -> DIS optical flow + forward/backward + loop closure
|
||||
hhd -> Helmholtz-Hodge decomposition + force readout (+ contact)
|
||||
|
||||
Running them back to back in one thread bounds throughput by that *sum*. Running
|
||||
them as three concurrent threads bounds it by the *slowest stage* only, which is
|
||||
what makes multi-sensor deployments viable: measured on a Ryzen 9 9950X, five
|
||||
finger streams go from 60.8 to 125.2 FPS per stream, and five box streams from
|
||||
39.1 to 57.6 FPS per stream.
|
||||
|
||||
Usage (finger)::
|
||||
|
||||
with orisys.StreamPipeline.for_finger_runtime(runtime) as pipe:
|
||||
while True:
|
||||
snap = pipe.wait()
|
||||
if snap is None:
|
||||
break
|
||||
print(snap.normal_force, snap.shear_x, snap.shear_y)
|
||||
|
||||
Usage (box / mini)::
|
||||
|
||||
with orisys.StreamPipeline(sensor, sensor.get_img) as pipe:
|
||||
...
|
||||
|
||||
Data ownership
|
||||
--------------
|
||||
The SDK reuses its working buffers between frames (``Sensor.img`` is
|
||||
``_img_gray_buf``, ``Sensor.flow`` may be the flow-EMA buffer, ``Sensor.vnormal``
|
||||
is the HHD upsample buffer, and the finger remap output buffer is reused too), so
|
||||
each stage must not hand a bare reference to the next one:
|
||||
|
||||
* the producer copies the frame it publishes, because the next ``get_img`` call
|
||||
overwrites the SDK buffer in place;
|
||||
* the flow stage takes ownership of ``Sensor.img`` for the duration of the frame,
|
||||
which is why the producer writes its own copy rather than letting the SDK
|
||||
buffer be read while being rewritten;
|
||||
* the flow stage also copies ``Sensor.flow`` before handing the frame on, because
|
||||
``Sensor.compute_hhd`` reads ``self.flow`` - and with the flow-EMA enabled that
|
||||
attribute is a buffer the next frame rewrites. Handing the copy forward and
|
||||
restoring ``Sensor.flow`` in the HHD stage is what keeps each frame's forces
|
||||
paired with that frame's flow;
|
||||
* the HHD stage owns every array that goes into a :class:`FrameSnapshot` (the
|
||||
frame copy is already private), so a published snapshot is immutable and safe
|
||||
to hand to a GUI thread.
|
||||
|
||||
``Sensor.valid_mask`` is still rebuilt by the producer; it is geometry that does
|
||||
not change between frames in practice, so the stages only ever read it. A sensor
|
||||
whose mask changes every frame would need the SDK to expose a frame-scoped mask.
|
||||
|
||||
Cost: one grey-frame copy (~0.1 MB) plus one flow copy (~1 MB at 365x365) per
|
||||
frame, roughly 0.1 ms - about 0.02 cores of a 32-thread machine at five streams.
|
||||
|
||||
Latency
|
||||
-------
|
||||
With the queues empty (a real camera at its own frame rate) the end-to-end
|
||||
capture-to-force delay is the sum of the three stage times. ``depth`` bounds how
|
||||
many frames may be in flight; a larger depth absorbs jitter at the cost of
|
||||
latency when the producer outruns the consumer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from queue import Empty, Full, Queue
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
__all__ = ["FrameSnapshot", "PipelineStats", "StreamPipeline"]
|
||||
|
||||
#: Array fields a snapshot can carry. ``image`` is the working grey frame; the
|
||||
#: rest come from ``Sensor.read_info`` (already copied by the SDK).
|
||||
_ARRAY_FIELDS = ("image", "flow", "vnormal", "vshear", "depth", "centroid", "contour")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameSnapshot:
|
||||
"""An immutable view of one finished frame.
|
||||
|
||||
Scalars are always populated; arrays are ``None`` unless requested through
|
||||
``StreamPipeline(include=...)``.
|
||||
"""
|
||||
|
||||
seq: int
|
||||
timestamp: float
|
||||
age_ms: float
|
||||
fps: float
|
||||
unit: str
|
||||
normal_force: float
|
||||
shear_x: float
|
||||
shear_y: float
|
||||
normal_force_raw: float
|
||||
shear_x_raw: float
|
||||
shear_y_raw: float
|
||||
torque: float
|
||||
image: np.ndarray | None = None
|
||||
flow: np.ndarray | None = None
|
||||
vnormal: np.ndarray | None = None
|
||||
vshear: np.ndarray | None = None
|
||||
depth: np.ndarray | None = None
|
||||
centroid: np.ndarray | None = None
|
||||
contour: np.ndarray | None = None
|
||||
produce_ms: float = 0.0
|
||||
flow_ms: float = 0.0
|
||||
hhd_ms: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineStats:
|
||||
"""Rolling diagnostics for one stream."""
|
||||
|
||||
produced: int = 0
|
||||
published: int = 0
|
||||
produce_ms: float = 0.0
|
||||
flow_ms: float = 0.0
|
||||
hhd_ms: float = 0.0
|
||||
age_ms: float = 0.0
|
||||
age_p95_ms: float = 0.0
|
||||
fps: float = 0.0
|
||||
_ages: list = field(default_factory=list, repr=False)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"produced": self.produced,
|
||||
"published": self.published,
|
||||
"fps": round(self.fps, 2),
|
||||
"produce_ms": round(self.produce_ms, 3),
|
||||
"flow_ms": round(self.flow_ms, 3),
|
||||
"hhd_ms": round(self.hhd_ms, 3),
|
||||
"age_ms": round(self.age_ms, 3),
|
||||
"age_p95_ms": round(self.age_p95_ms, 3),
|
||||
}
|
||||
|
||||
|
||||
class StreamPipeline:
|
||||
"""Run ``produce -> flow -> hhd`` as three concurrent stages.
|
||||
|
||||
``produce`` is a zero-argument callable returning either the working frame
|
||||
(``(H, W)`` grey or ``(H, W, 3)`` BGR) or ``None`` to signal end of input.
|
||||
For a ``Sensor`` that is ``sensor.get_img``; for a ``FingerRuntime`` use
|
||||
:meth:`for_finger_runtime`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sensor: Any,
|
||||
produce: Callable[[], np.ndarray | None],
|
||||
*,
|
||||
contact: bool = False,
|
||||
depth: int = 2,
|
||||
include: Sequence[str] | Iterable[str] = ("image",),
|
||||
on_frame: Callable[["FrameSnapshot"], None] | None = None,
|
||||
verbose: bool = False,
|
||||
fps_window: int = 30,
|
||||
) -> None:
|
||||
unknown = set(include) - set(_ARRAY_FIELDS)
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"unknown include fields {sorted(unknown)}; expected {list(_ARRAY_FIELDS)}"
|
||||
)
|
||||
self.sensor = sensor
|
||||
self._produce = produce
|
||||
self._contact = bool(contact)
|
||||
self._depth = max(1, int(depth))
|
||||
self._include = tuple(dict.fromkeys(include))
|
||||
self._on_frame = on_frame
|
||||
self._verbose = bool(verbose)
|
||||
self._fps_window = max(2, int(fps_window))
|
||||
|
||||
self._q1: Queue = Queue(maxsize=self._depth)
|
||||
self._q2: Queue = Queue(maxsize=self._depth)
|
||||
self._done = threading.Event()
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._error: BaseException | None = None
|
||||
self._running = False
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._latest: FrameSnapshot | None = None
|
||||
self._published = 0
|
||||
self._wait_seq = 0
|
||||
self._stamps: list[float] = []
|
||||
self.stats = PipelineStats()
|
||||
|
||||
# ------------------------------------------------------------------ ctors
|
||||
@classmethod
|
||||
def for_finger_runtime(cls, runtime: Any, **kwargs: Any) -> "StreamPipeline":
|
||||
"""Build a pipeline for a ``FingerRuntime`` (capture + UV unroll stage)."""
|
||||
|
||||
def produce() -> np.ndarray | None:
|
||||
ok, frame = runtime.grab_and_process(color=False)
|
||||
return frame if ok else None
|
||||
|
||||
return cls(runtime.tactile.raw_sensor, produce, **kwargs)
|
||||
|
||||
# --------------------------------------------------------------- lifecycle
|
||||
def start(self) -> "StreamPipeline":
|
||||
if self._running:
|
||||
return self
|
||||
self._running = True
|
||||
self._done.clear()
|
||||
self._error = None
|
||||
with self._lock:
|
||||
self._wait_seq = self._published
|
||||
self._threads = [
|
||||
threading.Thread(target=self._guard, args=(self._producer_loop,),
|
||||
name="orisys-pipeline-produce", daemon=True),
|
||||
threading.Thread(target=self._guard, args=(self._flow_loop,),
|
||||
name="orisys-pipeline-flow", daemon=True),
|
||||
threading.Thread(target=self._guard, args=(self._hhd_loop,),
|
||||
name="orisys-pipeline-hhd", daemon=True),
|
||||
]
|
||||
for t in self._threads:
|
||||
t.start()
|
||||
return self
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> None:
|
||||
"""Stop all stages and join them. Re-raises a stage exception, if any."""
|
||||
self._done.set()
|
||||
self._drain(self._q1)
|
||||
self._drain(self._q2)
|
||||
for t in self._threads:
|
||||
t.join(timeout=timeout)
|
||||
self._threads = []
|
||||
self._running = False
|
||||
if self._error is not None:
|
||||
err, self._error = self._error, None
|
||||
raise err
|
||||
|
||||
def __enter__(self) -> "StreamPipeline":
|
||||
return self.start()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
try:
|
||||
self.stop()
|
||||
except BaseException:
|
||||
if exc_type is None:
|
||||
raise
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running and any(t.is_alive() for t in self._threads)
|
||||
|
||||
@property
|
||||
def error(self) -> BaseException | None:
|
||||
return self._error
|
||||
|
||||
def latest(self) -> FrameSnapshot | None:
|
||||
"""Most recent snapshot, or ``None`` if none was published yet."""
|
||||
with self._lock:
|
||||
return self._latest
|
||||
|
||||
def wait(self, timeout: float | None = None) -> FrameSnapshot | None:
|
||||
"""Block until a snapshot newer than the last returned one exists.
|
||||
|
||||
This is a *latest-wins* view: if the stages outrun the caller, skipped
|
||||
frames are gone, which is what a live GUI/control loop wants. Use
|
||||
``on_frame`` when every frame must be handled exactly once.
|
||||
|
||||
Returns ``None`` on timeout, or once the pipeline has ended without a
|
||||
newer frame.
|
||||
"""
|
||||
with self._lock:
|
||||
seen = self._published
|
||||
deadline = None if timeout is None else time.perf_counter() + timeout
|
||||
while True:
|
||||
with self._lock:
|
||||
if self._published > self._wait_seq and self._latest is not None:
|
||||
self._wait_seq = self._published
|
||||
return self._latest
|
||||
if self._error is not None:
|
||||
err, self._error = self._error, None
|
||||
raise err
|
||||
if not self.running:
|
||||
with self._lock:
|
||||
if self._published > self._wait_seq and self._latest is not None:
|
||||
self._wait_seq = self._published
|
||||
return self._latest
|
||||
return None
|
||||
if deadline is not None and time.perf_counter() >= deadline:
|
||||
return None
|
||||
time.sleep(0.0005)
|
||||
|
||||
# ----------------------------------------------------------------- internals
|
||||
def _guard(self, target: Callable[[], None]) -> None:
|
||||
try:
|
||||
target()
|
||||
except BaseException as exc: # noqa: BLE001 - surfaced by stop()/wait()
|
||||
if self._error is None:
|
||||
self._error = exc
|
||||
self._done.set()
|
||||
|
||||
def _drain(self, q: Queue) -> None:
|
||||
while True:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except Empty:
|
||||
return
|
||||
|
||||
def _put(self, q: Queue, item: Any) -> bool:
|
||||
while not self._done.is_set():
|
||||
try:
|
||||
q.put(item, timeout=0.05)
|
||||
return True
|
||||
except Full:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _get(self, q: Queue) -> Any:
|
||||
"""Pop the next item, or ``None`` once the pipeline is stopping.
|
||||
|
||||
Consumers must use this rather than ``q.get()``: when :meth:`stop` sets
|
||||
the done flag the producers stop delivering sentinels, so a bare
|
||||
blocking ``get()`` would wait forever and ``join()`` would time out.
|
||||
"""
|
||||
while not self._done.is_set():
|
||||
try:
|
||||
return q.get(timeout=0.05)
|
||||
except Empty:
|
||||
continue
|
||||
try:
|
||||
return q.get_nowait()
|
||||
except Empty:
|
||||
return None
|
||||
|
||||
def _producer_loop(self) -> None:
|
||||
while not self._done.is_set():
|
||||
t0 = time.perf_counter()
|
||||
frame = self._produce()
|
||||
if frame is None:
|
||||
break
|
||||
t1 = time.perf_counter()
|
||||
# Private copy: the SDK buffer is rewritten in place by the next
|
||||
# produce() call while the flow stage is still reading this frame.
|
||||
payload = (frame.copy(), t0, t1 - t0)
|
||||
if not self._put(self._q1, payload):
|
||||
break
|
||||
self.stats.produced += 1
|
||||
self._put(self._q1, None)
|
||||
|
||||
def _flow_loop(self) -> None:
|
||||
while True:
|
||||
item = self._get(self._q1)
|
||||
if item is None:
|
||||
break
|
||||
frame, t_cap, produce_s = item
|
||||
t0 = time.perf_counter()
|
||||
# The frame is passed explicitly: the SDK's self.img slot is shared
|
||||
# with the producer, so it must not be the transport.
|
||||
self.sensor.compute_flow(frame)
|
||||
flow_s = time.perf_counter() - t0
|
||||
flow = getattr(self.sensor, "flow", None)
|
||||
# Private copy: with the flow EMA enabled Sensor.flow is a buffer the
|
||||
# next frame rewrites.
|
||||
flow_copy = flow.copy() if isinstance(flow, np.ndarray) else None
|
||||
self._put(self._q2, (frame, flow_copy, t_cap, produce_s, flow_s))
|
||||
self._put(self._q2, None)
|
||||
|
||||
def _hhd_loop(self) -> None:
|
||||
while True:
|
||||
item = self._get(self._q2)
|
||||
if item is None:
|
||||
break
|
||||
frame, flow, t_cap, produce_s, flow_s = item
|
||||
t0 = time.perf_counter()
|
||||
# Explicit flow: compute_hhd reads self.flow, which the flow stage
|
||||
# overwrites for the next frame, so it cannot be the transport.
|
||||
self.sensor.compute_hhd(flow)
|
||||
if self._contact:
|
||||
self.sensor.compute_contact()
|
||||
hhd_s = time.perf_counter() - t0
|
||||
snap = self._build(frame, flow, produce_s, flow_s, hhd_s, t_cap)
|
||||
self._publish(snap)
|
||||
if self._on_frame is not None:
|
||||
# Runs on this stage's thread: a slow consumer exerts real
|
||||
# backpressure instead of silently losing frames.
|
||||
self._on_frame(snap)
|
||||
if self._verbose:
|
||||
print(f"[pipeline] finished after {self.stats.published} frames")
|
||||
|
||||
# -------------------------------------------------------------- snapshot
|
||||
def _copy_array(self, selector: Callable[[], Any]) -> np.ndarray | None:
|
||||
try:
|
||||
value = selector()
|
||||
except Exception:
|
||||
return None
|
||||
if value is None or not isinstance(value, np.ndarray) or value.size == 0:
|
||||
return None
|
||||
return value.copy()
|
||||
|
||||
def _build(self, frame: np.ndarray, flow: np.ndarray | None, produce_s: float,
|
||||
flow_s: float, hhd_s: float, t_cap: float) -> FrameSnapshot:
|
||||
sensor = self.sensor
|
||||
info = sensor.info
|
||||
include = self._include
|
||||
now = time.perf_counter()
|
||||
|
||||
def maybe(name: str, selector: Callable[[], Any]) -> np.ndarray | None:
|
||||
return self._copy_array(selector) if name in include else None
|
||||
|
||||
return FrameSnapshot(
|
||||
seq=self.stats.published,
|
||||
timestamp=time.time(),
|
||||
age_ms=(now - t_cap) * 1000.0,
|
||||
fps=self._fps(),
|
||||
unit=str(getattr(sensor, "force_unit", "raw")),
|
||||
normal_force=float(sensor.fnormal),
|
||||
shear_x=float(sensor.fshearx),
|
||||
shear_y=float(sensor.fsheary),
|
||||
normal_force_raw=float(sensor._force_raw[0]),
|
||||
shear_x_raw=float(sensor._force_raw[1]),
|
||||
shear_y_raw=float(sensor._force_raw[2]),
|
||||
torque=float(getattr(sensor, "ftor", 0.0)),
|
||||
# Both already private per-frame copies owned by this snapshot.
|
||||
image=frame if "image" in include else None,
|
||||
flow=flow if "flow" in include else None,
|
||||
# Written only by the HHD stage, so read_info is safe here.
|
||||
vnormal=maybe("vnormal", lambda: sensor.read_info(info.VNORMAL)[0]),
|
||||
vshear=maybe("vshear", lambda: sensor.read_info(info.VSHEAR)[0]),
|
||||
depth=maybe("depth", lambda: sensor.read_info(info.DEPTH)[0]),
|
||||
centroid=maybe("centroid", lambda: sensor.read_info(info.CENTROID)[0]),
|
||||
contour=maybe("contour", lambda: sensor.read_info(info.CONTOUR)[0]),
|
||||
produce_ms=produce_s * 1000.0,
|
||||
flow_ms=flow_s * 1000.0,
|
||||
hhd_ms=hhd_s * 1000.0,
|
||||
)
|
||||
|
||||
def _fps(self) -> float:
|
||||
now = time.perf_counter()
|
||||
stamps = self._stamps
|
||||
stamps.append(now)
|
||||
if len(stamps) > self._fps_window:
|
||||
del stamps[: len(stamps) - self._fps_window]
|
||||
if len(stamps) < 2:
|
||||
return 0.0
|
||||
span = stamps[-1] - stamps[0]
|
||||
return (len(stamps) - 1) / span if span > 0 else 0.0
|
||||
|
||||
def _publish(self, snap: FrameSnapshot) -> None:
|
||||
stats = self.stats
|
||||
n = stats.published + 1
|
||||
stats.published = n
|
||||
stats.produce_ms += (snap.produce_ms - stats.produce_ms) / n
|
||||
stats.flow_ms += (snap.flow_ms - stats.flow_ms) / n
|
||||
stats.hhd_ms += (snap.hhd_ms - stats.hhd_ms) / n
|
||||
stats.age_ms += (snap.age_ms - stats.age_ms) / n
|
||||
stats.fps = snap.fps
|
||||
ages = stats._ages
|
||||
ages.append(snap.age_ms)
|
||||
if len(ages) > 200:
|
||||
del ages[: len(ages) - 200]
|
||||
# A percentile per published frame is measurable at 500+ FPS; refresh
|
||||
# it periodically instead.
|
||||
if n <= 10 or n % 10 == 0:
|
||||
stats.age_p95_ms = float(np.percentile(ages, 95))
|
||||
with self._lock:
|
||||
self._latest = snap
|
||||
self._published = n
|
||||
+680
-71
@@ -19,16 +19,79 @@ from typing import Optional, Tuple
|
||||
from .Stitcher import Stitcher
|
||||
from .tracker import Tracker
|
||||
from .hhd import nHHD
|
||||
from . import calibration as cal_mod
|
||||
from . import configs
|
||||
from .get_depth import Contact3D
|
||||
from .backend_availability import (check_cupy_available, check_opencl_available)
|
||||
from .frame_processors import create_frame_processor
|
||||
from .signal_processing import (
|
||||
DEFAULT_SAMPLE_RATE_HZ,
|
||||
CausalMedianFilter,
|
||||
LowpassFilter,
|
||||
calibrate_normal_force,
|
||||
calibrate_shear_components,
|
||||
SampleRateEstimator,
|
||||
)
|
||||
|
||||
# Default signal-conditioning settings (config: top-level "filters" block).
|
||||
#
|
||||
# A key a config does not state is **off**, not "whatever we thought was
|
||||
# sensible": the file has to name what runs, or a device can be filtering a
|
||||
# measurement nobody asked it to filter and no reader of the file could tell.
|
||||
# The shipped bundles state all three values explicitly, so they are unaffected
|
||||
# by these defaults; a config with no "filters" block at all filters nothing.
|
||||
#
|
||||
# Which settings are worth asking for is measured, not guessed. On dd02-0703 at
|
||||
# 280x300 (see the noise analysis in the perf notes) flow EMA 0.5 + median 3
|
||||
# costs ~2 frames at 30 fps and cuts steady-press force jitter by roughly half
|
||||
# while also stabilising vnormal/contact (which a force-level filter cannot do);
|
||||
# that combination is the "balanced" preset. The force low-pass stays off there
|
||||
# too: on top of the EMA it added no measurable noise benefit at the same
|
||||
# latency, so it is an opt-in stage for sites with different noise
|
||||
# characteristics (the "low_noise" preset).
|
||||
DEFAULT_FORCE_LOWPASS_HZ = 0.0
|
||||
DEFAULT_FORCE_MEDIAN_WINDOW = 1
|
||||
DEFAULT_FLOW_EMA_ALPHA = 1.0
|
||||
|
||||
#: Named filter presets, so a recommendation is *executable* instead of a
|
||||
#: comment in a JSON file (``json.load`` cannot carry comments). A config may
|
||||
#: write ``"filters": {"preset": "balanced"}`` and override any single key on
|
||||
#: top of it; the resolved values are what ``Sensor.filters_state()`` reports.
|
||||
#: The latency/noise trade-offs behind these numbers are measured in
|
||||
#: ``_perf/`` and tabulated in ``docs/filters.md``.
|
||||
FILTER_PRESETS = {
|
||||
# Everything off: the file says what runs, and the raw reading is the record.
|
||||
"off": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
},
|
||||
# One frame of latency @30 fps: median only. The EMA changes the
|
||||
# measurement, so it stays off when latency matters more than jitter.
|
||||
"low_latency": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
},
|
||||
# Two frames @30 fps: the EMA removes the spatially-coherent flow jitter
|
||||
# that no force-level filter can reach, the median removes its spikes.
|
||||
"balanced": {
|
||||
"flow_ema_alpha": 0.5,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
},
|
||||
# Three frames @30 fps and the quietest readout: as ``balanced`` plus the
|
||||
# force low-pass on top of the median.
|
||||
"low_noise": {
|
||||
"flow_ema_alpha": 0.3,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 10.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def available_filter_presets() -> dict:
|
||||
"""The presets, for UIs that want to offer them (the studio's gear dialog)."""
|
||||
return {name: dict(values) for name, values in FILTER_PRESETS.items()}
|
||||
|
||||
from .capture import (
|
||||
CaptureConfig,
|
||||
CaptureSource,
|
||||
@@ -87,7 +150,7 @@ class Sensor:
|
||||
isstitch: Enable stitching mode for multi-tile sensor (default: False)
|
||||
verbose: Print initialization logs (default: True)
|
||||
config_name: Configuration name (default: "prototype")
|
||||
cal_path: Path to calibration file (default: None)
|
||||
cal_path: Explicit force_calibration.json path (overrides bundle default)
|
||||
override: Skip auto-detection, use provided box (default: False)
|
||||
box: Manual bounding boxes when override=True (default: None)
|
||||
backend: Poisson backend (default: "auto")
|
||||
@@ -152,22 +215,129 @@ class Sensor:
|
||||
self.n_boxes = None
|
||||
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
|
||||
self._force_raw = (0.0, 0.0, 0.0)
|
||||
self._force_cal_sensor_name = self._resolve_force_calibration_sensor_name()
|
||||
self._lowpass_enabled = False
|
||||
self._lowpass_cutoff_hz = 0.0
|
||||
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
|
||||
self.calibration: Optional[cal_mod.Calibration] = None
|
||||
self._force_cal_sensor_name: Optional[str] = None
|
||||
#: Why no calibration was applied, when a bundle existed but carried no
|
||||
#: usable coefficients. ``None`` means the question never came up.
|
||||
self._force_cal_attempts: Optional[list[dict]] = None
|
||||
self._init_force_calibration()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Signal conditioning (config: top-level "filters" block)
|
||||
# ---------------------------------------------------------------
|
||||
# These settings change the reported force values, so they are part of
|
||||
# the measurement-pipeline identity and are recorded in the calibration
|
||||
# metadata / mismatch check alongside dis_mode and hhd_downsample.
|
||||
#
|
||||
# Every stage is switched by its **own value**, so the config states what
|
||||
# runs: ``flow_ema_alpha >= 1``, ``force_median_window <= 1`` and
|
||||
# ``force_lowpass_cutoff_hz <= 0`` each mean "stage off". Values are never
|
||||
# overridden: there is no master switch that could make the file say 0.5
|
||||
# while the device runs 1.0. An invalid value falls back to **off**, never
|
||||
# to a default that filters. ``enabled`` is kept only as a deprecated
|
||||
# alias for "turn every stage off" and is honoured when it is explicitly
|
||||
# false. ``Sensor.filters_state()`` reports what actually took effect.
|
||||
filters_cfg = self._config.get("filters")
|
||||
if not isinstance(filters_cfg, dict):
|
||||
filters_cfg = {}
|
||||
legacy_off = filters_cfg.get("enabled", None) is False
|
||||
preset_name = filters_cfg.get("preset", None)
|
||||
preset_values = FILTER_PRESETS.get(str(preset_name), {}) if preset_name else {}
|
||||
self._filter_preset = str(preset_name) if preset_name in FILTER_PRESETS else None
|
||||
|
||||
def _cfg(key, default):
|
||||
"""The configured value: an explicit key, else the preset, else default."""
|
||||
if legacy_off:
|
||||
return default
|
||||
value = filters_cfg.get(key, None)
|
||||
if value is None:
|
||||
value = preset_values.get(key, None)
|
||||
return default if value is None else value
|
||||
|
||||
self._lowpass_order = 2
|
||||
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
|
||||
self._lowpass_cutoff_hz = 0.0
|
||||
self._lowpass_rate_auto = True
|
||||
self._config_fps = 0.0
|
||||
try:
|
||||
self._config_fps = float(self.__config_cam.get("fps", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
self._config_fps = 0.0
|
||||
if self._config_fps <= 0:
|
||||
self._config_fps = float(DEFAULT_SAMPLE_RATE_HZ)
|
||||
self._lowpass_sample_rate_hz = self._config_fps
|
||||
try:
|
||||
self._lowpass_order = max(1, int(_cfg("force_lowpass_order", 2)))
|
||||
except (TypeError, ValueError):
|
||||
self._lowpass_order = 2
|
||||
rate_cfg = _cfg("force_lowpass_sample_rate_hz", "auto")
|
||||
if isinstance(rate_cfg, str):
|
||||
self._lowpass_rate_auto = rate_cfg.strip().lower() in ("auto", "", "measured")
|
||||
else:
|
||||
try:
|
||||
rate = float(rate_cfg)
|
||||
except (TypeError, ValueError):
|
||||
rate = 0.0
|
||||
self._lowpass_rate_auto = rate <= 0.0
|
||||
if not self._lowpass_rate_auto:
|
||||
self._lowpass_sample_rate_hz = rate
|
||||
try:
|
||||
self._lowpass_cutoff_hz = float(
|
||||
_cfg("force_lowpass_cutoff_hz", DEFAULT_FORCE_LOWPASS_HZ)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
self._lowpass_cutoff_hz = 0.0
|
||||
if self._lowpass_sample_rate_hz <= 0:
|
||||
self._lowpass_sample_rate_hz = self._config_fps
|
||||
# An unusable cutoff is "off", never a silent jump to the default.
|
||||
if not (0.0 <= self._lowpass_cutoff_hz < 0.5 * self._lowpass_sample_rate_hz):
|
||||
self._lowpass_cutoff_hz = 0.0
|
||||
self._lowpass_enabled = self._lowpass_cutoff_hz > 0
|
||||
self._force_filter = LowpassFilter(
|
||||
cutoff_hz=self._lowpass_cutoff_hz,
|
||||
sample_rate_hz=self._lowpass_sample_rate_hz,
|
||||
order=self._lowpass_order,
|
||||
n_channels=3,
|
||||
)
|
||||
# Rate tracking: only meaningful while the force low-pass is in use.
|
||||
self._force_rate = SampleRateEstimator(initial_hz=self._config_fps)
|
||||
self._force_rate_source = "config"
|
||||
|
||||
try:
|
||||
self._force_median_window = max(1, int(_cfg("force_median_window", DEFAULT_FORCE_MEDIAN_WINDOW)))
|
||||
except (TypeError, ValueError):
|
||||
self._force_median_window = 1
|
||||
self._force_median = CausalMedianFilter(window=self._force_median_window, n_channels=3)
|
||||
|
||||
self._flow_ema_alpha = 1.0
|
||||
try:
|
||||
alpha = float(_cfg("flow_ema_alpha", DEFAULT_FLOW_EMA_ALPHA))
|
||||
except (TypeError, ValueError):
|
||||
alpha = 1.0
|
||||
if 0.0 < alpha < 1.0:
|
||||
self._flow_ema_alpha = alpha
|
||||
self._ema_flow = None
|
||||
if legacy_off and self.verbose:
|
||||
print(
|
||||
"Note: filters.enabled is deprecated and means 'all stages off'; "
|
||||
"remove it and switch each stage by its own value "
|
||||
"(flow_ema_alpha=1, force_median_window=1, force_lowpass_cutoff_hz=0)."
|
||||
)
|
||||
# Derived, for callers that ask "is any stage filtering?".
|
||||
self._filters_enabled = bool(
|
||||
self._flow_ema_alpha < 1.0
|
||||
or self._force_median_window > 1
|
||||
or self._lowpass_enabled
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
if self._force_cal_sensor_name:
|
||||
print(
|
||||
f"Force calibration: sensors/{self._force_cal_sensor_name}/force_calibration.json"
|
||||
)
|
||||
if self.calibration is not None and self.calibration.source_path is not None:
|
||||
print(f"Force calibration: {self.calibration.source_path}")
|
||||
elif self._force_cal_attempts is not None:
|
||||
# A bundle was resolved for this sensor type, so "file missing"
|
||||
# would be the wrong story: name the bundle and the roots that
|
||||
# were searched instead.
|
||||
print(self._force_calibration_note())
|
||||
else:
|
||||
print("Force calibration: raw (no force_calibration.json)")
|
||||
|
||||
@@ -179,6 +349,13 @@ class Sensor:
|
||||
self.valid_mask_raw = None
|
||||
self.valid_mask = None
|
||||
self.valid_mask_hhd = None
|
||||
# Scratch buffers reused by _compute_flow_means / compute_hhd.
|
||||
self._flow_mag_buf = None
|
||||
self._flow_mask_u8 = None
|
||||
self._hhd_vn_buf = None
|
||||
self._hhd_vs_buf = None
|
||||
self._hhd_flow_ds_buf = None
|
||||
self._hhd_mask_f32 = None
|
||||
self.mask_stats = {
|
||||
"coverage": 0.0,
|
||||
"flow_mean_valid": 0.0,
|
||||
@@ -191,10 +368,16 @@ class Sensor:
|
||||
self._last_capture_time = 0.0
|
||||
self._last_processing_time = 0.0
|
||||
self._last_stage_times = {
|
||||
"extract_tiles": 0.0,
|
||||
"stitch": 0.0,
|
||||
"resize": 0.0,
|
||||
"mirror": 0.0,
|
||||
# Frame-post-processing roll-ups (seconds).
|
||||
"extract_tiles": 0.0, # sensor units + tile extraction
|
||||
"stitch": 0.0, # feature/feather stitch of the extracted tiles
|
||||
"resize": 0.0, # BGR->gray + resize to resolution_stitch + mirror
|
||||
# Finer breakdown.
|
||||
"store": 0.0, # frame reference/copy
|
||||
"units": 0.0, # compound-eye unit (sub-image) extraction
|
||||
"gray": 0.0, # BGR->gray conversion
|
||||
"mirror": 0.0, # horizontal flip
|
||||
"mask": 0.0, # valid-mask resize/cache update
|
||||
"astype": 0.0,
|
||||
}
|
||||
|
||||
@@ -215,6 +398,7 @@ class Sensor:
|
||||
except (TypeError, ValueError):
|
||||
self._hhd_downsample = 1
|
||||
self._hhd_grid_shape = None
|
||||
self._hhd_ref_shape = None
|
||||
cfg_backend = self.__config_tracker.get("backend", None)
|
||||
self.poisson_backend = cfg_backend if cfg_backend is not None else backend
|
||||
self.masked_poisson_solver = self.__config_tracker.get("masked_poisson_solver", "sparse")
|
||||
@@ -241,6 +425,15 @@ class Sensor:
|
||||
print("Sensor initialized in external-frame mode.")
|
||||
else:
|
||||
self._open_video_source(override, box)
|
||||
if not self._is_live_camera:
|
||||
# A file source is replayed at whatever speed the CPU manages,
|
||||
# so wall-clock intervals do not reflect signal time: keep the
|
||||
# configured camera fps for the force low-pass design.
|
||||
self._lowpass_rate_auto = False
|
||||
self._force_rate_source = "config (file)"
|
||||
self._lowpass_sample_rate_hz = self._config_fps
|
||||
if getattr(self, "_force_rate", None) is not None:
|
||||
self._force_rate.reset(self._config_fps)
|
||||
|
||||
if self.verbose:
|
||||
print(f" Video source: {vid_src}")
|
||||
@@ -250,6 +443,14 @@ class Sensor:
|
||||
# print(f" CUDA acceleration: {self.cuda}")
|
||||
print(f" CuPy available: {self.CUPY_AVAILABLE}")
|
||||
print(f" OpenCL available: {self.OPENCL_AVAILABLE}")
|
||||
print(
|
||||
" Signal filters: "
|
||||
f"ema_alpha={self._flow_ema_alpha} "
|
||||
f"median={self._force_median_window} "
|
||||
f"lowpass={self._lowpass_cutoff_hz}Hz@"
|
||||
f"{self._lowpass_sample_rate_hz:.1f}Hz "
|
||||
f"({self.force_sample_rate_source})"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -310,17 +511,31 @@ class Sensor:
|
||||
def _compute_flow_means(self, flow):
|
||||
if flow is None:
|
||||
return
|
||||
mag = np.linalg.norm(flow, axis=-1)
|
||||
shape = flow.shape[:2]
|
||||
mag = self._flow_mag_buf
|
||||
if mag is None or mag.shape != shape or mag.dtype != np.float32:
|
||||
mag = np.empty(shape, dtype=np.float32)
|
||||
self._flow_mag_buf = mag
|
||||
# cv2.magnitude + cv2.mean(mask=...) avoid the (H, W) temporaries and the
|
||||
# boolean fancy-index gather that np.linalg.norm + mag[mask].mean() need.
|
||||
cv2.magnitude(flow[:, :, 0], flow[:, :, 1], mag)
|
||||
|
||||
mask = self.valid_mask
|
||||
if mask is None or mask.shape != mag.shape:
|
||||
self.mask_stats["flow_mean_valid"] = float(mag.mean()) if mag.size else 0.0
|
||||
if mask is None or mask.shape != shape:
|
||||
self.mask_stats["flow_mean_valid"] = float(cv2.mean(mag)[0]) if mag.size else 0.0
|
||||
self.mask_stats["flow_mean_invalid"] = 0.0
|
||||
return
|
||||
|
||||
valid = mask.astype(bool)
|
||||
invalid = ~valid
|
||||
self.mask_stats["flow_mean_valid"] = float(mag[valid].mean()) if np.any(valid) else 0.0
|
||||
self.mask_stats["flow_mean_invalid"] = float(mag[invalid].mean()) if np.any(invalid) else 0.0
|
||||
mask_u8 = self._flow_mask_u8
|
||||
if mask_u8 is None or mask_u8.shape != shape or mask_u8.dtype != np.uint8:
|
||||
mask_u8 = np.empty(shape, dtype=np.uint8)
|
||||
self._flow_mask_u8 = mask_u8
|
||||
np.copyto(mask_u8, mask, casting="unsafe")
|
||||
np.multiply(mask_u8, 255, out=mask_u8)
|
||||
|
||||
self.mask_stats["flow_mean_valid"] = float(cv2.mean(mag, mask=mask_u8)[0])
|
||||
np.subtract(255, mask_u8, out=mask_u8)
|
||||
self.mask_stats["flow_mean_invalid"] = float(cv2.mean(mag, mask=mask_u8)[0])
|
||||
|
||||
def get_img(self, frame=None, already_processed=False, valid_mask=None, already_gray=False):
|
||||
"""
|
||||
@@ -359,6 +574,7 @@ class Sensor:
|
||||
self.frame = frame if (already_processed and isinstance(frame, np.ndarray)) else (
|
||||
frame.copy() if isinstance(frame, np.ndarray) else frame
|
||||
)
|
||||
t_after_store = time.perf_counter()
|
||||
if already_processed:
|
||||
img_bgr = frame
|
||||
gray = None
|
||||
@@ -380,8 +596,6 @@ class Sensor:
|
||||
gray = None
|
||||
t_after_extract = time.perf_counter()
|
||||
|
||||
t_after_stitch = time.perf_counter()
|
||||
|
||||
if gray is None:
|
||||
self.img_size_raw = img_bgr.shape
|
||||
self._img_bgr_raw = img_bgr
|
||||
@@ -389,6 +603,8 @@ class Sensor:
|
||||
else:
|
||||
self.img_size_raw = gray.shape
|
||||
self._img_bgr_raw = None
|
||||
t_after_gray = time.perf_counter()
|
||||
|
||||
res = tuple(self._config_resolution_stitch)
|
||||
if self._img_gray_buf is None or self._img_gray_buf.shape != (res[1], res[0]):
|
||||
self._img_gray_buf = np.empty((res[1], res[0]), dtype=np.uint8)
|
||||
@@ -397,15 +613,22 @@ class Sensor:
|
||||
|
||||
if self.mirror:
|
||||
cv2.flip(self._img_gray_buf, 1, dst=self._img_gray_buf)
|
||||
t_after_mirror = time.perf_counter()
|
||||
|
||||
self.img = self._img_gray_buf
|
||||
self._prepare_valid_mask(valid_mask, gray.shape, self.img.shape)
|
||||
|
||||
t_processing_end = time.perf_counter()
|
||||
|
||||
self._last_processing_time = t_processing_end - t_processing_start
|
||||
self._last_stage_times["extract_tiles"] = t_after_extract - t_processing_start
|
||||
self._last_stage_times["stitch"] = t_after_stitch - t_after_extract
|
||||
self._last_stage_times["resize"] = t_processing_end - t_after_stitch
|
||||
self._record_stage_times(
|
||||
t_processing_start=t_processing_start,
|
||||
t_after_store=t_after_store,
|
||||
t_after_extract=t_after_extract,
|
||||
t_after_gray=t_after_gray,
|
||||
t_after_resize=t_after_resize,
|
||||
t_after_mirror=t_after_mirror,
|
||||
t_processing_end=t_processing_end,
|
||||
)
|
||||
|
||||
# FPS calculation
|
||||
self._fps = 1.0 / (time.perf_counter() - self.last_log_time)
|
||||
@@ -413,6 +636,48 @@ class Sensor:
|
||||
|
||||
return self.img.copy()
|
||||
|
||||
def _record_stage_times(
|
||||
self,
|
||||
*,
|
||||
t_processing_start,
|
||||
t_after_store,
|
||||
t_after_extract,
|
||||
t_after_gray,
|
||||
t_after_resize,
|
||||
t_after_mirror,
|
||||
t_processing_end,
|
||||
):
|
||||
"""Record per-stage frame-post-processing timings (seconds).
|
||||
|
||||
``extract_tiles``/``stitch`` come from the frame processor, which times
|
||||
its own sub-steps; the remaining keys are measured here. The roll-ups
|
||||
keep the historical key names while no longer collapsing ``stitch`` to
|
||||
zero: previously ``t_after_stitch`` was sampled immediately after
|
||||
``t_after_extract``, so ``stitch`` was always ~0 s and ``resize``
|
||||
silently absorbed the whole stitch cost.
|
||||
"""
|
||||
stage = self._last_stage_times
|
||||
processor_timings = getattr(self.frame_processor, "last_timings", None) or {}
|
||||
units_s = float(processor_timings.get("units", 0.0))
|
||||
extract_s = float(processor_timings.get("extract_tiles", 0.0))
|
||||
stitch_s = float(processor_timings.get("stitch", 0.0))
|
||||
processor_total_s = t_after_extract - t_after_store
|
||||
|
||||
stage["store"] = t_after_store - t_processing_start
|
||||
stage["units"] = units_s
|
||||
stage["gray"] = t_after_gray - t_after_extract
|
||||
stage["resize"] = t_after_resize - t_after_gray
|
||||
stage["mirror"] = t_after_mirror - t_after_resize
|
||||
stage["mask"] = t_processing_end - t_after_mirror
|
||||
|
||||
if processor_timings:
|
||||
stage["extract_tiles"] = units_s + extract_s
|
||||
stage["stitch"] = stitch_s
|
||||
else:
|
||||
# Non-compound-eye processors are a single opaque step.
|
||||
stage["extract_tiles"] = processor_total_s
|
||||
stage["stitch"] = 0.0
|
||||
|
||||
def _get_img_bgr(self):
|
||||
"""Produce a resized+mirrored BGR image on demand (for display)."""
|
||||
if self._img_bgr_raw is None:
|
||||
@@ -501,20 +766,99 @@ class Sensor:
|
||||
self.compute_hhd()
|
||||
return True
|
||||
|
||||
def compute_flow(self):
|
||||
if self.img is None:
|
||||
def compute_flow(self, img=None):
|
||||
"""Compute the optical flow for one frame.
|
||||
|
||||
``img`` optionally supplies the frame to use instead of ``self.img``.
|
||||
StreamPipeline passes it explicitly so a producer thread cannot swap
|
||||
``self.img`` between the hand-off and the tracker call. Positional
|
||||
callers and existing code are unaffected.
|
||||
"""
|
||||
image = self.img if img is None else img
|
||||
if image is None:
|
||||
raise Exception("Warning: Get image first")
|
||||
|
||||
flow = self.__tracker.track(self.img, valid_mask=self.valid_mask)
|
||||
flow = self.__tracker.track(image, valid_mask=self.valid_mask)
|
||||
# flow = self.__tracker.track_raft(self.img)
|
||||
if hasattr(flow, 'download'):
|
||||
flow = flow.download()
|
||||
flow = self._apply_flow_ema(flow)
|
||||
self.flow = flow
|
||||
self._compute_flow_means(self.flow)
|
||||
|
||||
def compute_hhd(self):
|
||||
def _apply_flow_ema(self, flow):
|
||||
"""Temporally smooth the optical-flow field before HHD.
|
||||
|
||||
flow = self.flow
|
||||
The frame-to-frame jitter of the estimated flow is spatially coherent
|
||||
(a near-global shift: it survives ~40x less averaging than per-pixel
|
||||
noise would, see the noise analysis), so it cannot be removed by
|
||||
spatial smoothing, a coarser HHD grid or a different DIS setting - only
|
||||
by averaging along time. Applying it here also stabilises
|
||||
``vnormal``/contact, which a force-level filter cannot reach.
|
||||
|
||||
``flow_ema_alpha >= 1`` disables the stage.
|
||||
"""
|
||||
alpha = getattr(self, "_flow_ema_alpha", 1.0)
|
||||
if flow is None or alpha >= 1.0:
|
||||
return flow
|
||||
prev = self._ema_flow
|
||||
if prev is None or prev.shape != flow.shape or prev.dtype != flow.dtype:
|
||||
self._ema_flow = np.array(flow, copy=True)
|
||||
return flow
|
||||
prev += alpha * (flow - prev)
|
||||
return prev
|
||||
|
||||
def _update_force_sample_rate(self):
|
||||
"""Track the achieved force update rate for the low-pass design.
|
||||
|
||||
A Butterworth designed for 30 Hz stops meaning the same thing at 60 Hz,
|
||||
so when ``force_lowpass_sample_rate_hz`` is ``"auto"`` the rate actually
|
||||
achieved is used. For sources whose wall-clock cadence does not equal
|
||||
signal time (video files, offline replay) ``_lowpass_rate_auto`` is
|
||||
turned off by whoever knows the source - ``__init__`` for a capture the
|
||||
Sensor opened itself, and ``FingerRuntime.open`` for external frames -
|
||||
and the configured camera fps is used instead.
|
||||
"""
|
||||
if not getattr(self, "_lowpass_rate_auto", False):
|
||||
return
|
||||
estimator = getattr(self, "_force_rate", None)
|
||||
if estimator is None:
|
||||
return
|
||||
hz = estimator.tick()
|
||||
if not (1.0 <= hz <= 240.0):
|
||||
return
|
||||
self._force_rate_source = "measured"
|
||||
used = self._lowpass_sample_rate_hz
|
||||
if used > 0 and abs(hz - used) <= 0.1 * used:
|
||||
return
|
||||
self._lowpass_sample_rate_hz = hz
|
||||
if self._lowpass_enabled:
|
||||
self._force_filter.configure(
|
||||
cutoff_hz=self._lowpass_cutoff_hz,
|
||||
sample_rate_hz=hz,
|
||||
order=self._lowpass_order,
|
||||
)
|
||||
|
||||
@property
|
||||
def force_sample_rate_hz(self) -> float:
|
||||
"""Sample rate currently used to design the force low-pass (Hz)."""
|
||||
return float(self._lowpass_sample_rate_hz)
|
||||
|
||||
@property
|
||||
def force_sample_rate_source(self) -> str:
|
||||
"""``config``, ``measured`` or ``config (replay)``."""
|
||||
return str(getattr(self, "_force_rate_source", "config"))
|
||||
|
||||
def compute_hhd(self, flow=None):
|
||||
"""Decompose the optical flow and update the force readout.
|
||||
|
||||
``flow`` optionally supplies the field to decompose instead of
|
||||
``self.flow``. StreamPipeline passes its own per-frame copy so the flow
|
||||
stage cannot overwrite ``self.flow`` mid-decomposition. Existing
|
||||
callers are unaffected.
|
||||
"""
|
||||
|
||||
flow = self.flow if flow is None else flow
|
||||
if flow is None:
|
||||
raise Exception("Warning: Compute flow first")
|
||||
|
||||
@@ -540,7 +884,11 @@ class Sensor:
|
||||
if k > 1:
|
||||
ds_w = max(1, orig_w // k)
|
||||
ds_h = max(1, orig_h // k)
|
||||
flow_ds = cv2.resize(flow, (ds_w, ds_h), interpolation=cv2.INTER_AREA)
|
||||
flow_ds = self._hhd_flow_ds_buf
|
||||
if flow_ds is None or flow_ds.shape != (ds_h, ds_w, 2) or flow_ds.dtype != np.float32:
|
||||
flow_ds = np.empty((ds_h, ds_w, 2), dtype=np.float32)
|
||||
self._hhd_flow_ds_buf = flow_ds
|
||||
cv2.resize(flow, (ds_w, ds_h), dst=flow_ds, interpolation=cv2.INTER_AREA)
|
||||
if mask_ds is not None and (
|
||||
mask_ds.shape[1] != ds_w or mask_ds.shape[0] != ds_h
|
||||
):
|
||||
@@ -549,8 +897,13 @@ class Sensor:
|
||||
flow_ds = flow
|
||||
|
||||
ds_shape = flow_ds.shape[:2]
|
||||
ref_shape = (orig_h, orig_w)
|
||||
self.valid_mask_hhd = mask_ds
|
||||
if self.__hhd is None or self._hhd_grid_shape != ds_shape:
|
||||
if (
|
||||
self.__hhd is None
|
||||
or self._hhd_grid_shape != ds_shape
|
||||
or self._hhd_ref_shape != ref_shape
|
||||
):
|
||||
spacings = (18 * k, 18 * k)
|
||||
self.__hhd = nHHD(
|
||||
cuda=self.cuda,
|
||||
@@ -558,6 +911,9 @@ class Sensor:
|
||||
spacings=spacings,
|
||||
poisson_backend=self.poisson_backend,
|
||||
debug=self.debug,
|
||||
# Rescale grid-summed force aggregates back onto the full-resolution
|
||||
# flow grid so Sn/Stx/Sty/Stor stay comparable across hhd_downsample.
|
||||
force_reference_grid=ref_shape,
|
||||
masked_poisson_solver=self.masked_poisson_solver,
|
||||
masked_poisson_max_boundary=self.masked_poisson_max_boundary,
|
||||
masked_poisson_boundary_stride=self.masked_poisson_boundary_stride,
|
||||
@@ -566,29 +922,46 @@ class Sensor:
|
||||
masked_poisson_reg=self.masked_poisson_reg,
|
||||
)
|
||||
self._hhd_grid_shape = ds_shape
|
||||
self._hhd_ref_shape = ref_shape
|
||||
|
||||
_, forces = self.__hhd.get_force(flow_ds, valid_mask=mask_ds)
|
||||
fn, fx, fy, ftor = forces
|
||||
self._force_raw = (float(fn), float(fx), float(fy))
|
||||
self.ftor = float(ftor)
|
||||
self._update_force_sample_rate()
|
||||
self._apply_force_filter()
|
||||
|
||||
vnormal_ds = self.__hhd.get_normal()
|
||||
vshear_ds = self.__hhd.get_shear()
|
||||
|
||||
if k > 1:
|
||||
self.vnormal = cv2.resize(vnormal_ds, (orig_w, orig_h), interpolation=cv2.INTER_LINEAR)
|
||||
self.vshear = cv2.resize(vshear_ds, (orig_w, orig_h), interpolation=cv2.INTER_LINEAR)
|
||||
# Upsample into reusable buffers; the solver keeps owning its own
|
||||
# arrays, so nothing downstream can alias solver state.
|
||||
vn = self._hhd_vn_buf
|
||||
vs = self._hhd_vs_buf
|
||||
if vn is None or vn.shape != (orig_h, orig_w, 2) or vn.dtype != np.float32:
|
||||
vn = np.empty((orig_h, orig_w, 2), dtype=np.float32)
|
||||
vs = np.empty((orig_h, orig_w, 2), dtype=np.float32)
|
||||
self._hhd_vn_buf = vn
|
||||
self._hhd_vs_buf = vs
|
||||
cv2.resize(vnormal_ds, (orig_w, orig_h), dst=vn, interpolation=cv2.INTER_LINEAR)
|
||||
cv2.resize(vshear_ds, (orig_w, orig_h), dst=vs, interpolation=cv2.INTER_LINEAR)
|
||||
self.vnormal = vn
|
||||
self.vshear = vs
|
||||
else:
|
||||
self.vnormal = vnormal_ds
|
||||
self.vshear = vshear_ds
|
||||
self.vnormal = vnormal_ds.copy()
|
||||
self.vshear = vshear_ds.copy()
|
||||
|
||||
if self.valid_mask is not None:
|
||||
active = self.valid_mask.astype(bool)
|
||||
self.vnormal = self.vnormal.copy()
|
||||
self.vshear = self.vshear.copy()
|
||||
self.vnormal[~active] = 0
|
||||
self.vshear[~active] = 0
|
||||
# In-place multiply by a 0/1 float mask instead of
|
||||
# ``x.copy(); x[~active] = 0`` (copy + boolean gather/scatter).
|
||||
m3 = self._hhd_mask_f32
|
||||
if m3 is None or m3.shape != (orig_h, orig_w, 1) or m3.dtype != np.float32:
|
||||
m3 = np.empty((orig_h, orig_w, 1), dtype=np.float32)
|
||||
self._hhd_mask_f32 = m3
|
||||
np.copyto(m3[:, :, 0], self.valid_mask, casting="unsafe")
|
||||
np.multiply(self.vnormal, m3, out=self.vnormal)
|
||||
np.multiply(self.vshear, m3, out=self.vshear)
|
||||
|
||||
self.compute_deformation_valid = True
|
||||
|
||||
@@ -697,6 +1070,13 @@ class Sensor:
|
||||
"""Reset optical flow tracker and force-filter state."""
|
||||
self.__tracker.reset()
|
||||
self._force_filter.reset()
|
||||
if getattr(self, "_force_median", None) is not None:
|
||||
self._force_median.reset()
|
||||
self._ema_flow = None
|
||||
# The unfiltered pair belongs to the frame it was computed for: a new
|
||||
# session must not report the previous one's reading.
|
||||
self._force_unfiltered = None
|
||||
self._force_raw_unfiltered = None
|
||||
if self.img is not None:
|
||||
self.initial_img = self.img.copy()
|
||||
|
||||
@@ -727,7 +1107,14 @@ class Sensor:
|
||||
# self.cap = None
|
||||
# if self.verbose:
|
||||
# print("Sensor disconnected.")
|
||||
cv2.destroyAllWindows()
|
||||
try:
|
||||
cv2.destroyAllWindows()
|
||||
except cv2.error:
|
||||
# A headless OpenCV build (``opencv-python-headless``) has no highgui,
|
||||
# so there is nothing to close. Failing here would leave the rest of
|
||||
# the shutdown — and every caller that disconnects in a ``finally`` —
|
||||
# reporting an error that says nothing about the sensor.
|
||||
pass
|
||||
|
||||
# =========================================================================
|
||||
# Public API - Configuration & Calibration
|
||||
@@ -740,6 +1127,8 @@ class Sensor:
|
||||
lowpass_sample_rate_hz=None,
|
||||
lowpass_order=None,
|
||||
lowpass_enabled=None,
|
||||
flow_ema_alpha=None,
|
||||
force_median_window=None,
|
||||
hhd_downsample=None,
|
||||
):
|
||||
"""Update runtime-safe settings without reconnecting the camera.
|
||||
@@ -751,10 +1140,18 @@ class Sensor:
|
||||
value enables filtering unless ``lowpass_enabled=False``.
|
||||
lowpass_sample_rate_hz : float, optional
|
||||
Assumed force sample rate for the Butterworth design (default 30).
|
||||
Set this to the actual output rate of the deployment.
|
||||
lowpass_order : int, optional
|
||||
Butterworth order (default 2).
|
||||
lowpass_enabled : bool, optional
|
||||
Explicit on/off for the causal force filter.
|
||||
flow_ema_alpha : float, optional
|
||||
EMA coefficient applied to the optical-flow field before HHD.
|
||||
``1.0`` disables it; smaller values smooth more (0.5 is the default
|
||||
and costs about one frame of delay at 30 fps).
|
||||
force_median_window : int, optional
|
||||
Causal sliding-median width applied to the raw force triple before
|
||||
the low-pass. ``<= 1`` disables it; 3 is the default.
|
||||
hhd_downsample : int, optional
|
||||
Integer ``>= 1``. Rebuilds the HHD solver on the next frame.
|
||||
"""
|
||||
@@ -765,10 +1162,26 @@ class Sensor:
|
||||
if lowpass_enabled is None:
|
||||
self._lowpass_enabled = self._lowpass_cutoff_hz > 0
|
||||
if lowpass_sample_rate_hz is not None:
|
||||
rate = float(lowpass_sample_rate_hz)
|
||||
if rate <= 0:
|
||||
raise ValueError("lowpass_sample_rate_hz must be > 0")
|
||||
self._lowpass_sample_rate_hz = rate
|
||||
# Accept a positive number to pin the rate, or "auto"/<=0 to track
|
||||
# the measured output rate (see _update_force_sample_rate).
|
||||
auto = False
|
||||
rate = 0.0
|
||||
if isinstance(lowpass_sample_rate_hz, str):
|
||||
auto = lowpass_sample_rate_hz.strip().lower() in ("auto", "", "measured")
|
||||
else:
|
||||
try:
|
||||
rate = float(lowpass_sample_rate_hz)
|
||||
except (TypeError, ValueError):
|
||||
auto = True
|
||||
else:
|
||||
auto = rate <= 0.0
|
||||
self._lowpass_rate_auto = auto
|
||||
if not auto:
|
||||
self._lowpass_sample_rate_hz = rate
|
||||
elif self._lowpass_sample_rate_hz <= 0:
|
||||
self._lowpass_sample_rate_hz = self._config_fps
|
||||
if getattr(self, "_force_rate", None) is not None:
|
||||
self._force_rate.reset(self._config_fps)
|
||||
filter_changed = True
|
||||
if lowpass_order is not None:
|
||||
order = int(lowpass_order)
|
||||
@@ -780,6 +1193,22 @@ class Sensor:
|
||||
self._lowpass_enabled = bool(lowpass_enabled)
|
||||
filter_changed = True
|
||||
|
||||
if flow_ema_alpha is not None:
|
||||
alpha = float(flow_ema_alpha)
|
||||
if not (0.0 < alpha <= 1.0):
|
||||
raise ValueError("flow_ema_alpha must be in (0, 1]")
|
||||
self._flow_ema_alpha = alpha
|
||||
self._ema_flow = None
|
||||
|
||||
if force_median_window is not None:
|
||||
try:
|
||||
window = max(1, int(force_median_window))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("force_median_window must be an integer >= 1") from exc
|
||||
self._force_median_window = window
|
||||
self._force_median.configure(window)
|
||||
filter_changed = True
|
||||
|
||||
if filter_changed:
|
||||
cutoff = self._lowpass_cutoff_hz if self._lowpass_enabled else 0.0
|
||||
self._force_filter.configure(
|
||||
@@ -799,20 +1228,15 @@ class Sensor:
|
||||
self._hhd_downsample = k
|
||||
self.__hhd = None
|
||||
self._hhd_grid_shape = None
|
||||
self._hhd_ref_shape = None
|
||||
self._update_hhd_mask_cache()
|
||||
return self
|
||||
|
||||
def _resolve_force_calibration_sensor_name(self) -> Optional[str]:
|
||||
"""Return sensors/<name> when force_calibration.json exists; else None.
|
||||
|
||||
Lookup order:
|
||||
1. ``config_name`` stem / product alias (e.g. ``box``)
|
||||
2. ``sensors/<name>/...`` path segment in ``config_name``
|
||||
Missing JSON means ``FNORMAL/FSHEAR*`` stay raw (no hardcoded fallback).
|
||||
"""
|
||||
def _bundle_name_candidates(self) -> list[str]:
|
||||
"""Product bundle names implied by ``config_name``."""
|
||||
text = str(self.config_name or "").strip().replace("\\", "/")
|
||||
if not text:
|
||||
return None
|
||||
return []
|
||||
|
||||
candidates: list[str] = []
|
||||
stem = Path(text).stem if text else ""
|
||||
@@ -828,33 +1252,218 @@ class Sensor:
|
||||
candidates.append(name)
|
||||
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for candidate in candidates:
|
||||
alias = configs.CONFIG_ALIASES.get(candidate, candidate)
|
||||
for name in (alias, candidate):
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
if configs.load_force_calibration(name) is not None:
|
||||
return name
|
||||
return None
|
||||
ordered.append(name)
|
||||
return ordered
|
||||
|
||||
def _force_calibration_note(self) -> str:
|
||||
"""Explain a bundle that resolved but carried no usable coefficients.
|
||||
|
||||
The old message ("raw (no force_calibration.json)") reads as "the file is
|
||||
missing", which is exactly the wrong story when the lookup found a
|
||||
bundle from another root and stopped there without ever asking whether
|
||||
that root had coefficients.
|
||||
"""
|
||||
attempts = self._force_cal_attempts or []
|
||||
# ``found_root`` is keyed even when nothing was found (valued None), so
|
||||
# test its value, not the key.
|
||||
found = [entry for entry in attempts if entry.get("found_root")]
|
||||
with_cal = [entry for entry in found if entry.get("force_calibration_json")]
|
||||
chosen = (with_cal or found or [None])[0]
|
||||
searched: list = []
|
||||
for entry in attempts:
|
||||
for root in entry.get("searched") or ():
|
||||
if root not in searched:
|
||||
searched.append(root)
|
||||
lines: list[str] = []
|
||||
if chosen is None:
|
||||
lines.append("Force calibration: raw (no sensor bundle resolved)")
|
||||
else:
|
||||
root = chosen["found_root"]
|
||||
if chosen.get("force_calibration_json"):
|
||||
lines.append(
|
||||
f"Force calibration: raw (a bundle was found at {root}, but its "
|
||||
"force_calibration.json could not be loaded)"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f"Force calibration: raw (bundle found at {root} has no "
|
||||
"force_calibration.json)"
|
||||
)
|
||||
if searched:
|
||||
lines.append(
|
||||
" roots searched: " + "; ".join(str(root) for root in searched)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _init_force_calibration(self) -> None:
|
||||
"""Load force calibration from ``cal_path`` or the product bundle."""
|
||||
self.calibration = None
|
||||
self._force_cal_sensor_name = None
|
||||
self._force_cal_attempts = None
|
||||
|
||||
if self.cal_path is not None and str(self.cal_path).strip():
|
||||
# Explicit path: missing/invalid raises (no silent fallback).
|
||||
self.calibration = cal_mod.load(self.cal_path, strict=True)
|
||||
return
|
||||
|
||||
attempts: list[dict] = []
|
||||
for name in self._bundle_name_candidates():
|
||||
cal = cal_mod.load(sensor_type=name)
|
||||
if cal is not None:
|
||||
self.calibration = cal
|
||||
self._force_cal_sensor_name = name
|
||||
self._force_cal_attempts = None
|
||||
return
|
||||
try:
|
||||
attempts.append(configs.describe_bundle_resolution(name))
|
||||
except Exception: # noqa: BLE001 - diagnostics must never break loading
|
||||
continue
|
||||
# Deliberately NOT setting ``_force_cal_sensor_name``: callers read a
|
||||
# non-None value there as "this sensor is calibrated", and a bundle
|
||||
# without coefficients is not that.
|
||||
self._force_cal_attempts = attempts or None
|
||||
|
||||
@property
|
||||
def force_calibrated(self) -> bool:
|
||||
"""True when at least one channel curve is loaded."""
|
||||
return self.calibration is not None and bool(self.calibration.channels)
|
||||
|
||||
@property
|
||||
def force_unit(self) -> str:
|
||||
return "N" if self.force_calibrated else "raw"
|
||||
|
||||
def set_calibration(
|
||||
self,
|
||||
cal: "cal_mod.Calibration | str | Path | None",
|
||||
) -> None:
|
||||
"""Attach or replace the active force calibration (live swap after save)."""
|
||||
if cal is None:
|
||||
self.calibration = None
|
||||
self._force_cal_sensor_name = None
|
||||
self._force_cal_attempts = None
|
||||
return
|
||||
if isinstance(cal, cal_mod.Calibration):
|
||||
self.calibration = cal
|
||||
self._force_cal_attempts = None
|
||||
return
|
||||
self.calibration = cal_mod.load(cal, strict=True)
|
||||
self._force_cal_attempts = None
|
||||
|
||||
def _apply_force_filter(self):
|
||||
raw = np.asarray(self._force_raw, dtype=np.float64)
|
||||
pre_filter = np.asarray(self._force_raw, dtype=np.float64)
|
||||
raw = pre_filter
|
||||
# Median first: it deletes the heavy-tailed spikes that the flow chain
|
||||
# produces, so the low-pass does not have to spread them over time.
|
||||
median = getattr(self, "_force_median", None)
|
||||
if median is not None and median.enabled:
|
||||
raw = median.process(raw)
|
||||
if self._lowpass_enabled and self._lowpass_cutoff_hz > 0:
|
||||
filtered = self._force_filter.process(raw)
|
||||
fn, fx, fy = (float(v) for v in filtered)
|
||||
else:
|
||||
fn, fx, fy = float(raw[0]), float(raw[1]), float(raw[2])
|
||||
raw = self._force_filter.process(raw)
|
||||
# The chain's own output, before calibration: the "filtered raw counts".
|
||||
# Without this the raw axis would have no filtered/unfiltered pair and
|
||||
# ``force_triplets`` could only offer the device counts twice.
|
||||
self._force_filtered_raw = (
|
||||
float(raw[0]),
|
||||
float(raw[1]),
|
||||
float(raw[2]),
|
||||
)
|
||||
fn, fx, fy = self._force_filtered_raw
|
||||
|
||||
# Apply sensors/<name>/force_calibration.json when present; otherwise keep raw.
|
||||
name = self._force_cal_sensor_name
|
||||
if name is not None:
|
||||
fn = float(calibrate_normal_force(fn, sensor_name=name))
|
||||
fx, fy = calibrate_shear_components(fx, fy, sensor_name=name)
|
||||
# Apply loaded force_calibration.json when present; otherwise keep raw.
|
||||
if self.calibration is not None:
|
||||
fn, fx, fy = self.calibration.convert(fn, fx, fy)
|
||||
fx, fy = float(fx), float(fy)
|
||||
fn = float(fn)
|
||||
|
||||
self.fnormal, self.fshearx, self.fsheary = fn, fx, fy
|
||||
|
||||
# The same reading with no readout filtering, so a caller can keep both
|
||||
# (the app stores the filtered values as the record and the unfiltered
|
||||
# ones beside them). Calibrated with the same coefficients, so the two
|
||||
# only differ by the filter chain.
|
||||
ufn, ufx, ufy = (float(pre_filter[0]), float(pre_filter[1]), float(pre_filter[2]))
|
||||
if self.calibration is not None:
|
||||
ufn, ufx, ufy = self.calibration.convert(ufn, ufx, ufy)
|
||||
ufx, ufy, ufn = float(ufx), float(ufy), float(ufn)
|
||||
self._force_raw_unfiltered = (
|
||||
float(pre_filter[0]), float(pre_filter[1]), float(pre_filter[2])
|
||||
)
|
||||
self._force_unfiltered = (ufn, ufx, ufy)
|
||||
|
||||
# ------------------------------------------------------- filter contract
|
||||
def filters_state(self) -> dict:
|
||||
"""What the filter chain is **actually** doing, for records and audits.
|
||||
|
||||
Read this instead of the config file: a config can be overridden at
|
||||
runtime (``configure``) and a value that is present is never silently
|
||||
replaced, so the effective state is the only honest answer. The two
|
||||
groups are kept apart on purpose:
|
||||
|
||||
* ``measurement`` — stages upstream of the HHD/force computation
|
||||
(``flow_ema_alpha``): turning them on changes *what is measured*, so
|
||||
two recordings with different values are not comparable;
|
||||
* ``readout`` — stages that only smooth the force triple on its way out
|
||||
(median, low-pass): recorded, but they do not change the physical
|
||||
claim.
|
||||
"""
|
||||
median_applied = bool(getattr(self._force_median, "enabled", False))
|
||||
tracker_cfg = self._config.get("tracker") or {}
|
||||
return {
|
||||
"preset": getattr(self, "_filter_preset", None),
|
||||
"measurement": {
|
||||
"flow_ema_alpha": float(self._flow_ema_alpha),
|
||||
"flow_ema_applied": bool(self._flow_ema_alpha < 1.0),
|
||||
"hhd_downsample": int(getattr(self, "hhd_downsample", 0) or 0),
|
||||
"dis_mode": tracker_cfg.get("dis_mode"),
|
||||
},
|
||||
"readout": {
|
||||
"force_median_window": int(self._force_median_window),
|
||||
"force_median_applied": median_applied,
|
||||
"force_lowpass_cutoff_hz": float(self._lowpass_cutoff_hz),
|
||||
"force_lowpass_order": int(self._lowpass_order),
|
||||
"force_lowpass_sample_rate_hz": float(self._lowpass_sample_rate_hz),
|
||||
"force_lowpass_rate_auto": bool(self._lowpass_rate_auto),
|
||||
"force_lowpass_applied": bool(self._lowpass_enabled),
|
||||
"sample_rate_source": str(getattr(self, "_force_rate_source", "config")),
|
||||
},
|
||||
"any_stage_applied": bool(self._filters_enabled),
|
||||
"unfiltered_available": True,
|
||||
"source": "sensor.filters_state",
|
||||
}
|
||||
|
||||
def force_triplets(self) -> dict:
|
||||
"""The filtered and unfiltered force triples of the current frame.
|
||||
|
||||
One call, so a sampling thread reads a single consistent frame: the
|
||||
filtered pair is what ``read_info`` reports and what a recording stores
|
||||
as its primary columns, the unfiltered pair is the same reading with the
|
||||
readout stages bypassed.
|
||||
"""
|
||||
raw_unfiltered = getattr(self, "_force_raw_unfiltered", None)
|
||||
unfiltered = getattr(self, "_force_unfiltered", None)
|
||||
filtered_raw = getattr(self, "_force_filtered_raw", None)
|
||||
return {
|
||||
"filtered": {
|
||||
"raw": tuple(filtered_raw) if filtered_raw is not None else None,
|
||||
"newton": (
|
||||
float(self.fnormal),
|
||||
float(self.fshearx),
|
||||
float(self.fsheary),
|
||||
),
|
||||
},
|
||||
"unfiltered": {
|
||||
"raw": tuple(raw_unfiltered) if raw_unfiltered is not None else None,
|
||||
"newton": tuple(unfiltered) if unfiltered is not None else None,
|
||||
},
|
||||
}
|
||||
|
||||
# def export_config(self, path="./config/exported_config.json"):
|
||||
# """Return current configuration dict."""
|
||||
# configs.export_config(self._config, path)
|
||||
|
||||
+195
-146
@@ -3,14 +3,20 @@
|
||||
Batch ``lowpass_filter`` is the zero-phase Butterworth used by example charts.
|
||||
``LowpassFilter`` is a causal, stateful filter for per-frame ``Sensor`` outputs.
|
||||
|
||||
Force calibration resolution order (when register support arrives):
|
||||
1. Sensor register / EEPROM calibration blob
|
||||
2. ``sensors/<name>/force_calibration.json``
|
||||
3. Hardcoded linear defaults below
|
||||
Force calibration is owned by ``orisys.calibration``. These helpers remain as
|
||||
convenience wrappers:
|
||||
|
||||
1. Bundle JSON via ``calibration.load(sensor_type=...)``
|
||||
2. Hardcoded linear defaults for normal force when no file is present
|
||||
3. Shear helpers return raw when no curve is available
|
||||
|
||||
Prefer ``Sensor.calibration`` / ``read_info(FNORMAL, …)`` in new code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
@@ -26,20 +32,15 @@ DEFAULT_LOWPASS_ORDER = 2
|
||||
|
||||
|
||||
def _parse_calibration_point(point) -> tuple[float, float]:
|
||||
if isinstance(point, dict):
|
||||
raw = point.get("fnormal", point.get("raw", point.get("x")))
|
||||
force_n = point.get("force_n", point.get("n", point.get("y")))
|
||||
if raw is None or force_n is None:
|
||||
raise ValueError(f"invalid calibration point: {point!r}")
|
||||
return float(raw), float(force_n)
|
||||
if isinstance(point, (list, tuple)) and len(point) >= 2:
|
||||
return float(point[0]), float(point[1])
|
||||
raise ValueError(f"invalid calibration point: {point!r}")
|
||||
"""Legacy helper; prefer ``orisys.calibration``."""
|
||||
from .calibration import _parse_point
|
||||
|
||||
return _parse_point(point)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForceChannelCalibration:
|
||||
"""One force channel: linear coeffs and/or piecewise-linear breakpoints."""
|
||||
"""Compatibility wrapper around ``calibration.ChannelCurve`` convert math."""
|
||||
|
||||
method: str = "linear"
|
||||
slope: float | None = None
|
||||
@@ -63,75 +64,40 @@ class ForceChannelCalibration:
|
||||
unit: str = "N",
|
||||
default_method: str = "piecewise_linear",
|
||||
) -> ForceChannelCalibration | None:
|
||||
if not isinstance(block, dict):
|
||||
return None
|
||||
from .calibration import _channel_from_block
|
||||
|
||||
method = str(block.get("method", block.get("model", default_method))).lower()
|
||||
if method in ("linear", "affine"):
|
||||
slope = block.get("slope")
|
||||
offset = block.get("offset")
|
||||
if slope is None or offset is None:
|
||||
return None
|
||||
return cls.linear(float(slope), float(offset), unit=str(block.get("unit", unit)))
|
||||
|
||||
if method != "piecewise_linear":
|
||||
curve = _channel_from_block(
|
||||
"fn", block, unit=unit, default_method=default_method
|
||||
)
|
||||
if curve is None:
|
||||
return None
|
||||
|
||||
raw_points = block.get("points", [])
|
||||
if not isinstance(raw_points, (list, tuple)) or len(raw_points) < 2:
|
||||
return None
|
||||
try:
|
||||
points = tuple(sorted(_parse_calibration_point(p) for p in raw_points))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if len(points) < 2:
|
||||
return None
|
||||
|
||||
return cls(
|
||||
method="piecewise_linear",
|
||||
points=points,
|
||||
extrapolate=bool(block.get("extrapolate", True)),
|
||||
clamp_output_min=block.get("clamp_output_min", 0.0),
|
||||
input_mode=str(block.get("input", "magnitude")).lower(),
|
||||
signed=bool(block.get("signed", False)),
|
||||
unit=str(block.get("unit", unit)),
|
||||
method=curve.method,
|
||||
slope=curve.slope,
|
||||
offset=curve.offset,
|
||||
points=curve.points,
|
||||
extrapolate=curve.extrapolate,
|
||||
clamp_output_min=curve.clamp_output_min,
|
||||
input_mode=curve.input_mode,
|
||||
signed=curve.signed,
|
||||
unit=curve.unit,
|
||||
)
|
||||
|
||||
def convert(self, raw: float) -> float:
|
||||
raw = float(raw)
|
||||
if self.method == "linear":
|
||||
slope = NORMAL_FORCE_CAL_SLOPE if self.slope is None else float(self.slope)
|
||||
offset = NORMAL_FORCE_CAL_OFFSET if self.offset is None else float(self.offset)
|
||||
return slope * raw + offset
|
||||
from .calibration import ChannelCurve
|
||||
|
||||
points = self.points
|
||||
if len(points) < 2:
|
||||
return raw
|
||||
|
||||
if raw <= points[0][0]:
|
||||
segment = (points[0], points[1])
|
||||
if not self.extrapolate:
|
||||
return self._clamp(points[0][1])
|
||||
elif raw >= points[-1][0]:
|
||||
segment = (points[-2], points[-1])
|
||||
if not self.extrapolate:
|
||||
return self._clamp(points[-1][1])
|
||||
else:
|
||||
segment = None
|
||||
for left, right in zip(points, points[1:]):
|
||||
if left[0] <= raw <= right[0]:
|
||||
segment = (left, right)
|
||||
break
|
||||
if segment is None:
|
||||
return self._clamp(points[-1][1])
|
||||
|
||||
(raw0, n0), (raw1, n1) = segment
|
||||
if raw1 == raw0:
|
||||
value = n0
|
||||
else:
|
||||
ratio = (raw - raw0) / (raw1 - raw0)
|
||||
value = n0 + ratio * (n1 - n0)
|
||||
return self._clamp(value)
|
||||
return ChannelCurve(
|
||||
id="fn",
|
||||
method=self.method,
|
||||
points=self.points,
|
||||
slope=self.slope if self.slope is not None else NORMAL_FORCE_CAL_SLOPE,
|
||||
offset=self.offset if self.offset is not None else NORMAL_FORCE_CAL_OFFSET,
|
||||
signed=self.signed,
|
||||
extrapolate=self.extrapolate,
|
||||
clamp_output_min=self.clamp_output_min,
|
||||
input_mode=self.input_mode,
|
||||
unit=self.unit,
|
||||
).convert(raw)
|
||||
|
||||
def convert_component(self, raw: float) -> float:
|
||||
if not self.signed:
|
||||
@@ -140,11 +106,6 @@ class ForceChannelCalibration:
|
||||
sign = -1.0 if raw < 0.0 else 1.0
|
||||
return sign * self.convert(abs(raw))
|
||||
|
||||
def _clamp(self, value: float) -> float:
|
||||
if self.clamp_output_min is not None:
|
||||
return max(float(self.clamp_output_min), float(value))
|
||||
return float(value)
|
||||
|
||||
|
||||
_DEFAULT_NORMAL = ForceChannelCalibration.linear(
|
||||
NORMAL_FORCE_CAL_SLOPE,
|
||||
@@ -152,57 +113,41 @@ _DEFAULT_NORMAL = ForceChannelCalibration.linear(
|
||||
)
|
||||
|
||||
|
||||
def _load_force_calibration_data(sensor_name: str | None) -> dict | None:
|
||||
def _load_calibration(sensor_name: str | None):
|
||||
if not sensor_name:
|
||||
return None
|
||||
try:
|
||||
from .configs import load_force_calibration
|
||||
from .calibration import load
|
||||
|
||||
data = load_force_calibration(sensor_name)
|
||||
return load(sensor_type=sensor_name)
|
||||
except Exception:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _channel_from_data(data: dict | None, channel: str) -> ForceChannelCalibration | None:
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
unit = str(data.get("unit", "N"))
|
||||
channels = data.get("channels")
|
||||
if isinstance(channels, dict):
|
||||
block = channels.get(channel)
|
||||
cal = ForceChannelCalibration.from_channel_dict(block, unit=unit)
|
||||
if cal is not None:
|
||||
return cal
|
||||
|
||||
# Legacy single-channel file: {"normal_force": {"model": "linear", ...}}
|
||||
if channel == "normal":
|
||||
block = data.get("normal_force", data)
|
||||
cal = ForceChannelCalibration.from_channel_dict(
|
||||
block, unit=unit, default_method="linear"
|
||||
)
|
||||
if cal is not None:
|
||||
return cal
|
||||
if isinstance(block, dict):
|
||||
slope = block.get("slope")
|
||||
offset = block.get("offset")
|
||||
if slope is not None and offset is not None:
|
||||
return ForceChannelCalibration.linear(
|
||||
float(slope),
|
||||
float(offset),
|
||||
unit=str(block.get("unit", unit)),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _normal_calibration(sensor_name: str | None = None) -> ForceChannelCalibration:
|
||||
cal = _channel_from_data(_load_force_calibration_data(sensor_name), "normal")
|
||||
return cal if cal is not None else _DEFAULT_NORMAL
|
||||
cal = _load_calibration(sensor_name)
|
||||
if cal is not None:
|
||||
curve = cal.get("fn")
|
||||
if curve is not None:
|
||||
return ForceChannelCalibration(
|
||||
method=curve.method,
|
||||
slope=curve.slope,
|
||||
offset=curve.offset,
|
||||
points=curve.points,
|
||||
extrapolate=curve.extrapolate,
|
||||
clamp_output_min=curve.clamp_output_min,
|
||||
input_mode=curve.input_mode,
|
||||
signed=curve.signed,
|
||||
unit=curve.unit,
|
||||
)
|
||||
return _DEFAULT_NORMAL
|
||||
|
||||
|
||||
def _shear_calibration(sensor_name: str | None = None) -> ForceChannelCalibration | None:
|
||||
return _channel_from_data(_load_force_calibration_data(sensor_name), "shear")
|
||||
def _shear_curve(sensor_name: str | None, channel: str):
|
||||
cal = _load_calibration(sensor_name)
|
||||
if cal is None:
|
||||
return None
|
||||
return cal.get(channel)
|
||||
|
||||
|
||||
def _load_normal_force_coeffs(sensor_name: str | None = None) -> tuple[float, float]:
|
||||
@@ -225,12 +170,20 @@ def calibrate_normal_force(raw_value: float, *, sensor_name: str | None = None)
|
||||
def calibrate_shear_force(raw_value: float, *, sensor_name: str | None = None) -> float:
|
||||
"""Convert a raw shear magnitude to Newtons using the shear channel curve.
|
||||
|
||||
Returns ``float(raw_value)`` unchanged when no shear calibration is available.
|
||||
Prefers the average of FX/FY magnitude maps when present.
|
||||
Returns ``float(raw_value)`` when unavailable.
|
||||
"""
|
||||
cal = _shear_calibration(sensor_name)
|
||||
if cal is None:
|
||||
return float(raw_value)
|
||||
return cal.convert(raw_value)
|
||||
cal_x = _shear_curve(sensor_name, "fx")
|
||||
cal_y = _shear_curve(sensor_name, "fy")
|
||||
if cal_x is not None and cal_y is not None:
|
||||
return 0.5 * (
|
||||
cal_x.convert(abs(float(raw_value))) + cal_y.convert(abs(float(raw_value)))
|
||||
)
|
||||
if cal_x is not None:
|
||||
return cal_x.convert(abs(float(raw_value)))
|
||||
if cal_y is not None:
|
||||
return cal_y.convert(abs(float(raw_value)))
|
||||
return float(raw_value)
|
||||
|
||||
|
||||
def calibrate_shear_components(
|
||||
@@ -239,27 +192,11 @@ def calibrate_shear_components(
|
||||
*,
|
||||
sensor_name: str | None = None,
|
||||
) -> tuple[float, float]:
|
||||
"""Convert raw shear components to Newtons.
|
||||
|
||||
When the shear channel uses ``input: components`` and ``signed: true``, each
|
||||
axis is calibrated on absolute magnitude then re-signed. Otherwise the
|
||||
magnitude is calibrated and components are scaled proportionally.
|
||||
"""
|
||||
sx = float(shear_x)
|
||||
sy = float(shear_y)
|
||||
cal = _shear_calibration(sensor_name)
|
||||
"""Convert raw shear components to Newtons via ``calibration.load`` curves."""
|
||||
cal = _load_calibration(sensor_name)
|
||||
if cal is None:
|
||||
return sx, sy
|
||||
|
||||
if cal.input_mode == "components":
|
||||
return cal.convert_component(sx), cal.convert_component(sy)
|
||||
|
||||
magnitude = (sx * sx + sy * sy) ** 0.5
|
||||
if magnitude <= 0.0:
|
||||
return 0.0, 0.0
|
||||
calibrated = cal.convert(magnitude)
|
||||
scale = calibrated / magnitude
|
||||
return sx * scale, sy * scale
|
||||
return float(shear_x), float(shear_y)
|
||||
return cal.convert(0.0, float(shear_x), float(shear_y))[1:]
|
||||
|
||||
|
||||
def _nyquist_hz(sample_rate_hz: float) -> float:
|
||||
@@ -377,3 +314,115 @@ class LowpassFilter:
|
||||
self.reset(x)
|
||||
y, self._zi = sosfilt(self._sos, x[None, :], zi=self._zi, axis=0)
|
||||
return np.asarray(y[0], dtype=np.float64)
|
||||
|
||||
|
||||
class CausalMedianFilter:
|
||||
"""Causal sliding-median over the last ``window`` samples, one sample at a time.
|
||||
|
||||
The optical-flow force chain produces heavy-tailed jitter (rare multi-sigma
|
||||
spikes). A linear low-pass spreads such a spike over several frames instead
|
||||
of deleting it, whereas a median removes it outright - measured on idle
|
||||
data, a 5-sample median cut the jitter 4x better than a moving average of
|
||||
the same width.
|
||||
|
||||
``window <= 1`` disables the filter (pass-through). Until ``window`` samples
|
||||
have arrived the median of the available samples is returned, so the output
|
||||
is well defined from the first frame.
|
||||
"""
|
||||
|
||||
def __init__(self, window: int = 1, n_channels: int = 3):
|
||||
self.n_channels = int(n_channels)
|
||||
self.window = 1
|
||||
self._buf = None
|
||||
self.configure(window)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.window > 1
|
||||
|
||||
def configure(self, window: int | None = None) -> None:
|
||||
if window is not None:
|
||||
try:
|
||||
self.window = max(1, int(window))
|
||||
except (TypeError, ValueError):
|
||||
self.window = 1
|
||||
self._buf = None
|
||||
|
||||
def reset(self, x0=None) -> None:
|
||||
"""Drop the window history (e.g. after a tracker reset)."""
|
||||
self._buf = None
|
||||
|
||||
def process(self, values) -> np.ndarray:
|
||||
"""Filter one multi-channel sample. Returns float64 array of shape (n_channels,)."""
|
||||
x = np.asarray(values, dtype=np.float64).reshape(-1)
|
||||
if x.size != self.n_channels:
|
||||
raise ValueError(f"expected {self.n_channels} channels, got {x.size}")
|
||||
if not self.enabled:
|
||||
return x.copy()
|
||||
if self._buf is None or self._buf.shape[1] != x.size:
|
||||
self._buf = x[None, :].copy()
|
||||
return x.copy()
|
||||
self._buf = np.vstack((self._buf, x[None, :]))[-self.window:]
|
||||
return np.median(self._buf, axis=0)
|
||||
|
||||
|
||||
class SampleRateEstimator:
|
||||
"""Robust online estimate of the rate at which :meth:`tick` is called.
|
||||
|
||||
Uses the *median* of the most recent inter-call intervals, so one stall or
|
||||
scheduling hiccup does not poison the estimate. :attr:`rate_hz` returns the
|
||||
seed value until ``min_samples`` intervals have been observed.
|
||||
|
||||
This exists because a fixed floor filter designed for 30 Hz behaves
|
||||
differently at 60 Hz: the cutoff stays put in Hz but the per-frame
|
||||
smoothing halves. Measuring the achieved rate keeps the filter identical in
|
||||
the time domain whatever the deployment runs at.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_hz: float = 30.0,
|
||||
window: int = 45,
|
||||
min_samples: int = 8,
|
||||
max_interval_s: float = 1.0,
|
||||
):
|
||||
self.initial_hz = float(initial_hz) if initial_hz else 30.0
|
||||
self.window = max(3, int(window))
|
||||
self.min_samples = max(2, int(min_samples))
|
||||
self.max_interval_s = float(max_interval_s)
|
||||
self.reset()
|
||||
|
||||
def reset(self, initial_hz: float | None = None) -> None:
|
||||
if initial_hz is not None:
|
||||
self.initial_hz = float(initial_hz) if initial_hz else 30.0
|
||||
self._dts: collections.deque = collections.deque(maxlen=self.window)
|
||||
self._last = None
|
||||
self._rate = self.initial_hz
|
||||
self._measured = False
|
||||
|
||||
@property
|
||||
def rate_hz(self) -> float:
|
||||
return self._rate
|
||||
|
||||
@property
|
||||
def measured(self) -> bool:
|
||||
"""True once the estimate comes from real intervals."""
|
||||
return self._measured
|
||||
|
||||
def tick(self, now: float | None = None) -> float:
|
||||
"""Register one sample and return the current rate estimate."""
|
||||
now = time.perf_counter() if now is None else float(now)
|
||||
last, self._last = self._last, now
|
||||
if last is None:
|
||||
return self._rate
|
||||
dt = now - last
|
||||
if not (1e-5 < dt <= self.max_interval_s):
|
||||
# Stalls, pauses and the first frame carry no rate information.
|
||||
return self._rate
|
||||
self._dts.append(dt)
|
||||
if len(self._dts) >= self.min_samples:
|
||||
dt_med = float(np.median(self._dts))
|
||||
if dt_med > 0.0:
|
||||
self._rate = 1.0 / dt_med
|
||||
self._measured = True
|
||||
return self._rate
|
||||
|
||||
+21
-4
@@ -94,13 +94,30 @@ class Tracker:
|
||||
return mask.astype(bool)
|
||||
|
||||
def apply_valid_mask(self, flow, valid_mask, erode_iters=1):
|
||||
"""Zero flow outside the valid UV domain."""
|
||||
"""Zero flow outside the valid UV domain (in place when safe).
|
||||
|
||||
Multiplying by a 0/1 mask is a single streaming pass, whereas
|
||||
``flow.copy()`` followed by a boolean fancy-index assignment costs a
|
||||
full copy plus a gather/scatter over the masked-out pixels.
|
||||
|
||||
Note: a masked-out *negative* component becomes ``-0.0`` rather than
|
||||
``+0.0``. The two compare, sum and magnitude identically; only the raw
|
||||
bit pattern of those zeros differs from the previous implementation.
|
||||
"""
|
||||
if flow is None or valid_mask is None:
|
||||
return flow
|
||||
mask = self._prepare_valid_mask(valid_mask, flow.shape[:2], erode_iters=erode_iters)
|
||||
flow_out = flow.copy()
|
||||
flow_out[~mask] = 0
|
||||
return flow_out
|
||||
if not getattr(self, "_Tracker__adaptive", False):
|
||||
# The non-adaptive path hands back the DIS state buffer, which feeds
|
||||
# the next frame as its initial guess; never mutate it.
|
||||
flow_out = flow.copy()
|
||||
flow_out[~mask] = 0
|
||||
return flow_out
|
||||
if not flow.flags.writeable:
|
||||
flow = flow.copy()
|
||||
mask_f = mask.astype(np.float32, copy=False)[:, :, None]
|
||||
np.multiply(flow, mask_f, out=flow)
|
||||
return flow
|
||||
|
||||
def track(self, frame, valid_mask=None):
|
||||
"""Main tracking function that dispatches to CUDA or CPU implementation"""
|
||||
|
||||
+4
-1
@@ -75,7 +75,10 @@ def draw_arrows(
|
||||
)
|
||||
if mask is not None:
|
||||
valid &= mask[rr, cc]
|
||||
valid &= mask[end_y, end_x]
|
||||
# Arrow tips may fall outside the frame (the length cap is applied
|
||||
# before the bounds check above), so clamp before indexing: those points
|
||||
# are already False in ``valid``, and in-bounds indices are unchanged.
|
||||
valid &= mask[np.clip(end_y, 0, h - 1), np.clip(end_x, 0, w - 1)]
|
||||
|
||||
mag_range = mag.max() - mag.min()
|
||||
norm_mag = (
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""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"
|
||||
BUILD_ROOT = REPO_ROOT / "build"
|
||||
EXAMPLES_ROOT = REPO_ROOT / "examples"
|
||||
|
||||
# Establish import precedence once, here, before any test module is imported. The
|
||||
# test modules insert the same directories, but all of those inserts are guarded
|
||||
# by `if path not in sys.path`, so once the paths are present the guards turn
|
||||
# them into no-ops and this order sticks.
|
||||
#
|
||||
# Order matters and is not incidental:
|
||||
# * examples/ must win over build/ because `build/gui` is also a package named
|
||||
# `gui`; with build/ first, `import gui.viewer2d` silently resolves to the
|
||||
# build tooling and fails.
|
||||
# * build/ must precede site-packages so `wheel`, `common` and `release` mean
|
||||
# the repository's build tooling, not the unrelated PyPI `wheel` package.
|
||||
# * src/ provides the checkout's `orisys`.
|
||||
# Insert in reverse priority (each insert goes to the front).
|
||||
for _path in (SRC_ROOT, BUILD_ROOT, EXAMPLES_ROOT):
|
||||
if str(_path) not in sys.path:
|
||||
sys.path.insert(0, str(_path))
|
||||
|
||||
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. The 720p clip is
|
||||
# deliberately not a fallback: tests should run the sanctioned sample or skip,
|
||||
# never silently verify against a different resolution.
|
||||
DEFAULT_VIDEO_CANDIDATES = (
|
||||
Path("tests") / "idle_1min.mp4",
|
||||
Path("data") / "sample" / "s1.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
|
||||
@@ -8,7 +8,6 @@ Flow:
|
||||
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
|
||||
5. Compute deformation/contact and visualize
|
||||
|
||||
SDK version: 0.5.0
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Tests for orisys.calibration load/parse/write and Sensor apply."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys import Sensor
|
||||
from orisys.calibration import (
|
||||
CalSample,
|
||||
CalibrationError,
|
||||
build,
|
||||
check,
|
||||
filter_mismatches,
|
||||
filters_fingerprint,
|
||||
load,
|
||||
parse,
|
||||
pipeline_from_sensor_json,
|
||||
write,
|
||||
)
|
||||
|
||||
|
||||
def _box_cal_path() -> Path:
|
||||
# Editable checkout: repo_root/sensors/box/force_calibration.json
|
||||
here = Path(__file__).resolve()
|
||||
candidate = here.parents[1] / "sensors" / "box" / "force_calibration.json"
|
||||
assert candidate.is_file(), f"missing shipped cal: {candidate}"
|
||||
return candidate
|
||||
|
||||
|
||||
def test_parse_box_v1_piecewise():
|
||||
cal = load(sensor_type="box")
|
||||
assert cal is not None
|
||||
assert cal.get("FN") is not None
|
||||
assert cal.get("FN").convert(10000.0) == pytest.approx(0.377)
|
||||
assert cal.get("FX").convert_component(36000.0) == pytest.approx(1.19)
|
||||
assert cal.get("FX").convert_component(-36000.0) == pytest.approx(-1.19)
|
||||
assert cal.get("FY").convert_component(36000.0) == pytest.approx(1.19)
|
||||
|
||||
|
||||
def test_parse_v2_tmp_file(tmp_path: Path):
|
||||
doc = {
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0], [1000.0, 1.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": 0.0,
|
||||
},
|
||||
"shear_x": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"points": [[0.0, 0.0], [500.0, 2.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": 0.0,
|
||||
},
|
||||
"shear_y": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"points": [[0.0, 0.0], [500.0, 3.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": 0.0,
|
||||
},
|
||||
},
|
||||
"meta": {"sensor_type": "box", "sn": "TEST-001"},
|
||||
}
|
||||
path = tmp_path / "force_calibration_v2.json"
|
||||
path.write_text(json.dumps(doc), encoding="utf-8")
|
||||
cal = load(path)
|
||||
assert cal is not None
|
||||
assert check(cal).ok
|
||||
assert cal.convert(1000.0, 500.0, -500.0) == pytest.approx((1.0, 2.0, -3.0))
|
||||
|
||||
|
||||
def test_build_write_load_roundtrip(tmp_path: Path):
|
||||
samples = {
|
||||
"fn": [
|
||||
CalSample(weight_n=0.0, raw_mean=0.0, n=10),
|
||||
CalSample(weight_n=1.0, raw_mean=1000.0, n=10),
|
||||
],
|
||||
"fx": [
|
||||
CalSample(weight_n=0.0, raw_mean=0.0, n=10),
|
||||
CalSample(weight_n=2.0, raw_mean=500.0, n=10),
|
||||
],
|
||||
"fy": [
|
||||
CalSample(weight_n=0.0, raw_mean=0.0, n=10),
|
||||
CalSample(weight_n=3.0, raw_mean=500.0, n=10),
|
||||
],
|
||||
}
|
||||
cal = build(
|
||||
samples,
|
||||
identity={"sensor_type": "box", "sn": "RT-1"},
|
||||
pipeline={"dis_mode": 1},
|
||||
origin="user",
|
||||
)
|
||||
path = tmp_path / "out.json"
|
||||
write(cal, path, overwrite=True)
|
||||
loaded = load(path)
|
||||
assert loaded is not None
|
||||
assert loaded.convert(1000.0, 500.0, -500.0) == pytest.approx((1.0, 2.0, -3.0))
|
||||
|
||||
|
||||
def test_check_rejects_bad_schema_and_short_points():
|
||||
bad_schema = {"schema_version": 99, "channels": {"normal": {"method": "linear", "slope": 1, "offset": 0}}}
|
||||
result = check(bad_schema)
|
||||
assert not result.ok
|
||||
assert any("schema_version" in e for e in result.errors)
|
||||
|
||||
short = {
|
||||
"schema_version": 2,
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0]],
|
||||
}
|
||||
},
|
||||
}
|
||||
result2 = check(short)
|
||||
assert not result2.ok
|
||||
|
||||
|
||||
def test_sensor_cal_path_v2_applies_to_read_info(tmp_path: Path):
|
||||
doc = {
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0], [100.0, 5.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": 0.0,
|
||||
},
|
||||
"shear_x": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"points": [[0.0, 0.0], [100.0, 7.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": 0.0,
|
||||
},
|
||||
"shear_y": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"points": [[0.0, 0.0], [100.0, 9.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": 0.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
path = tmp_path / "v2.json"
|
||||
path.write_text(json.dumps(doc), encoding="utf-8")
|
||||
sensor = Sensor(
|
||||
"external",
|
||||
config_name="box",
|
||||
cal_path=str(path),
|
||||
verbose=False,
|
||||
backend="cpu",
|
||||
)
|
||||
try:
|
||||
assert sensor.force_calibrated
|
||||
assert sensor.force_unit == "N"
|
||||
sensor._force_raw = (100.0, 100.0, -100.0)
|
||||
sensor._apply_force_filter()
|
||||
fn, fx, fy = sensor.read_info(
|
||||
sensor.info.FNORMAL,
|
||||
sensor.info.FSHEARX,
|
||||
sensor.info.FSHEARY,
|
||||
)
|
||||
fn_raw, fx_raw, fy_raw = sensor.read_info(
|
||||
sensor.info.FNORMAL_RAW,
|
||||
sensor.info.FSHEARX_RAW,
|
||||
sensor.info.FSHEARY_RAW,
|
||||
)
|
||||
assert (fn_raw, fx_raw, fy_raw) == pytest.approx((100.0, 100.0, -100.0))
|
||||
assert (fn, fx, fy) == pytest.approx((5.0, 7.0, -9.0))
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_set_calibration_swaps_curve(tmp_path: Path):
|
||||
path_a = tmp_path / "a.json"
|
||||
path_b = tmp_path / "b.json"
|
||||
path_a.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 2,
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0], [10.0, 1.0]],
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
path_b.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 2,
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0], [10.0, 2.0]],
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sensor = Sensor(
|
||||
"external",
|
||||
config_name="box",
|
||||
cal_path=str(path_a),
|
||||
verbose=False,
|
||||
backend="cpu",
|
||||
)
|
||||
try:
|
||||
sensor._force_raw = (10.0, 0.0, 0.0)
|
||||
sensor._apply_force_filter()
|
||||
assert sensor.fnormal == pytest.approx(1.0)
|
||||
sensor.set_calibration(path_b)
|
||||
sensor._apply_force_filter()
|
||||
assert sensor.fnormal == pytest.approx(2.0)
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_missing_cal_path_raises():
|
||||
with pytest.raises(CalibrationError):
|
||||
Sensor(
|
||||
"external",
|
||||
config_name="box",
|
||||
cal_path="D:/no/such/force_calibration.json",
|
||||
verbose=False,
|
||||
backend="cpu",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_shipped_box_file_directly():
|
||||
cal = parse(json.loads(_box_cal_path().read_text(encoding="utf-8")), source_path=_box_cal_path())
|
||||
assert cal.schema_version == 2
|
||||
assert cal.convert(10000.0, 36000.0, -36000.0) == pytest.approx((0.377, 1.19, -1.19))
|
||||
# The shipped file names both shear axes outright: no shared block, no clone.
|
||||
assert set(cal.channels) == {"fn", "fx", "fy"}
|
||||
fx, fy = cal.get("FX"), cal.get("FY")
|
||||
assert fx is not None and fy is not None and fx is not fy
|
||||
for curve in (fx, fy):
|
||||
assert curve.signed is True
|
||||
assert curve.extrapolate is True
|
||||
assert curve.clamp_output_min == pytest.approx(0.0)
|
||||
assert len(curve.points) == 3
|
||||
assert fx.raw_key == "fshearx"
|
||||
assert fy.raw_key == "fsheary"
|
||||
|
||||
|
||||
def test_the_shipped_box_shear_axes_can_drift_apart(tmp_path: Path):
|
||||
"""The whole point of the split: FX and FY are independent curves.
|
||||
|
||||
A document with the same shape as the shipped one but different per-axis
|
||||
points must apply each axis's own curve. Re-collapsing the axes into one
|
||||
shared ``shear`` block makes this fail.
|
||||
"""
|
||||
doc = {
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0], [1000.0, 1.0]],
|
||||
},
|
||||
"shear_x": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"input": "components",
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": 0.0,
|
||||
"points": [[0.0, 0.0], [500.0, 2.0]],
|
||||
},
|
||||
"shear_y": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"input": "components",
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": 0.0,
|
||||
"points": [[0.0, 0.0], [500.0, 3.0]],
|
||||
},
|
||||
},
|
||||
}
|
||||
path = tmp_path / "split.json"
|
||||
path.write_text(json.dumps(doc), encoding="utf-8")
|
||||
cal = load(path)
|
||||
assert cal is not None
|
||||
assert cal.convert(0.0, 500.0, 500.0)[1:] == pytest.approx((2.0, 3.0))
|
||||
assert cal.convert(0.0, -500.0, 500.0)[1:] == pytest.approx((-2.0, 3.0))
|
||||
|
||||
|
||||
def test_out_of_range_readings_extend_instead_of_being_dropped():
|
||||
"""Outside the calibrated range the nearest segment is extended.
|
||||
|
||||
This pins the "extend, never ignore, never reuse a previous coefficient"
|
||||
behaviour of the shipped box file, so flipping ``extrapolate`` has to be a
|
||||
deliberate edit that fails this test first.
|
||||
"""
|
||||
cal = load(sensor_type="box")
|
||||
assert cal is not None
|
||||
fx, fy, fn = cal.get("FX"), cal.get("FY"), cal.get("FN")
|
||||
assert fx is not None and fy is not None and fn is not None
|
||||
# 136000 = 2 x the top raw break (68000 -> 2.06): the last segment's slope is
|
||||
# applied again, so the value keeps growing past the calibrated range.
|
||||
(r0, n0), (r1, n1) = fx.points[-2], fx.points[-1]
|
||||
extended = n1 + (n1 - n0) / (r1 - r0) * (136000.0 - r1)
|
||||
assert extended > n1
|
||||
assert fx.convert_component(136000.0) == pytest.approx(extended)
|
||||
assert fy.convert_component(136000.0) == pytest.approx(extended)
|
||||
# A negative beyond the range keeps its sign.
|
||||
assert fx.convert_component(-136000.0) == pytest.approx(-extended)
|
||||
# Below the first point the normal channel floors at clamp_output_min.
|
||||
assert fn.convert(500.0) == pytest.approx(0.0)
|
||||
|
||||
|
||||
# ------------------------------------------------------- filter fingerprint
|
||||
ALL_OFF_FILTERS = {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto",
|
||||
}
|
||||
|
||||
|
||||
def _sensor_with_filters(tmp_path: Path, filters: dict) -> Sensor:
|
||||
"""An external-frame Sensor whose bundle carries ``filters``."""
|
||||
bundle = _box_cal_path().parent
|
||||
copy = tmp_path / "box"
|
||||
shutil.copytree(bundle, copy)
|
||||
path = copy / "sensor.json"
|
||||
cfg = json.loads(path.read_text(encoding="utf-8"))
|
||||
cfg["filters"] = filters
|
||||
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||
return Sensor(
|
||||
"external",
|
||||
config_name=str(path),
|
||||
cal_path=str(copy / "force_calibration.json"),
|
||||
verbose=False,
|
||||
backend="cpu",
|
||||
)
|
||||
|
||||
|
||||
def test_the_fingerprint_keeps_measurement_and_readout_apart():
|
||||
"""A change to one group must not be confused with a change to the other."""
|
||||
fp = filters_fingerprint(ALL_OFF_FILTERS)
|
||||
assert fp["measurement"] == {"flow_ema_alpha": 1.0}
|
||||
assert fp["readout"] == {
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_rate_auto": True,
|
||||
}
|
||||
|
||||
|
||||
def test_a_preset_expands_without_inventing_anything():
|
||||
fp = filters_fingerprint({"preset": "low_noise"})
|
||||
assert fp["preset"] == "low_noise"
|
||||
assert fp["measurement"] == {"flow_ema_alpha": 0.3}
|
||||
assert fp["readout"] == {"force_median_window": 3, "force_lowpass_cutoff_hz": 10.0}
|
||||
# An explicit key wins over the preset.
|
||||
explicit = filters_fingerprint({"preset": "low_noise", "force_lowpass_cutoff_hz": 0.0})
|
||||
assert explicit["readout"]["force_lowpass_cutoff_hz"] == 0.0
|
||||
# Keys no source states are absent, never filled in from a default.
|
||||
assert filters_fingerprint({}) == {}
|
||||
assert filters_fingerprint(None) == {}
|
||||
assert filters_fingerprint({"flow_ema_alpha": "not-a-number"}) == {}
|
||||
|
||||
|
||||
def test_the_deprecated_master_switch_still_means_all_off():
|
||||
fp = filters_fingerprint(dict(ALL_OFF_FILTERS, enabled=False, flow_ema_alpha=0.3))
|
||||
assert fp["measurement"] == {"flow_ema_alpha": 1.0}
|
||||
assert fp["readout"]["force_median_window"] == 1
|
||||
assert fp["readout"]["force_lowpass_cutoff_hz"] == 0.0
|
||||
|
||||
|
||||
def test_a_measurement_change_warns_and_a_readout_change_only_notes():
|
||||
recorded = filters_fingerprint({"preset": "balanced"})
|
||||
expected = filters_fingerprint({"preset": "low_noise"})
|
||||
warns, notes = filter_mismatches(recorded, expected)
|
||||
assert any("not comparable" in w and "flow_ema_alpha" in w for w in warns)
|
||||
assert not any("flow_ema_alpha" in n for n in notes)
|
||||
assert any("force_lowpass_cutoff_hz" in n for n in notes)
|
||||
assert filter_mismatches(recorded, recorded) == ([], [])
|
||||
|
||||
|
||||
def test_check_reports_the_two_groups_apart():
|
||||
samples = {
|
||||
key: [
|
||||
CalSample(weight_n=0.0, raw_mean=0.0, n=10),
|
||||
CalSample(weight_n=1.0, raw_mean=1000.0, n=10),
|
||||
]
|
||||
for key in ("fn", "fx", "fy")
|
||||
}
|
||||
doc = build(
|
||||
samples,
|
||||
identity={"sensor_type": "box", "sn": "FP-1"},
|
||||
pipeline={"filters": filters_fingerprint({"preset": "balanced"})},
|
||||
)
|
||||
result = check(doc, pipeline={"filters": filters_fingerprint({"preset": "low_noise"})})
|
||||
assert result.ok, "a filter difference must not refuse a calibration"
|
||||
assert any("not comparable" in w for w in result.warnings)
|
||||
assert any("readout" in n for n in result.notes)
|
||||
assert not any("readout" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_the_fingerprint_survives_a_write_and_read(tmp_path: Path):
|
||||
samples = {
|
||||
key: [
|
||||
CalSample(weight_n=0.0, raw_mean=0.0, n=10),
|
||||
CalSample(weight_n=1.0, raw_mean=1000.0, n=10),
|
||||
]
|
||||
for key in ("fn", "fx", "fy")
|
||||
}
|
||||
doc = build(
|
||||
samples,
|
||||
identity={"sensor_type": "box", "sn": "FP-2"},
|
||||
pipeline={"filters": filters_fingerprint({"preset": "balanced"})},
|
||||
)
|
||||
path = tmp_path / "fp.json"
|
||||
write(doc, path, overwrite=True)
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert raw["meta"]["filters"]["measurement"] == {"flow_ema_alpha": 0.5}
|
||||
# The flat keys stay for readers that look them up by name.
|
||||
assert raw["meta"]["flow_ema_alpha"] == 0.5
|
||||
|
||||
loaded = load(path)
|
||||
assert loaded.pipeline["filters"]["measurement"] == {"flow_ema_alpha": 0.5}
|
||||
assert loaded.pipeline["filters"]["readout"]["force_median_window"] == 3
|
||||
assert loaded.pipeline["filters"]["preset"] == "balanced"
|
||||
assert loaded.to_dict()["meta"]["filters"] == doc.to_dict()["meta"]["filters"]
|
||||
|
||||
|
||||
def test_a_legacy_flat_fingerprint_is_still_read_back(tmp_path: Path):
|
||||
"""Documents written before the groups existed keep being comparable."""
|
||||
samples = {
|
||||
key: [
|
||||
CalSample(weight_n=0.0, raw_mean=0.0, n=10),
|
||||
CalSample(weight_n=1.0, raw_mean=1000.0, n=10),
|
||||
]
|
||||
for key in ("fn", "fx", "fy")
|
||||
}
|
||||
doc = build(samples, identity={"sensor_type": "box", "sn": "FP-3"})
|
||||
legacy = json.loads(json.dumps(doc.to_dict()))
|
||||
legacy["meta"].pop("filters", None)
|
||||
legacy["meta"].update(
|
||||
{"flow_ema_alpha": 0.3, "force_median_window": 3, "force_lowpass_cutoff_hz": 10.0}
|
||||
)
|
||||
path = tmp_path / "legacy.json"
|
||||
path.write_text(json.dumps(legacy), encoding="utf-8")
|
||||
loaded = load(path)
|
||||
assert loaded.pipeline["filters"]["measurement"] == {"flow_ema_alpha": 0.3}
|
||||
assert loaded.pipeline["filters"]["readout"]["force_median_window"] == 3
|
||||
assert loaded.pipeline["filters"]["readout"]["force_lowpass_cutoff_hz"] == 10.0
|
||||
|
||||
|
||||
def test_the_effective_state_beats_the_config_file(tmp_path: Path):
|
||||
"""A file states an intent; the device states what it is doing."""
|
||||
bundle_json = _box_cal_path().parent / "sensor.json"
|
||||
from_file = pipeline_from_sensor_json(bundle_json)
|
||||
assert from_file["filters"]["measurement"] == {"flow_ema_alpha": 1.0}
|
||||
assert "flow_ema_alpha" not in from_file, "loose top-level filter keys are gone"
|
||||
|
||||
sensor = _sensor_with_filters(tmp_path, {"preset": "balanced"})
|
||||
try:
|
||||
from_state = pipeline_from_sensor_json(
|
||||
bundle_json, filters_state=sensor.filters_state()
|
||||
)
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
assert from_state["filters"]["measurement"] == {"flow_ema_alpha": 0.5}
|
||||
assert from_state["filters"]["readout"]["force_median_window"] == 3
|
||||
assert from_state["filters"]["source"] == "sensor.filters_state"
|
||||
assert from_state["config_ref"] == str(bundle_json)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preset, alpha, median, cutoff",
|
||||
[
|
||||
("off", 1.0, 1, 0.0),
|
||||
("low_latency", 1.0, 3, 0.0),
|
||||
("balanced", 0.5, 3, 0.0),
|
||||
("low_noise", 0.3, 3, 10.0),
|
||||
],
|
||||
)
|
||||
def test_every_preset_reports_what_it_does(
|
||||
tmp_path: Path, preset: str, alpha: float, median: int, cutoff: float
|
||||
):
|
||||
sensor = _sensor_with_filters(tmp_path, {"preset": preset})
|
||||
try:
|
||||
fp = filters_fingerprint(sensor.filters_state())
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
assert fp["measurement"]["flow_ema_alpha"] == pytest.approx(alpha)
|
||||
assert fp["readout"]["force_median_window"] == median
|
||||
assert fp["readout"]["force_lowpass_cutoff_hz"] == pytest.approx(cutoff)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""orisys.util.draw_arrows: mask handling and the out-of-frame tip regression."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys.util import _prepare_draw_mask, draw_arrows
|
||||
|
||||
|
||||
def flow_of(shape, dx, dy):
|
||||
flow = np.zeros((*shape, 2), dtype=np.float32)
|
||||
flow[..., 0] = dx / 2.0 # draw_arrows doubles the stored value
|
||||
flow[..., 1] = dy / 2.0
|
||||
return flow
|
||||
|
||||
|
||||
def background(frame):
|
||||
return cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
|
||||
def test_out_of_frame_arrow_tip_is_dropped_not_raised():
|
||||
"""Regression: mask[end_y, end_x] was indexed before the bounds check.
|
||||
|
||||
A 40 px grid with the default edge_margin of 15 lets an arrow tip leave the
|
||||
frame, which used to raise IndexError as soon as a valid_mask was supplied.
|
||||
"""
|
||||
frame = np.full((48, 48), 100, dtype=np.uint8)
|
||||
flow = flow_of((48, 48), dx=40.0, dy=0.0)
|
||||
mask = np.ones((48, 48), dtype=bool)
|
||||
|
||||
out = draw_arrows(frame, flow, grid_spacing=40, margin=0.8, valid_mask=mask)
|
||||
|
||||
assert out.shape == (48, 48, 3)
|
||||
# The only grid point (20, 20) has a tip at x=58 in a 48 px frame, so the
|
||||
# bounds check must have discarded it: nothing was drawn.
|
||||
assert np.array_equal(out, background(frame))
|
||||
|
||||
|
||||
def test_out_of_frame_tip_matches_render_without_mask():
|
||||
"""Clamping the mask lookup must not change which points are valid."""
|
||||
frame = np.full((48, 48), 100, dtype=np.uint8)
|
||||
flow = flow_of((48, 48), dx=40.0, dy=0.0)
|
||||
mask = np.ones((48, 48), dtype=bool)
|
||||
|
||||
with_mask = draw_arrows(frame, flow, grid_spacing=40, valid_mask=mask)
|
||||
without = draw_arrows(frame, flow, grid_spacing=40)
|
||||
assert np.array_equal(with_mask, without)
|
||||
|
||||
|
||||
def test_in_frame_arrows_are_drawn_through_an_all_true_mask():
|
||||
frame = np.full((96, 96), 90, dtype=np.uint8)
|
||||
flow = flow_of((96, 96), dx=6.0, dy=3.0)
|
||||
mask = np.ones((96, 96), dtype=bool)
|
||||
|
||||
out = draw_arrows(frame, flow, grid_spacing=12, arrow_scale=1.0,
|
||||
threshold=0.1, valid_mask=mask)
|
||||
assert not np.array_equal(out, background(frame))
|
||||
|
||||
|
||||
def test_all_false_mask_draws_nothing():
|
||||
frame = np.full((96, 96), 90, dtype=np.uint8)
|
||||
flow = flow_of((96, 96), dx=6.0, dy=3.0)
|
||||
mask = np.zeros((96, 96), dtype=bool)
|
||||
|
||||
out = draw_arrows(frame, flow, grid_spacing=12, arrow_scale=1.0,
|
||||
threshold=0.1, valid_mask=mask)
|
||||
assert np.array_equal(out, background(frame))
|
||||
|
||||
|
||||
def test_mask_can_exclude_one_side():
|
||||
"""Endpoints outside the mask are skipped even when inside the frame."""
|
||||
frame = np.full((96, 96), 90, dtype=np.uint8)
|
||||
flow = flow_of((96, 96), dx=8.0, dy=0.0)
|
||||
mask = np.zeros((96, 96), dtype=bool)
|
||||
mask[24:48, 24:72] = True # only the upper middle band
|
||||
|
||||
out = draw_arrows(frame, flow, grid_spacing=12, arrow_scale=1.0,
|
||||
threshold=0.1, valid_mask=mask)
|
||||
assert not np.array_equal(out, background(frame))
|
||||
# Rows below the band stay untouched.
|
||||
assert np.array_equal(out[80:, :, :], background(frame)[80:, :, :])
|
||||
|
||||
|
||||
def test_below_threshold_points_get_the_dot_colour():
|
||||
frame = np.full((96, 96), 90, dtype=np.uint8)
|
||||
flow = flow_of((96, 96), dx=0.05, dy=0.05)
|
||||
out = draw_arrows(frame, flow, grid_spacing=12, arrow_scale=1.0,
|
||||
threshold=1.0, below_threshold_color=(0, 0, 255))
|
||||
# OpenCV colours are BGR, so (0, 0, 255) is red.
|
||||
red = (out[:, :, 2] > 150) & (out[:, :, 0] < 100) & (out[:, :, 1] < 100)
|
||||
assert red.sum() > 0
|
||||
|
||||
|
||||
def test_prepare_draw_mask_resizes_to_frame():
|
||||
mask = np.zeros((10, 20), dtype=bool)
|
||||
mask[:, :10] = True
|
||||
out = _prepare_draw_mask(mask, (40, 40))
|
||||
assert out.shape == (40, 40)
|
||||
assert out[:, :20].all()
|
||||
assert not out[:, 20:].any()
|
||||
|
||||
|
||||
def test_prepare_draw_mask_passes_none_through():
|
||||
assert _prepare_draw_mask(None, (10, 10)) is None
|
||||
|
||||
|
||||
def test_non_square_grid_does_not_raise_with_mask():
|
||||
"""Height and width differ, so rr/cc broadcasting must stay (N, N)."""
|
||||
frame = np.full((60, 120), 80, dtype=np.uint8)
|
||||
flow = flow_of((60, 120), dx=5.0, dy=5.0)
|
||||
out = draw_arrows(frame, flow, grid_spacing=10, arrow_scale=1.0,
|
||||
threshold=0.1, valid_mask=np.ones((60, 120), dtype=bool))
|
||||
assert out.shape == (60, 120, 3)
|
||||
assert not np.array_equal(out, background(frame))
|
||||
@@ -0,0 +1,281 @@
|
||||
"""StreamRecorder / mosaic_videos: off-hot-path arrow overlay export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys import FrameSnapshot, StreamRecorder, mosaic_videos
|
||||
|
||||
|
||||
_DEFAULT = object()
|
||||
|
||||
|
||||
def make_snapshot(seq: int = 0, *, size: int = 64, image=_DEFAULT, flow=_DEFAULT) -> FrameSnapshot:
|
||||
if image is _DEFAULT:
|
||||
image = np.full((size, size), 128, dtype=np.uint8)
|
||||
image[8:-8, 8:-8] = 200
|
||||
if flow is _DEFAULT:
|
||||
flow = np.zeros((size, size, 2), dtype=np.float32)
|
||||
flow[..., 0] = 2.0
|
||||
flow[..., 1] = 1.0
|
||||
return FrameSnapshot(
|
||||
seq=seq,
|
||||
timestamp=time.time(),
|
||||
age_ms=5.0,
|
||||
fps=100.0,
|
||||
unit="raw",
|
||||
normal_force=1.5,
|
||||
shear_x=0.25,
|
||||
shear_y=-0.5,
|
||||
normal_force_raw=1.5,
|
||||
shear_x_raw=0.25,
|
||||
shear_y_raw=-0.5,
|
||||
torque=0.01,
|
||||
image=image,
|
||||
flow=flow,
|
||||
)
|
||||
|
||||
|
||||
def read_video(path):
|
||||
cap = cv2.VideoCapture(path)
|
||||
try:
|
||||
assert cap.isOpened(), f"cannot reopen {path}"
|
||||
frames = []
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
frames.append(frame)
|
||||
return frames, (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
||||
int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
|
||||
def _diff_count(frame, expected, tol=20):
|
||||
"""Pixels differing from ``expected`` by more than the codec's noise floor."""
|
||||
d = np.abs(frame.astype(np.int16) - expected.astype(np.int16)).max(axis=2)
|
||||
return int((d > tol).sum())
|
||||
|
||||
|
||||
def _near(pixel, value, tol=14):
|
||||
"""Codec round-trip shifts solid greys by a few levels (YUV range)."""
|
||||
return all(abs(int(c) - value) <= tol for c in np.ravel(pixel))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- recorder
|
||||
def test_recorder_writes_every_frame(tmp_path):
|
||||
path = str(tmp_path / "s0.mp4")
|
||||
# Depth >= frame count so the drop-oldest policy provably does not engage;
|
||||
# exactness of the writer itself is what this test is about.
|
||||
rec = StreamRecorder(path, fps=25.0, grid_spacing=8, overlay_values=False,
|
||||
queue_depth=64)
|
||||
for i in range(12):
|
||||
rec.on_frame(make_snapshot(i))
|
||||
stats = rec.close()
|
||||
|
||||
assert stats["seen"] == 12
|
||||
assert stats["written"] == 12
|
||||
assert stats["dropped"] == 0
|
||||
assert stats["skipped"] == 0
|
||||
assert "error" not in stats
|
||||
assert stats["thread_alive"] is False
|
||||
|
||||
frames, size = read_video(path)
|
||||
assert len(frames) == 12
|
||||
assert size == (64, 64)
|
||||
|
||||
|
||||
def test_recorder_every_skips_frames(tmp_path):
|
||||
path = str(tmp_path / "s0.mp4")
|
||||
rec = StreamRecorder(path, every=3, grid_spacing=8, overlay_values=False,
|
||||
queue_depth=64)
|
||||
for i in range(12):
|
||||
rec.on_frame(make_snapshot(i))
|
||||
stats = rec.close()
|
||||
|
||||
assert stats["seen"] == 12
|
||||
assert stats["written"] == 4
|
||||
assert stats["skipped"] == 8
|
||||
frames, _ = read_video(path)
|
||||
assert len(frames) == 4
|
||||
|
||||
|
||||
def test_on_frame_never_blocks_and_drops_oldest(tmp_path, monkeypatch):
|
||||
"""A slow writer must cost frames, never pipeline throughput."""
|
||||
path = str(tmp_path / "s0.mp4")
|
||||
rec = StreamRecorder(path, queue_depth=2, grid_spacing=8, overlay_values=False)
|
||||
|
||||
original = rec._render
|
||||
|
||||
def slow_render(snap):
|
||||
time.sleep(0.05)
|
||||
return original(snap)
|
||||
|
||||
rec._render = slow_render
|
||||
|
||||
t0 = time.perf_counter()
|
||||
for i in range(20):
|
||||
rec.on_frame(make_snapshot(i))
|
||||
ingest = time.perf_counter() - t0
|
||||
stats = rec.close(timeout=30.0)
|
||||
|
||||
# Ingesting 20 frames must not wait on ~1s of encoder work.
|
||||
assert ingest < 0.1, f"on_frame blocked for {ingest * 1000:.1f} ms"
|
||||
assert stats["dropped"] > 0
|
||||
assert stats["written"] + stats["dropped"] + stats["skipped"] == 20
|
||||
assert stats["written"] < 20
|
||||
|
||||
|
||||
def test_recorder_reports_missing_arrays(tmp_path):
|
||||
path = str(tmp_path / "s0.mp4")
|
||||
rec = StreamRecorder(path, grid_spacing=8, overlay_values=False)
|
||||
rec.on_frame(make_snapshot(0, image=None))
|
||||
stats = rec.close()
|
||||
|
||||
assert stats["written"] == 0
|
||||
assert "error" in stats
|
||||
assert 'include=("image", "flow")' in stats["error"]
|
||||
|
||||
|
||||
def test_recorder_context_manager(tmp_path):
|
||||
path = str(tmp_path / "s0.mp4")
|
||||
with StreamRecorder(path, grid_spacing=8, overlay_values=False) as rec:
|
||||
rec.on_frame(make_snapshot(0))
|
||||
assert os.path.isfile(path)
|
||||
assert rec._writer is None
|
||||
|
||||
|
||||
def test_valid_mask_callable_resolved_per_frame(tmp_path):
|
||||
calls = []
|
||||
|
||||
def mask():
|
||||
calls.append(1)
|
||||
return None
|
||||
|
||||
path = str(tmp_path / "s0.mp4")
|
||||
rec = StreamRecorder(path, grid_spacing=8, overlay_values=False, valid_mask=mask)
|
||||
for i in range(5):
|
||||
rec.on_frame(make_snapshot(i))
|
||||
stats = rec.close()
|
||||
|
||||
assert stats["written"] == 5
|
||||
assert len(calls) == 5
|
||||
|
||||
|
||||
def test_all_false_mask_suppresses_arrows(tmp_path):
|
||||
"""With every grid point masked out the overlay is just the grey frame."""
|
||||
path = str(tmp_path / "s0.mp4")
|
||||
snap = make_snapshot(0)
|
||||
rec = StreamRecorder(path, grid_spacing=8, overlay_values=False,
|
||||
valid_mask=np.zeros((64, 64), dtype=bool))
|
||||
rec.on_frame(snap)
|
||||
rec.close()
|
||||
|
||||
frames, _ = read_video(path)
|
||||
expected = cv2.cvtColor(snap.image, cv2.COLOR_GRAY2BGR)
|
||||
assert _diff_count(frames[0], expected) == 0
|
||||
|
||||
|
||||
def test_arrows_change_pixels_without_mask(tmp_path):
|
||||
path = str(tmp_path / "s0.mp4")
|
||||
snap = make_snapshot(0)
|
||||
rec = StreamRecorder(path, grid_spacing=8, overlay_values=False)
|
||||
rec.on_frame(snap)
|
||||
rec.close()
|
||||
|
||||
frames, _ = read_video(path)
|
||||
expected = cv2.cvtColor(snap.image, cv2.COLOR_GRAY2BGR)
|
||||
assert _diff_count(frames[0], expected) > 100
|
||||
|
||||
|
||||
def test_close_without_frames_is_safe(tmp_path):
|
||||
rec = StreamRecorder(str(tmp_path / "empty.mp4"), grid_spacing=8)
|
||||
stats = rec.close()
|
||||
assert stats["written"] == 0
|
||||
assert "error" not in stats
|
||||
|
||||
|
||||
def test_stats_report_requested_geometry(tmp_path):
|
||||
rec = StreamRecorder(str(tmp_path / "s0.mp4"), fps=12.0, fourcc="mp4v")
|
||||
assert rec.stats()["fps"] == 12.0
|
||||
assert rec.stats()["fourcc"] == "mp4v"
|
||||
assert rec.stats()["path"].endswith("s0.mp4")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- mosaic
|
||||
def _write_solid(path, value, frames=4, size=(16, 12), fps=10.0):
|
||||
w, h = size
|
||||
writer = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h), True)
|
||||
assert writer.isOpened()
|
||||
try:
|
||||
for _ in range(frames):
|
||||
writer.write(np.full((h, w, 3), value, dtype=np.uint8))
|
||||
finally:
|
||||
writer.release()
|
||||
return path
|
||||
|
||||
|
||||
def test_mosaic_grid_geometry(tmp_path):
|
||||
srcs = [_write_solid(str(tmp_path / f"s{i}.mp4"), 40 + 40 * i) for i in range(3)]
|
||||
out = str(tmp_path / "mosaic.mp4")
|
||||
info = mosaic_videos(srcs, out, cols=2, gap=4, fps=10.0)
|
||||
|
||||
assert info["grid"] == (2, 2)
|
||||
# 2 columns x 16 + 1 gap, 2 rows x 12 + 1 gap
|
||||
assert info["size"] == (2 * 16 + 4, 2 * 12 + 4)
|
||||
assert info["frames"] == 4
|
||||
frames, size = read_video(out)
|
||||
assert len(frames) == 4
|
||||
assert size == info["size"]
|
||||
# the empty bottom-right cell stays black
|
||||
assert _near(frames[0][-1, -1], 0, tol=12)
|
||||
# top-left tile carries stream 0's colour
|
||||
assert _near(frames[0][0, 0], 40)
|
||||
|
||||
|
||||
def test_mosaic_holds_last_frame_on_short_input(tmp_path):
|
||||
a = _write_solid(str(tmp_path / "a.mp4"), 200, frames=5)
|
||||
b = _write_solid(str(tmp_path / "b.mp4"), 50, frames=3)
|
||||
out = str(tmp_path / "mosaic.mp4")
|
||||
info = mosaic_videos([a, b], out, cols=2, gap=0, fps=10.0)
|
||||
|
||||
assert info["frames"] == 5
|
||||
frames, size = read_video(out)
|
||||
assert len(frames) == 5
|
||||
# b's 4th frame is a repeat of its 3rd, so the right half is still solid 50
|
||||
assert _near(frames[-1][0, size[0] // 2], 50)
|
||||
|
||||
|
||||
def test_mosaic_labels_drawn(tmp_path):
|
||||
srcs = [_write_solid(str(tmp_path / f"s{i}.mp4"), 30) for i in range(2)]
|
||||
out = str(tmp_path / "mosaic.mp4")
|
||||
plain = str(tmp_path / "plain.mp4")
|
||||
mosaic_videos(srcs, plain, cols=2, gap=0, fps=10.0)
|
||||
mosaic_videos(srcs, out, cols=2, gap=0, fps=10.0, labels=["#0", "#1"])
|
||||
|
||||
with_label, _ = read_video(out)
|
||||
without, _ = read_video(plain)
|
||||
assert _diff_count(with_label[0], without[0]) > 0
|
||||
|
||||
|
||||
def test_mosaic_scale_downscales(tmp_path):
|
||||
srcs = [_write_solid(str(tmp_path / f"s{i}.mp4"), 30, size=(16, 12)) for i in range(4)]
|
||||
out = str(tmp_path / "mosaic.mp4")
|
||||
info = mosaic_videos(srcs, out, cols=2, gap=2, scale=0.5, fps=10.0)
|
||||
assert info["size"] == (8 * 2 + 2, 6 * 2 + 2)
|
||||
|
||||
|
||||
def test_mosaic_rejects_missing_input(tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
mosaic_videos([str(tmp_path / "nope.mp4")], str(tmp_path / "out.mp4"))
|
||||
|
||||
|
||||
def test_mosaic_rejects_empty_input(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
mosaic_videos([], str(tmp_path / "out.mp4"))
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for sensor-bundle export helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys.configs import (
|
||||
export_sensor_bundle,
|
||||
export_sensor_bundles,
|
||||
list_sensor_bundles,
|
||||
resolve_sensor_bundle,
|
||||
)
|
||||
|
||||
|
||||
def test_export_sensor_bundle_json_only(tmp_path: Path) -> None:
|
||||
assert resolve_sensor_bundle("box") is not None
|
||||
|
||||
dest = export_sensor_bundle("box", tmp_path, include_assets=False)
|
||||
assert dest == tmp_path / "box"
|
||||
assert (dest / "sensor.json").is_file()
|
||||
assert (dest / "force_calibration.json").is_file()
|
||||
assert not (dest / "assets").exists()
|
||||
|
||||
|
||||
def test_export_sensor_bundles_default_names(tmp_path: Path) -> None:
|
||||
available = list_sensor_bundles()
|
||||
assert "box" in available
|
||||
|
||||
exported = export_sensor_bundles(tmp_path, include_assets=False)
|
||||
names = {path.name for path in exported}
|
||||
assert "box" in names
|
||||
assert "mini" in names
|
||||
for path in exported:
|
||||
assert (path / "sensor.json").is_file()
|
||||
assert not (path / "assets").exists()
|
||||
|
||||
|
||||
def test_export_sensor_bundle_no_overwrite(tmp_path: Path) -> None:
|
||||
export_sensor_bundle("box", tmp_path, overwrite=True)
|
||||
with pytest.raises(FileExistsError):
|
||||
export_sensor_bundle("box", tmp_path, overwrite=False)
|
||||
|
||||
|
||||
def test_export_sensor_bundle_missing_name(tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
export_sensor_bundle("not-a-real-sensor", tmp_path)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""The shipped filter presets and the example configs that document them.
|
||||
|
||||
``json.load`` cannot carry comments, so the answer to "which filter should I
|
||||
use?" lives in three places that must not drift apart: ``FILTER_PRESETS`` in the
|
||||
sensor, the table in ``docs/filters.md``, and the copy-paste files in
|
||||
``examples/filters/``. The presets are the source of truth; these tests fail
|
||||
when a copy stops matching them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys import Sensor
|
||||
from orisys.calibration import filters_fingerprint
|
||||
from orisys.sensor import FILTER_PRESETS, available_filter_presets
|
||||
from orisys import configs as orisys_configs
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXAMPLES = REPO_ROOT / "examples" / "filters"
|
||||
BUNDLES = ("box", "mini", "finger")
|
||||
|
||||
#: What every shipped bundle says: filtering is opt-in, so a fresh install
|
||||
#: records the raw reading and the operator turns a stage on deliberately.
|
||||
ALL_OFF = {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto",
|
||||
}
|
||||
|
||||
|
||||
def test_the_preset_names_are_the_documented_ones():
|
||||
assert sorted(available_filter_presets()) == [
|
||||
"balanced",
|
||||
"low_latency",
|
||||
"low_noise",
|
||||
"off",
|
||||
]
|
||||
# ``available_filter_presets`` hands out copies: a UI cannot edit the truth.
|
||||
snapshot = available_filter_presets()
|
||||
snapshot["off"]["flow_ema_alpha"] = 0.0
|
||||
assert FILTER_PRESETS["off"]["flow_ema_alpha"] == 1.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preset", sorted(FILTER_PRESETS))
|
||||
def test_every_example_file_still_matches_its_preset(preset: str):
|
||||
path = EXAMPLES / f"filters_{preset}.json"
|
||||
assert path.is_file(), f"missing example for preset {preset!r}: {path}"
|
||||
block = json.loads(path.read_text(encoding="utf-8"))
|
||||
# A whole ``filters`` block: the preset's own keys on top of the defaults
|
||||
# every config needs, and nothing else.
|
||||
expected = dict(ALL_OFF)
|
||||
expected.update(FILTER_PRESETS[preset])
|
||||
assert block == expected, f"{path.name} drifted from preset {preset!r}"
|
||||
|
||||
|
||||
def test_every_example_file_is_a_real_filters_block():
|
||||
"""A file that cannot be dropped into a config is documentation that lies."""
|
||||
for preset in FILTER_PRESETS:
|
||||
block = json.loads((EXAMPLES / f"filters_{preset}.json").read_text(encoding="utf-8"))
|
||||
assert isinstance(block, dict) and block, preset
|
||||
for key in ("flow_ema_alpha", "force_median_window", "force_lowpass_cutoff_hz"):
|
||||
assert key in block, f"{preset}: {key} missing"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bundle", BUNDLES)
|
||||
def test_shipped_bundles_ship_with_filtering_off(bundle: str):
|
||||
"""Nothing filters unless it was asked for."""
|
||||
path = orisys_configs.resolve_sensor_config_path(bundle)
|
||||
assert path is not None and path.is_file(), f"missing bundle: {bundle}"
|
||||
cfg = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert cfg.get("filters") == ALL_OFF, f"{bundle} no longer ships with filtering off"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bundle", BUNDLES)
|
||||
def test_shipped_bundles_report_all_off(bundle: str):
|
||||
"""The file and the running device have to agree about the shipped default."""
|
||||
sensor = Sensor("external", config_name=bundle, verbose=False, backend="cpu")
|
||||
try:
|
||||
fp = filters_fingerprint(sensor.filters_state())
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
assert fp["measurement"] == {"flow_ema_alpha": 1.0}
|
||||
assert fp["readout"]["force_median_window"] == 1
|
||||
assert fp["readout"]["force_lowpass_cutoff_hz"] == 0.0
|
||||
|
||||
|
||||
def test_a_config_with_no_filters_block_filters_nothing(tmp_path: Path):
|
||||
"""Absence is off, not "the values we thought were sensible".
|
||||
|
||||
A key that is missing has to mean the stage is not running: otherwise a
|
||||
device filters a measurement nobody asked for, and nothing in the file says
|
||||
so. Callers with a hand-written config (an old customer file, a bench
|
||||
script) rely on this.
|
||||
"""
|
||||
copy = tmp_path / "box"
|
||||
shutil.copytree(REPO_ROOT / "sensors" / "box", copy)
|
||||
path = copy / "sensor.json"
|
||||
cfg = json.loads(path.read_text(encoding="utf-8"))
|
||||
cfg.pop("filters", None)
|
||||
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||
|
||||
sensor = Sensor("external", config_name=str(path), verbose=False, backend="cpu")
|
||||
try:
|
||||
state = sensor.filters_state()
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
assert state["measurement"]["flow_ema_alpha"] == 1.0
|
||||
assert state["readout"]["force_median_window"] == 1
|
||||
assert state["readout"]["force_lowpass_cutoff_hz"] == 0.0
|
||||
assert state["any_stage_applied"] is False
|
||||
@@ -0,0 +1,142 @@
|
||||
"""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_import_precedence_is_correct():
|
||||
"""examples/ must beat build/ for `gui`; build/ must beat site-packages.
|
||||
|
||||
Regression guard: build/gui is a package named `gui`, so if build/ precedes
|
||||
examples/ on sys.path then `import gui.viewer2d` resolves to the build
|
||||
tooling and tests/test_qt_viewer_backend.py fails at collection.
|
||||
"""
|
||||
import common
|
||||
import gui
|
||||
import wheel
|
||||
|
||||
examples_gui = (REPO_ROOT / "examples" / "gui").resolve()
|
||||
build_root = (REPO_ROOT / "build").resolve()
|
||||
|
||||
assert Path(gui.__file__).resolve().parent == examples_gui, (
|
||||
f"`gui` resolved to {gui.__file__} instead of {examples_gui}; "
|
||||
"examples/ must precede build/ on sys.path"
|
||||
)
|
||||
for module in (common, wheel):
|
||||
resolved = Path(module.__file__).resolve()
|
||||
assert build_root in resolved.parents, (
|
||||
f"`{module.__name__}` resolved to {resolved}, outside {build_root}; "
|
||||
"build/ must precede site-packages on sys.path"
|
||||
)
|
||||
|
||||
|
||||
def test_tests_run_against_checkout_not_stale_install():
|
||||
"""Fail loudly when an old orisys in site-packages shadows src/orisys."""
|
||||
src_pkg = (REPO_ROOT / "src" / "orisys").resolve()
|
||||
if not src_pkg.is_dir():
|
||||
pytest.skip("src/orisys is not present in this checkout layout")
|
||||
|
||||
import orisys
|
||||
|
||||
module_file = getattr(orisys, "__file__", None)
|
||||
assert module_file, (
|
||||
"import orisys resolved to a namespace package (__file__ is None) "
|
||||
f"instead of {src_pkg}. A stale orisys in site-packages is shadowing "
|
||||
"the checkout; run `pip uninstall -y orisys` in this environment."
|
||||
)
|
||||
|
||||
resolved_parent = Path(module_file).resolve().parent
|
||||
assert resolved_parent == src_pkg, (
|
||||
f"import orisys resolved to {resolved_parent} instead of {src_pkg}. "
|
||||
"A stale orisys in site-packages is shadowing the checkout; run "
|
||||
"`pip uninstall -y orisys` in this environment."
|
||||
)
|
||||
@@ -70,17 +70,22 @@ def test_sensor_detect_motion_ignores_invalid_region():
|
||||
backend="cpu",
|
||||
)
|
||||
try:
|
||||
# get_img() resizes the frame to the config's camera.resolution_stitch
|
||||
# and stores valid_mask at that same resolution, so the flow buffer must
|
||||
# be sized from sensor.valid_mask rather than from the raw frame.
|
||||
frame = np.zeros((728, 672, 3), dtype=np.uint8)
|
||||
mask = np.zeros((728, 672), dtype=bool)
|
||||
mask[180:540, 160:512] = True
|
||||
sensor.get_img(frame=frame, already_processed=True, valid_mask=mask)
|
||||
|
||||
sensor.flow = np.zeros((728, 672, 2), dtype=np.float32)
|
||||
sensor.flow[~mask] = 10.0
|
||||
flow_shape = sensor.valid_mask.shape
|
||||
assert sensor.valid_mask.any() and not sensor.valid_mask.all()
|
||||
sensor.flow = np.zeros((*flow_shape, 2), dtype=np.float32)
|
||||
sensor.flow[~sensor.valid_mask] = 10.0
|
||||
assert not sensor.detect_motion(threshold=1.0)
|
||||
|
||||
sensor.flow[:] = 0.0
|
||||
sensor.flow[mask] = 10.0
|
||||
sensor.flow[sensor.valid_mask] = 10.0
|
||||
assert sensor.detect_motion(threshold=1.0)
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Every third-party import in src/orisys must be excluded from the Nuitka compile.
|
||||
|
||||
Omitting one means Nuitka tries to follow it into the compiled extension: compile
|
||||
time explodes, and the wheel's contents start depending on whatever happens to be
|
||||
installed in the *build* environment rather than on what is declared. ``torch``,
|
||||
``torchvision`` and ``pyvkfft`` were imported by the package but missing from the
|
||||
exclusion list; this test keeps that list complete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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.compile import NUITKA_INCLUDE_PACKAGES, NUITKA_NOFOLLOW_IMPORTS # noqa: E402
|
||||
|
||||
PACKAGE_ROOT = REPO_ROOT / "src" / "orisys"
|
||||
FIRST_PARTY = {"orisys", "orisys_blend"}
|
||||
|
||||
|
||||
def _third_party_imports() -> dict[str, set[str]]:
|
||||
"""Top-level third-party import names -> the files that import them."""
|
||||
stdlib = set(sys.stdlib_module_names)
|
||||
found: dict[str, set[str]] = {}
|
||||
|
||||
for path in sorted(PACKAGE_ROOT.rglob("*.py")):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
names: list[str] = []
|
||||
if isinstance(node, ast.Import):
|
||||
names = [alias.name for alias in node.names]
|
||||
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||
names = [node.module]
|
||||
for name in names:
|
||||
top = name.split(".")[0]
|
||||
if top in stdlib or top in FIRST_PARTY:
|
||||
continue
|
||||
found.setdefault(top, set()).add(path.relative_to(REPO_ROOT).as_posix())
|
||||
return found
|
||||
|
||||
|
||||
def test_every_third_party_import_is_nofollowed():
|
||||
found = _third_party_imports()
|
||||
assert found, "expected to find third-party imports in src/orisys"
|
||||
|
||||
missing = sorted(set(found) - set(NUITKA_NOFOLLOW_IMPORTS))
|
||||
assert not missing, (
|
||||
"these third-party modules are imported by src/orisys but missing from "
|
||||
"build/wheel/compile.py::NUITKA_NOFOLLOW_IMPORTS:\n "
|
||||
+ "\n ".join(f"{name} (imported by {sorted(found[name])})" for name in missing)
|
||||
+ "\nNuitka will try to follow them into the compiled module; add them to "
|
||||
"the exclusion list so they resolve from the user's environment at runtime."
|
||||
)
|
||||
|
||||
|
||||
def test_included_packages_are_first_party_or_subpackages():
|
||||
assert "orisys" in NUITKA_INCLUDE_PACKAGES
|
||||
for name in NUITKA_INCLUDE_PACKAGES:
|
||||
assert name == "orisys" or name.startswith("orisys."), name
|
||||
|
||||
|
||||
def test_nofollow_list_has_no_duplicates():
|
||||
assert len(NUITKA_NOFOLLOW_IMPORTS) == len(set(NUITKA_NOFOLLOW_IMPORTS))
|
||||
@@ -0,0 +1,329 @@
|
||||
"""StreamPipeline: staging, snapshot immutability, error propagation, shutdown."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys import FrameSnapshot, StreamPipeline
|
||||
|
||||
|
||||
class FakeSensor:
|
||||
"""Minimal Sensor stand-in with the same mutable-buffer behaviour.
|
||||
|
||||
It deliberately reuses one image buffer (like ``Sensor._img_gray_buf``) so the
|
||||
pipeline's private-copy contract is actually exercised.
|
||||
"""
|
||||
|
||||
class info:
|
||||
VRAW = "vraw"
|
||||
VNORMAL = "vnormal"
|
||||
VSHEAR = "vshear"
|
||||
DEPTH = "depth"
|
||||
CENTROID = "centroid"
|
||||
CONTOUR = "contour"
|
||||
|
||||
def __init__(self, *, fail_at: int | None = None, flow_delay: float = 0.0,
|
||||
hhd_delay: float = 0.0):
|
||||
self.img = None
|
||||
self.flow = None
|
||||
self.vnormal = None
|
||||
self.vshear = None
|
||||
self.depth_map = None
|
||||
self.centroid = (0, 0)
|
||||
self.contour = np.empty(0)
|
||||
self.ftor = 0.0
|
||||
self._force_raw = (0.0, 0.0, 0.0)
|
||||
self.fnormal = self.fshearx = self.fsheary = 0.0
|
||||
self.force_unit = "raw"
|
||||
self._img_buf = np.zeros((8, 8), dtype=np.uint8)
|
||||
self._flow_buf = np.zeros((8, 8, 2), dtype=np.float32)
|
||||
self._flow_delay = flow_delay
|
||||
self._hhd_delay = hhd_delay
|
||||
self._fail_at = fail_at
|
||||
self._flow_calls = 0
|
||||
self._seen_images: list[np.ndarray] = []
|
||||
self._seen_flow_values: list[float] = []
|
||||
self.contact_calls = 0
|
||||
|
||||
def make_frame(self, seq: int) -> np.ndarray:
|
||||
# writes INTO the shared buffer, exactly like cv2.resize(dst=...)
|
||||
self._img_buf.fill(seq % 251)
|
||||
self.img = self._img_buf
|
||||
return self._img_buf
|
||||
|
||||
def compute_flow(self, img=None, **kwargs) -> None:
|
||||
image = self.img if img is None else img
|
||||
self._flow_calls += 1
|
||||
if self._fail_at is not None and self._flow_calls == self._fail_at:
|
||||
raise RuntimeError("flow exploded")
|
||||
self._seen_images.append(np.array(image, copy=True))
|
||||
time.sleep(self._flow_delay)
|
||||
# rewrite the shared buffer, like the real DIS/EMA path does
|
||||
self._flow_buf.fill(float(self._flow_calls))
|
||||
self.flow = self._flow_buf
|
||||
|
||||
def compute_hhd(self, flow=None, **kwargs) -> None:
|
||||
# Mirrors Sensor.compute_hhd, which derives the forces from the field it
|
||||
# is given - so this only yields frame N's forces if the pipeline passes
|
||||
# frame N's flow explicitly.
|
||||
time.sleep(self._hhd_delay)
|
||||
field = self.flow if flow is None else flow
|
||||
value = (float(field[0, 0, 0])
|
||||
if isinstance(field, np.ndarray) and field.size else 0.0)
|
||||
self._seen_flow_values.append(value)
|
||||
self.fnormal = value * 10.0
|
||||
self.fshearx = value
|
||||
self.fsheary = -value
|
||||
self._force_raw = (self.fnormal, self.fshearx, self.fsheary)
|
||||
self.ftor = value
|
||||
self.vnormal = self._flow_buf
|
||||
self.vshear = self._flow_buf
|
||||
|
||||
def compute_contact(self) -> None:
|
||||
self.contact_calls += 1
|
||||
self.centroid = (1, 2)
|
||||
self.contour = np.zeros((1, 1, 2), dtype=np.int32)
|
||||
self.depth_map = np.zeros((8, 8), dtype=np.float32)
|
||||
|
||||
def read_info(self, *args):
|
||||
source = {
|
||||
self.info.VRAW: lambda: self.flow.copy(),
|
||||
self.info.VNORMAL: lambda: self.vnormal.copy(),
|
||||
self.info.VSHEAR: lambda: self.vshear.copy(),
|
||||
self.info.DEPTH: lambda: self.depth_map.copy(),
|
||||
self.info.CENTROID: lambda: np.array(self.centroid),
|
||||
self.info.CONTOUR: lambda: self.contour.copy(),
|
||||
}
|
||||
return tuple(source[a]() for a in args)
|
||||
|
||||
|
||||
def make_producer(sensor: FakeSensor, count: int):
|
||||
state = {"i": 0}
|
||||
|
||||
def produce():
|
||||
if state["i"] >= count:
|
||||
return None
|
||||
frame = sensor.make_frame(state["i"])
|
||||
state["i"] += 1
|
||||
return frame
|
||||
|
||||
return produce
|
||||
|
||||
|
||||
def run_all(sensor: FakeSensor, count: int, **kwargs):
|
||||
"""Drive the pipeline with an exhaustive consumer: every frame, in order."""
|
||||
frames: list = []
|
||||
kwargs.setdefault("depth", 2)
|
||||
pipe = StreamPipeline(sensor, make_producer(sensor, count),
|
||||
on_frame=frames.append, **kwargs)
|
||||
pipe.start()
|
||||
deadline = time.perf_counter() + 10.0
|
||||
while len(frames) < count and time.perf_counter() < deadline:
|
||||
if pipe.error is not None:
|
||||
break
|
||||
time.sleep(0.001)
|
||||
pipe.stop()
|
||||
return pipe, frames
|
||||
|
||||
|
||||
def test_on_frame_receives_every_frame_in_order():
|
||||
sensor = FakeSensor()
|
||||
pipe, frames = run_all(sensor, 12)
|
||||
assert len(frames) == 12
|
||||
assert [s.seq for s in frames] == list(range(12))
|
||||
assert pipe.stats.produced == 12
|
||||
assert pipe.stats.published == 12
|
||||
|
||||
|
||||
def test_forces_come_from_the_matching_frame():
|
||||
"""Each snapshot must carry the forces computed from its own frame's flow."""
|
||||
sensor = FakeSensor()
|
||||
_, frames = run_all(sensor, 10)
|
||||
# frame i's flow value is (i + 1); the fake derives the force from self.flow,
|
||||
# so a mismatched pairing would show up as a repeated or skipped value.
|
||||
assert sensor._seen_flow_values == [float(i + 1) for i in range(10)]
|
||||
assert [s.normal_force for s in frames] == [10.0 * (i + 1) for i in range(10)]
|
||||
assert [s.shear_x for s in frames] == [float(i + 1) for i in range(10)]
|
||||
|
||||
|
||||
def test_stages_do_not_read_a_rewritten_image_buffer():
|
||||
"""The producer reuses one buffer; no stage may observe a later frame."""
|
||||
sensor = FakeSensor(flow_delay=0.004)
|
||||
run_all(sensor, 8)
|
||||
seen = sensor._seen_images
|
||||
assert len(seen) == 8
|
||||
for i, arr in enumerate(seen):
|
||||
assert np.all(arr == i % 251), f"frame {i} was overwritten before flow ran"
|
||||
|
||||
|
||||
def test_snapshot_arrays_are_private_copies():
|
||||
sensor = FakeSensor()
|
||||
_, frames = run_all(sensor, 4, include=("image", "flow", "vnormal"))
|
||||
first = frames[0]
|
||||
assert isinstance(first, FrameSnapshot)
|
||||
assert first.image is not None and first.flow is not None
|
||||
saved = first.flow.copy()
|
||||
# the SDK keeps rewriting its buffers; the snapshot must not change
|
||||
sensor._flow_buf.fill(999.0)
|
||||
sensor._img_buf.fill(7)
|
||||
assert np.array_equal(first.flow, saved)
|
||||
assert not np.any(first.flow == 999.0)
|
||||
|
||||
|
||||
def test_include_filters_which_arrays_are_copied():
|
||||
sensor = FakeSensor()
|
||||
_, frames = run_all(sensor, 3, include=("vnormal",))
|
||||
assert frames[0].vnormal is not None
|
||||
assert frames[0].image is None and frames[0].flow is None
|
||||
assert frames[0].depth is None and frames[0].contour is None
|
||||
|
||||
|
||||
def test_rejects_unknown_include_field():
|
||||
sensor = FakeSensor()
|
||||
with pytest.raises(ValueError):
|
||||
StreamPipeline(sensor, make_producer(sensor, 1), include=("floww",))
|
||||
|
||||
|
||||
def test_contact_stage_runs_when_requested():
|
||||
sensor = FakeSensor()
|
||||
_, frames = run_all(sensor, 5, contact=True,
|
||||
include=("depth", "centroid", "contour"))
|
||||
assert sensor.contact_calls == 5
|
||||
assert frames[-1].depth is not None
|
||||
assert frames[-1].centroid is not None
|
||||
assert frames[-1].contour is not None
|
||||
|
||||
|
||||
def test_stage_exception_surfaces_on_stop():
|
||||
sensor = FakeSensor(fail_at=3)
|
||||
pipe = StreamPipeline(sensor, make_producer(sensor, 20), depth=2)
|
||||
pipe.start()
|
||||
time.sleep(0.4)
|
||||
with pytest.raises(RuntimeError, match="flow exploded"):
|
||||
pipe.stop()
|
||||
|
||||
|
||||
def test_stage_exception_surfaces_on_wait():
|
||||
sensor = FakeSensor(fail_at=2)
|
||||
pipe = StreamPipeline(sensor, make_producer(sensor, 20), depth=2)
|
||||
pipe.start()
|
||||
with pytest.raises(RuntimeError):
|
||||
for _ in range(50):
|
||||
pipe.wait(timeout=0.1)
|
||||
pipe.stop()
|
||||
|
||||
|
||||
def test_stop_is_idempotent():
|
||||
sensor = FakeSensor()
|
||||
pipe, _ = run_all(sensor, 4)
|
||||
pipe.stop() # second stop must not raise
|
||||
assert not pipe.running
|
||||
assert pipe.latest() is not None
|
||||
|
||||
|
||||
def test_stop_while_running_does_not_hang():
|
||||
"""Stopping mid-stream must unblock the consumer threads.
|
||||
|
||||
The producer stops delivering sentinels once the done flag is set, so a
|
||||
bare blocking Queue.get() in a consumer would wait forever.
|
||||
"""
|
||||
sensor = FakeSensor(flow_delay=0.002)
|
||||
pipe = StreamPipeline(sensor, make_producer(sensor, 100000), depth=2)
|
||||
pipe.start()
|
||||
assert pipe.wait(timeout=2.0) is not None
|
||||
t0 = time.perf_counter()
|
||||
pipe.stop(timeout=5.0)
|
||||
elapsed = time.perf_counter() - t0
|
||||
assert elapsed < 2.0, f"stop() took {elapsed:.2f}s; a stage was still blocked"
|
||||
assert not pipe.running
|
||||
|
||||
|
||||
def test_context_manager_stops_threads():
|
||||
sensor = FakeSensor()
|
||||
frames: list = []
|
||||
with StreamPipeline(sensor, make_producer(sensor, 6),
|
||||
on_frame=frames.append) as pipe:
|
||||
deadline = time.perf_counter() + 5.0
|
||||
while len(frames) < 6 and time.perf_counter() < deadline:
|
||||
time.sleep(0.001)
|
||||
assert len(frames) == 6
|
||||
assert not pipe.running
|
||||
|
||||
|
||||
def test_wait_exposes_a_latest_wins_view():
|
||||
sensor = FakeSensor()
|
||||
pipe = StreamPipeline(sensor, make_producer(sensor, 5))
|
||||
pipe.start()
|
||||
last = None
|
||||
while True:
|
||||
snap = pipe.wait(timeout=2.0)
|
||||
if snap is None:
|
||||
break
|
||||
if last is not None:
|
||||
assert snap.seq > last.seq
|
||||
last = snap
|
||||
# the pipeline has drained; the final snapshot is the last frame
|
||||
assert last is not None and last.normal_force == 50.0
|
||||
assert pipe.latest() is last
|
||||
# nothing new is coming, so a further poll returns immediately
|
||||
assert pipe.wait(timeout=0.2) is None
|
||||
pipe.stop()
|
||||
|
||||
|
||||
def test_wait_timeout_returns_none_without_new_frames():
|
||||
sensor = FakeSensor()
|
||||
pipe = StreamPipeline(sensor, make_producer(sensor, 1))
|
||||
pipe.start()
|
||||
assert pipe.wait(timeout=1.0) is not None
|
||||
t0 = time.perf_counter()
|
||||
assert pipe.wait(timeout=0.1) is None
|
||||
assert time.perf_counter() - t0 < 1.0
|
||||
pipe.stop()
|
||||
|
||||
|
||||
def test_stats_track_stage_times_and_age():
|
||||
sensor = FakeSensor(flow_delay=0.002, hhd_delay=0.001)
|
||||
pipe, _ = run_all(sensor, 15)
|
||||
d = pipe.stats.as_dict()
|
||||
assert d["published"] == 15
|
||||
assert d["flow_ms"] >= 1.5 # the injected delay shows up
|
||||
assert d["hhd_ms"] >= 0.8
|
||||
assert d["age_ms"] > 0.0
|
||||
assert d["fps"] > 0.0
|
||||
assert d["produce_ms"] >= 0.0
|
||||
|
||||
|
||||
def test_depth_does_not_change_results():
|
||||
for depth in (1, 2, 4):
|
||||
sensor = FakeSensor()
|
||||
_, frames = run_all(sensor, 10, depth=depth)
|
||||
assert [s.normal_force for s in frames] == [10.0 * (i + 1) for i in range(10)]
|
||||
|
||||
|
||||
def test_for_finger_runtime_maps_eof_to_none():
|
||||
"""``for_finger_runtime`` drives ``grab_and_process`` and maps EOF to None."""
|
||||
sensor = FakeSensor()
|
||||
state = {"i": 0}
|
||||
|
||||
class FakeRuntime:
|
||||
tactile = type("T", (), {"raw_sensor": sensor})()
|
||||
|
||||
def grab_and_process(self, color=False):
|
||||
if state["i"] >= 3:
|
||||
return False, None
|
||||
frame = sensor.make_frame(state["i"])
|
||||
state["i"] += 1
|
||||
return True, frame
|
||||
|
||||
frames: list = []
|
||||
pipe = StreamPipeline.for_finger_runtime(FakeRuntime(), on_frame=frames.append)
|
||||
pipe.start()
|
||||
deadline = time.perf_counter() + 5.0
|
||||
while len(frames) < 3 and time.perf_counter() < deadline:
|
||||
time.sleep(0.001)
|
||||
pipe.stop()
|
||||
assert len(frames) == 3
|
||||
assert pipe.stats.produced == 3
|
||||
+15
-2
@@ -28,14 +28,25 @@ def test_pyproject_dependencies(packaging_utils):
|
||||
|
||||
|
||||
def test_pyproject_dynamic_version(packaging_utils):
|
||||
"""The dynamic version must resolve to a valid version from one source.
|
||||
|
||||
Deliberately does NOT assert a literal version: pinning it here means every
|
||||
release has to edit this file too. ``src/orisys/__init__.py::__version__`` is
|
||||
the single source of truth, and build/release/version.py checks that the
|
||||
wheels agree with it.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
build_dir = REPO_ROOT / "build"
|
||||
if str(build_dir) not in sys.path:
|
||||
sys.path.insert(0, str(build_dir))
|
||||
from common.pyproject_utils import get_version
|
||||
from release.version import dynamic_version_attr
|
||||
|
||||
assert get_version(REPO_ROOT) == "0.5.0"
|
||||
version = get_version(REPO_ROOT)
|
||||
assert re.fullmatch(r"\d+(?:\.\d+)+(?:[-+][0-9A-Za-z.]+)?", version), version
|
||||
assert dynamic_version_attr(REPO_ROOT) == "orisys.__version__"
|
||||
|
||||
|
||||
def test_pyproject_bundle_manifest(packaging_utils):
|
||||
@@ -48,7 +59,9 @@ def test_pyproject_bundle_manifest(packaging_utils):
|
||||
|
||||
items = bundle_items(REPO_ROOT)
|
||||
names = {item["name"] for item in items}
|
||||
assert names == {"configs", "examples", "docs", "sensors"}
|
||||
# docs/ is intentionally not bundled: it ships with the GitHub repo and the
|
||||
# PyPI project page only, so doc edits do not invalidate built wheels.
|
||||
assert names == {"configs", "examples", "sensors"}
|
||||
|
||||
data_files = dict(bundle_data_files(REPO_ROOT))
|
||||
assert "orisys/configs" in data_files
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Tests for PyPI credential setup (~/.pypirc handling).
|
||||
|
||||
The writer does text surgery to preserve the explanatory comments in the file, so
|
||||
these tests check the result of that surgery rather than trusting it. No test
|
||||
touches the real ``~/.pypirc``: every path is injected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 release import auth # noqa: E402
|
||||
|
||||
TOKEN = "pypi-" + "A" * 30
|
||||
|
||||
COMMENTED_TEMPLATE = """\
|
||||
# keep me: why this file exists
|
||||
# second comment line
|
||||
|
||||
[distutils]
|
||||
index-servers =
|
||||
pypi
|
||||
|
||||
[pypi]
|
||||
repository = https://upload.pypi.org/legacy/
|
||||
username = __token__
|
||||
password = pypi-PASTE-YOUR-PRODUCTION-TOKEN-HERE
|
||||
|
||||
# [testpypi] commented out on purpose
|
||||
# password = pypi-OLD
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_pypirc_reads_sections_and_ignores_comments():
|
||||
sections = auth.parse_pypirc(COMMENTED_TEMPLATE)
|
||||
assert sections["pypi"]["username"] == "__token__"
|
||||
assert "testpypi" not in sections, "commented-out sections must stay inactive"
|
||||
|
||||
|
||||
def test_placeholder_is_detected_as_not_configured(tmp_path):
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text(COMMENTED_TEMPLATE, encoding="utf-8")
|
||||
state = auth.credential_state("pypi", path=path, env={})
|
||||
assert not state.configured
|
||||
assert "placeholder" in state.problem
|
||||
|
||||
|
||||
def test_missing_file_is_reported(tmp_path):
|
||||
state = auth.credential_state("pypi", path=tmp_path / "nope", env={})
|
||||
assert not state.configured
|
||||
assert "does not exist" in state.problem
|
||||
|
||||
|
||||
def test_env_variables_count_as_configured(tmp_path):
|
||||
state = auth.credential_state(
|
||||
"pypi", path=tmp_path / "nope", env={"TWINE_PASSWORD": "pypi-x"}
|
||||
)
|
||||
assert state.configured
|
||||
assert state.source == "environment variables"
|
||||
|
||||
|
||||
def test_wrong_username_is_rejected(tmp_path):
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text(
|
||||
"[pypi]\nusername = someone@example.com\npassword = pypi-real-token-value\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
state = auth.credential_state("pypi", path=path, env={})
|
||||
assert not state.configured
|
||||
assert "__token__" in state.problem
|
||||
|
||||
|
||||
def test_write_token_replaces_placeholder_and_keeps_comments(tmp_path):
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text(COMMENTED_TEMPLATE, encoding="utf-8")
|
||||
|
||||
auth.write_token("pypi", TOKEN, path=path)
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "# keep me: why this file exists" in text
|
||||
assert "# [testpypi] commented out on purpose" in text
|
||||
assert f"password = {TOKEN}" in text
|
||||
assert "PASTE-YOUR-PRODUCTION-TOKEN-HERE" not in text
|
||||
assert text.count("[pypi]") == 1
|
||||
assert auth.credential_state("pypi", path=path, env={}).configured
|
||||
|
||||
|
||||
def test_write_token_adds_missing_section_and_index_servers(tmp_path):
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text(COMMENTED_TEMPLATE, encoding="utf-8")
|
||||
|
||||
auth.write_token("testpypi", TOKEN, path=path)
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "[testpypi]" in text
|
||||
assert auth.index_servers(text) == {"pypi", "testpypi"}
|
||||
assert auth.credential_state("testpypi", path=path, env={}).configured
|
||||
# The production entry must be untouched.
|
||||
assert "PASTE-YOUR-PRODUCTION-TOKEN-HERE" in text
|
||||
|
||||
|
||||
def test_write_token_creates_file_when_absent(tmp_path):
|
||||
path = tmp_path / "nested" / ".pypirc"
|
||||
auth.write_token("pypi", TOKEN, path=path)
|
||||
|
||||
assert path.is_file()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "index-servers" in text
|
||||
assert auth.credential_state("pypi", path=path, env={}).configured
|
||||
|
||||
|
||||
def test_write_token_updates_existing_password_in_place(tmp_path):
|
||||
path = tmp_path / ".pypirc"
|
||||
auth.write_token("pypi", TOKEN, path=path)
|
||||
replacement = "pypi-" + "B" * 30
|
||||
auth.write_token("pypi", replacement, path=path)
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert f"password = {replacement}" in text
|
||||
assert TOKEN not in text
|
||||
assert text.count("password") == 1
|
||||
|
||||
|
||||
def test_upsert_token_does_not_leak_into_the_next_section():
|
||||
text = "[pypi]\nusername = __token__\n\n[testpypi]\nusername = __token__\n"
|
||||
result = auth.upsert_token(text, "pypi", TOKEN)
|
||||
pypi_block, testpypi_block = result.split("[testpypi]")
|
||||
assert TOKEN in pypi_block
|
||||
assert TOKEN not in testpypi_block
|
||||
|
||||
|
||||
def test_validate_token_rejects_empty_and_whitespace():
|
||||
assert auth.validate_token("")
|
||||
assert auth.validate_token("pypi-abc def")
|
||||
assert not auth.validate_token(TOKEN)
|
||||
|
||||
|
||||
def test_token_warnings_flag_suspicious_pastes():
|
||||
assert any("pypi-" in warning for warning in auth.token_warnings("not-a-token"))
|
||||
assert any("too short" in warning for warning in auth.token_warnings("pypi-abc"))
|
||||
assert auth.token_warnings(TOKEN) == []
|
||||
|
||||
|
||||
def test_prompt_never_echoes_the_token(capsys):
|
||||
token = auth.prompt_for_token("pypi", prompt_fn=lambda label: TOKEN)
|
||||
captured = capsys.readouterr()
|
||||
assert token == TOKEN
|
||||
assert TOKEN not in captured.out
|
||||
assert "hidden" in captured.out
|
||||
|
||||
|
||||
def test_prompt_abort_returns_none():
|
||||
def _raise(label):
|
||||
raise EOFError
|
||||
|
||||
assert auth.prompt_for_token("pypi", prompt_fn=_raise) is None
|
||||
|
||||
|
||||
def test_ensure_credentials_prompts_when_interactive(tmp_path, monkeypatch, capsys):
|
||||
from release import pypi
|
||||
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
monkeypatch.setattr(auth, "prompt_for_token", lambda index, **k: TOKEN)
|
||||
# _ensure_credentials resolves the path through auth.credential_state, so the
|
||||
# monkeypatched pypirc_path applies there too.
|
||||
|
||||
options = pypi.Options(version="1.2.3", apply=True)
|
||||
assert pypi._ensure_credentials(["pypi"], options, interactive=True) is True
|
||||
assert "[ok ] wrote [pypi] credentials" in capsys.readouterr().out
|
||||
assert auth.credential_state("pypi", path=tmp_path / ".pypirc", env={}).configured
|
||||
|
||||
|
||||
def test_ensure_credentials_fails_without_a_terminal(tmp_path, monkeypatch, capsys):
|
||||
from release import pypi
|
||||
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
options = pypi.Options(version="1.2.3", apply=True)
|
||||
|
||||
assert pypi._ensure_credentials(["pypi"], options, interactive=False) is False
|
||||
output = capsys.readouterr().out
|
||||
assert "--set-token pypi" in output
|
||||
assert "prompted" in output
|
||||
assert not (tmp_path / ".pypirc").exists(), "must not write anything when it cannot prompt"
|
||||
|
||||
|
||||
def test_ensure_credentials_dry_run_warns_but_continues(tmp_path, monkeypatch, capsys):
|
||||
from release import pypi
|
||||
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
options = pypi.Options(version="1.2.3", apply=False)
|
||||
|
||||
assert pypi._ensure_credentials(["pypi"], options, interactive=False) is True
|
||||
output = capsys.readouterr().out
|
||||
assert "[warn]" in output
|
||||
assert "dry run continues" in output
|
||||
|
||||
|
||||
def test_ensure_credentials_reports_testpypi_alternative(tmp_path, monkeypatch, capsys):
|
||||
from release import pypi
|
||||
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
options = pypi.Options(version="1.2.3", apply=True)
|
||||
|
||||
assert pypi._ensure_credentials(["testpypi"], options, interactive=False) is False
|
||||
output = capsys.readouterr().out
|
||||
assert "--set-token testpypi" in output
|
||||
assert "--pypi-only" in output
|
||||
|
||||
|
||||
def test_piped_token_is_used_only_once(tmp_path, monkeypatch, capsys):
|
||||
from release import pypi
|
||||
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
options = pypi.Options(version="1.2.3", apply=True, token=TOKEN)
|
||||
|
||||
assert pypi._ensure_credentials(["pypi", "testpypi"], options, interactive=True) is False
|
||||
# Second index had no token left to consume, so it fell back to the prompt,
|
||||
# which is unavailable in tests; nothing may be written for it.
|
||||
assert options.token is None
|
||||
assert auth.credential_state("pypi", path=tmp_path / ".pypirc", env={}).configured
|
||||
assert not auth.credential_state(
|
||||
"testpypi", path=tmp_path / ".pypirc", env={}
|
||||
).configured
|
||||
|
||||
|
||||
def test_section_not_listed_in_index_servers_is_not_configured(tmp_path):
|
||||
"""twine only honours sections named in [distutils] index-servers.
|
||||
|
||||
Regression test for a real failure: a hand-added [testpypi] section looked
|
||||
configured to us while twine answered "Missing 'testpypi' section".
|
||||
"""
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text(
|
||||
"[distutils]\n"
|
||||
"index-servers =\n"
|
||||
" pypi\n"
|
||||
"\n"
|
||||
"[pypi]\n"
|
||||
"username = __token__\n"
|
||||
"password = pypi-" + "A" * 30 + "\n"
|
||||
"\n"
|
||||
"[testpypi]\n"
|
||||
"username = __token__\n"
|
||||
"password = pypi-" + "B" * 30 + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
state = auth.credential_state("testpypi", path=path, env={})
|
||||
assert not state.configured
|
||||
assert "index-servers" in state.problem
|
||||
assert "--repair" in state.problem
|
||||
# The production index is unaffected.
|
||||
assert auth.credential_state("pypi", path=path, env={}).configured
|
||||
|
||||
|
||||
def test_credential_state_agrees_with_twine(tmp_path):
|
||||
"""Our verdict must match twine's own resolver for the same file."""
|
||||
twine_utils = pytest.importorskip("twine.utils")
|
||||
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text(
|
||||
"[distutils]\n"
|
||||
"index-servers =\n"
|
||||
" pypi\n"
|
||||
"\n"
|
||||
"[pypi]\n"
|
||||
"username = __token__\n"
|
||||
"password = pypi-" + "A" * 30 + "\n"
|
||||
"\n"
|
||||
"[testpypi]\n"
|
||||
"username = __token__\n"
|
||||
"password = pypi-" + "B" * 30 + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
twine_sees = set(twine_utils.get_config(str(path)))
|
||||
for index in ("pypi", "testpypi"):
|
||||
ours = auth.credential_state(index, path=path, env={})
|
||||
# twine is the authority: we may only claim configured when twine agrees.
|
||||
if ours.configured:
|
||||
assert index in twine_sees, f"{index} accepted by us but ignored by twine"
|
||||
|
||||
|
||||
def test_missing_index_servers_key_falls_back_to_both_indexes(tmp_path):
|
||||
"""twine's fallback is 'pypi testpypi' only when the key is absent entirely."""
|
||||
path = tmp_path / ".pypirc"
|
||||
text = "[distutils]\nindex-servers =\n pypi\n\n[ pypi ]\n".replace("[ pypi ]", "[pypi]")
|
||||
path.write_text(text, encoding="utf-8")
|
||||
assert auth.index_servers(path.read_text(encoding="utf-8")) == {"pypi"}
|
||||
|
||||
without_key = path.read_text(encoding="utf-8").replace("index-servers =\n pypi\n", "")
|
||||
path.write_text(without_key, encoding="utf-8", newline="")
|
||||
assert auth.index_servers(path.read_text(encoding="utf-8")) == {"pypi", "testpypi"}
|
||||
|
||||
|
||||
def test_empty_index_servers_honours_nothing(tmp_path):
|
||||
"""A trap: `index-servers =` with no entries means twine honours no sections.
|
||||
|
||||
Unlike a missing key, an empty value does not trigger the pypi+testpypi
|
||||
fallback, so a section that looks configured is ignored.
|
||||
"""
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text(
|
||||
"[distutils]\nindex-servers =\n\n"
|
||||
"[pypi]\nusername = __token__\npassword = pypi-" + "A" * 30 + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert auth.index_servers(path.read_text(encoding="utf-8")) == set()
|
||||
state = auth.credential_state("pypi", path=path, env={})
|
||||
assert not state.configured
|
||||
assert "index-servers" in state.problem
|
||||
|
||||
auth.repair_index_servers(path)
|
||||
assert auth.credential_state("pypi", path=path, env={}).configured
|
||||
|
||||
|
||||
def test_index_servers_reads_inline_and_continuation_forms():
|
||||
assert auth.index_servers("[distutils]\nindex-servers = pypi testpypi\n") == {
|
||||
"pypi",
|
||||
"testpypi",
|
||||
}
|
||||
assert auth.index_servers("[distutils]\nindex-servers =\n pypi\n") == {"pypi"}
|
||||
assert auth.index_servers("") == {"pypi", "testpypi"}
|
||||
|
||||
|
||||
def test_unlisted_sections_reports_the_gap(tmp_path):
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text(
|
||||
"[distutils]\nindex-servers =\n pypi\n\n"
|
||||
"[pypi]\npassword = pypi-x\n\n"
|
||||
"[testpypi]\npassword = pypi-y\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert auth.unlisted_sections(path) == ["testpypi"]
|
||||
|
||||
|
||||
def test_repair_index_servers_fixes_a_hand_edited_file(tmp_path):
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text(
|
||||
"[distutils]\nindex-servers =\n pypi\n\n"
|
||||
"[pypi]\nusername = __token__\npassword = pypi-" + "A" * 30 + "\n\n"
|
||||
"[testpypi]\nusername = __token__\npassword = pypi-" + "B" * 30 + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert not auth.credential_state("testpypi", path=path, env={}).configured
|
||||
|
||||
added = auth.repair_index_servers(path)
|
||||
|
||||
assert added == ["testpypi"]
|
||||
assert auth.index_servers(path.read_text(encoding="utf-8")) == {"pypi", "testpypi"}
|
||||
assert auth.credential_state("testpypi", path=path, env={}).configured
|
||||
# Tokens and comments survive the repair.
|
||||
assert "pypi-" + "B" * 30 in path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_repair_index_servers_is_a_no_op_when_correct(tmp_path):
|
||||
path = tmp_path / ".pypirc"
|
||||
auth.write_token("pypi", "pypi-" + "A" * 30, path=path)
|
||||
before = path.read_text(encoding="utf-8")
|
||||
assert auth.repair_index_servers(path) == []
|
||||
assert path.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_write_token_makes_the_result_visible_to_twine(tmp_path):
|
||||
"""The writer must produce a file twine actually honours, not just one we parse."""
|
||||
path = tmp_path / ".pypirc"
|
||||
path.write_text("[distutils]\nindex-servers =\n pypi\n", encoding="utf-8")
|
||||
|
||||
auth.write_token("testpypi", "pypi-" + "B" * 30, path=path)
|
||||
|
||||
assert auth.credential_state("testpypi", path=path, env={}).configured
|
||||
twine_utils = pytest.importorskip("twine.utils")
|
||||
assert "testpypi" in set(twine_utils.get_config(str(path)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", ["pypi", "testpypi"])
|
||||
def test_write_then_state_round_trips(tmp_path, index):
|
||||
path = tmp_path / ".pypirc"
|
||||
auth.write_token(index, TOKEN, path=path)
|
||||
state = auth.credential_state(index, path=path, env={})
|
||||
assert state.configured, state.problem
|
||||
@@ -0,0 +1,624 @@
|
||||
"""Tests for the publish gate, the GitHub plan and the PyPI command shape.
|
||||
|
||||
Nothing here touches the network, ``gh`` or an index: the mutating paths are
|
||||
verified through their pure planning functions, which is exactly what the CLI
|
||||
executes after the gate passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
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 github, publish, pypi # noqa: E402
|
||||
|
||||
|
||||
def _wheel(tmp_path: Path, name: str, payload: bytes = b"wheel") -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
|
||||
|
||||
def _report_payload(version: str, wheels: list[Path], **overrides) -> dict:
|
||||
payload = {
|
||||
"schema": 1,
|
||||
"version": version,
|
||||
"ok": True,
|
||||
"aborted": False,
|
||||
"git": {"commit": "a" * 40},
|
||||
"options": {"skip_tests": False, "allow_dirty": False},
|
||||
"wheels": [
|
||||
{"name": wheel.name, "sha256": publish.sha256_file(wheel), "size": wheel.stat().st_size}
|
||||
for wheel in wheels
|
||||
],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
# --- gate -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gate_passes_for_matching_wheels(tmp_path):
|
||||
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
||||
problems, warnings = publish.evaluate_gate(
|
||||
_report_payload("1.2.3", wheels), "1.2.3", {w.name: w for w in wheels}
|
||||
)
|
||||
assert problems == []
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_gate_rejects_version_mismatch(tmp_path):
|
||||
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
||||
problems, _ = publish.evaluate_gate(
|
||||
_report_payload("9.9.9", wheels), "1.2.3", {w.name: w for w in wheels}
|
||||
)
|
||||
assert any("report is for version" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_gate_rejects_failed_report(tmp_path):
|
||||
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
||||
problems, _ = publish.evaluate_gate(
|
||||
_report_payload("1.2.3", wheels, ok=False), "1.2.3", {w.name: w for w in wheels}
|
||||
)
|
||||
assert any("result is FAIL" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_gate_rejects_waived_guard_options(tmp_path):
|
||||
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
||||
payload = _report_payload("1.2.3", wheels, options={"skip_tests": True, "no_runtime": True})
|
||||
problems, _ = publish.evaluate_gate(payload, "1.2.3", {w.name: w for w in wheels})
|
||||
assert any("--skip-tests" in problem and "--no-runtime" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_gate_rejects_changed_wheel(tmp_path):
|
||||
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
||||
payload = _report_payload("1.2.3", wheels)
|
||||
wheels[0].write_bytes(b"tampered")
|
||||
problems, _ = publish.evaluate_gate(payload, "1.2.3", {w.name: w for w in wheels})
|
||||
assert any("changed after preflight" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_gate_rejects_uncertified_and_missing_wheels(tmp_path):
|
||||
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
||||
payload = _report_payload("1.2.3", wheels)
|
||||
extra = _wheel(tmp_path, "orisys-1.2.3-cp311-cp311-win_amd64.whl")
|
||||
problems, _ = publish.evaluate_gate(
|
||||
payload, "1.2.3", {w.name: w for w in [*wheels, extra]}
|
||||
)
|
||||
assert any("not covered by the report" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_gate_warns_on_commit_drift(tmp_path):
|
||||
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
||||
payload = _report_payload("1.2.3", wheels)
|
||||
problems, warnings = publish.evaluate_gate(
|
||||
payload, "1.2.3", {w.name: w for w in wheels}, head_commit="b" * 40
|
||||
)
|
||||
assert problems == []
|
||||
assert any("HEAD is" in warning for warning in warnings)
|
||||
|
||||
|
||||
def test_load_gate_requires_a_report(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(publish, "REPO_ROOT", tmp_path, raising=False)
|
||||
with pytest.raises(publish.PublishGateError, match="no preflight report"):
|
||||
publish.load_gate(tmp_path, "1.2.3")
|
||||
|
||||
|
||||
def test_load_gate_explains_waived_report(tmp_path):
|
||||
report_dir = tmp_path / "build" / "release" / "reports" / "1.2.3"
|
||||
report_dir.mkdir(parents=True)
|
||||
wheel_dir = tmp_path / "SDK" / "1.2.3"
|
||||
wheel_dir.mkdir(parents=True)
|
||||
wheel = wheel_dir / "orisys-1.2.3-cp310-cp310-win_amd64.whl"
|
||||
wheel.write_bytes(b"wheel")
|
||||
(report_dir / "preflight.json").write_text(
|
||||
json.dumps(_report_payload("1.2.3", [wheel], options={"skip_tests": True})),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(publish.PublishGateError, match="--skip-tests"):
|
||||
publish.load_gate(tmp_path, "1.2.3")
|
||||
|
||||
|
||||
def test_load_gate_returns_certified_wheels(tmp_path):
|
||||
report_dir = tmp_path / "build" / "release" / "reports" / "1.2.3"
|
||||
report_dir.mkdir(parents=True)
|
||||
wheel_dir = tmp_path / "SDK" / "1.2.3"
|
||||
wheel_dir.mkdir(parents=True)
|
||||
wheel = wheel_dir / "orisys-1.2.3-cp310-cp310-win_amd64.whl"
|
||||
wheel.write_bytes(b"wheel")
|
||||
(report_dir / "preflight.json").write_text(
|
||||
json.dumps(_report_payload("1.2.3", [wheel])), encoding="utf-8"
|
||||
)
|
||||
gate = publish.load_gate(tmp_path, "1.2.3")
|
||||
assert [record.name for record in gate.wheels] == [wheel.name]
|
||||
assert gate.wheels[0].sha256 == publish.sha256_file(wheel)
|
||||
assert gate.git_commit == "a" * 40
|
||||
|
||||
|
||||
# --- github -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_github_steps_shape(tmp_path):
|
||||
assets = [_wheel(tmp_path, f"orisys-1.2.3-cp3n{n}-cp3n{n}-win_amd64.whl") for n in (0, 1)]
|
||||
steps = github.build_github_steps(
|
||||
version="1.2.3",
|
||||
branch="main",
|
||||
tag="v1.2.3",
|
||||
assets=assets,
|
||||
notes_file=None,
|
||||
)
|
||||
by_description = {step.description: step.argv for step in steps}
|
||||
assert by_description["create tag"] == ["git", "tag", "v1.2.3"]
|
||||
assert by_description["push branch"] == ["git", "push", "origin", "main"]
|
||||
assert by_description["push tag"] == ["git", "push", "origin", "v1.2.3"]
|
||||
|
||||
release_argv = by_description["create GitHub Release with assets"]
|
||||
assert release_argv[:2] == ["gh", "release"]
|
||||
assert "v1.2.3" in release_argv
|
||||
assert "--verify-tag" in release_argv
|
||||
assert "--latest" in release_argv
|
||||
assert release_argv[-2:] == [str(asset) for asset in assets]
|
||||
|
||||
|
||||
def test_github_steps_include_notes_file(tmp_path):
|
||||
notes = tmp_path / "notes.md"
|
||||
notes.write_text("hello", encoding="utf-8")
|
||||
steps = github.build_github_steps(
|
||||
version="1.2.3", branch="main", tag="v1.2.3", assets=[], notes_file=notes
|
||||
)
|
||||
release_argv = next(
|
||||
step.argv for step in steps if step.description.startswith("create GitHub Release")
|
||||
)
|
||||
assert "--notes-file" in release_argv
|
||||
assert str(notes) in release_argv
|
||||
|
||||
|
||||
def test_release_notes_list_every_wheel():
|
||||
notes = github.build_release_notes(
|
||||
"1.2.3", [Path("a.whl"), Path("b.whl")], "c" * 40
|
||||
)
|
||||
assert "a.whl" in notes and "b.whl" in notes
|
||||
assert "cccccccccccc" in notes
|
||||
|
||||
|
||||
def test_manifest_scope_excludes_internal_docs():
|
||||
"""The public repo must not receive our internal process documents."""
|
||||
entry = github.manifest_entry()
|
||||
docs_entry = next(item for item in entry["sync"] if item["from"] == "docs")
|
||||
assert set(docs_entry["include"]) == {"get_started.md", "api.md"}
|
||||
assert "publishing.md" not in docs_entry["include"]
|
||||
assert "pypi_readme.md" not in docs_entry["include"]
|
||||
|
||||
|
||||
def test_manifest_scope_covers_examples_and_sensor_json_only():
|
||||
entry = github.manifest_entry()
|
||||
examples_entry = next(item for item in entry["sync"] if item["from"] == "examples")
|
||||
# Whole tree (no include list), with caches excluded.
|
||||
assert "include" not in examples_entry
|
||||
assert set(examples_entry["exclude"]) >= {"__pycache__", "*.pyc"}
|
||||
|
||||
sensors_entry = next(item for item in entry["sync"] if item["from"] == "sensors")
|
||||
assert sensors_entry["include"]
|
||||
assert all(
|
||||
name.endswith(".json") for name in sensors_entry["include"]
|
||||
), "binary sensor assets belong in the wheel, not the public repo"
|
||||
|
||||
|
||||
def test_github_steps_resume_skips_tag_creation(tmp_path):
|
||||
"""A tag pushed by an earlier failed run must not block the release upload."""
|
||||
assets = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
||||
steps = github.build_github_steps(
|
||||
version="1.2.3",
|
||||
branch="main",
|
||||
tag="v1.2.3",
|
||||
assets=assets,
|
||||
notes_file=None,
|
||||
create_tag=False,
|
||||
)
|
||||
descriptions = [step.description for step in steps]
|
||||
assert "create tag" not in descriptions
|
||||
assert descriptions[-1] == "create GitHub Release with assets"
|
||||
# The pushes stay: pushing an up-to-date ref is a safe no-op.
|
||||
assert "push branch" in descriptions and "push tag" in descriptions
|
||||
|
||||
|
||||
def test_non_github_remote_is_reported(tmp_path):
|
||||
repo = tmp_path / "release"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", "ssh://git@internal.example:24928/orisys-tech/orisys-sdk.git"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
problem = github.non_github_remote_problem(repo)
|
||||
assert problem is not None and "not a GitHub host" in problem
|
||||
|
||||
subprocess.run(
|
||||
["git", "remote", "set-url", "origin", "git@github.com:orisys-tech/orisys-sdk.git"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert github.non_github_remote_problem(repo) is None
|
||||
|
||||
|
||||
def _synthetic_publish_layout(tmp_path: Path, version: str, *, tag_exists: bool):
|
||||
"""Build a fake SDK checkout + release repo and point the module at them."""
|
||||
sdk = tmp_path / "sdk"
|
||||
wheel_dir = sdk / "SDK" / version
|
||||
wheel_dir.mkdir(parents=True)
|
||||
wheels = [wheel_dir / f"orisys-{version}-cp310-cp310-win_amd64.whl"]
|
||||
wheels[0].write_bytes(b"wheel")
|
||||
|
||||
report_dir = sdk / "build" / "release" / "reports" / version
|
||||
report_dir.mkdir(parents=True)
|
||||
(report_dir / "preflight.json").write_text(
|
||||
json.dumps(_report_payload(version, wheels)), encoding="utf-8"
|
||||
)
|
||||
|
||||
release = tmp_path / "orisys-sdk-release"
|
||||
release.mkdir()
|
||||
for command in (
|
||||
["git", "init", "-q"],
|
||||
["git", "config", "user.email", "test@example.invalid"],
|
||||
["git", "config", "user.name", "test"],
|
||||
["git", "remote", "add", "origin", "git@github.com:orisys-tech/orisys-sdk.git"],
|
||||
):
|
||||
subprocess.run(command, cwd=release, check=True, capture_output=True)
|
||||
(release / "README.md").write_text("x", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "-A"], cwd=release, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-qm", "init"], cwd=release, check=True, capture_output=True)
|
||||
if tag_exists:
|
||||
subprocess.run(["git", "tag", f"v{version}"], cwd=release, check=True, capture_output=True)
|
||||
|
||||
manifest = sdk / "tools" / "downstream_sync_manifest.json"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"downstreams": [
|
||||
{"name": "orisys-sdk-release", "path": "../orisys-sdk-release", "sync": []}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return sdk, release, wheels[0]
|
||||
|
||||
|
||||
def test_publish_dry_run_resumes_when_tag_already_exists(tmp_path, monkeypatch, capsys):
|
||||
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.3", tag_exists=True)
|
||||
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
||||
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
||||
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
||||
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
|
||||
|
||||
exit_code = github.publish(
|
||||
github.Options(version="1.2.3", apply=False, skip_fetch=True)
|
||||
)
|
||||
output = capsys.readouterr().out
|
||||
assert exit_code == 0
|
||||
assert "already exist" in output and "skipping tag creation" in output
|
||||
assert "git tag v1.2.3" not in output
|
||||
assert "gh release create v1.2.3" in output
|
||||
|
||||
|
||||
def test_publish_dry_run_creates_tag_when_absent(tmp_path, monkeypatch, capsys):
|
||||
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.4", tag_exists=False)
|
||||
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
||||
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
||||
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
||||
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
|
||||
|
||||
exit_code = github.publish(
|
||||
github.Options(version="1.2.4", apply=False, skip_fetch=True)
|
||||
)
|
||||
output = capsys.readouterr().out
|
||||
assert exit_code == 0
|
||||
assert "git tag v1.2.4" in output
|
||||
|
||||
|
||||
def test_publish_refuses_when_release_already_exists(tmp_path, monkeypatch, capsys):
|
||||
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.5", tag_exists=True)
|
||||
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
||||
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
||||
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
||||
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: True)
|
||||
|
||||
exit_code = github.publish(
|
||||
github.Options(version="1.2.5", apply=False, skip_fetch=True)
|
||||
)
|
||||
assert exit_code == 1
|
||||
assert "already exists; nothing to publish" in capsys.readouterr().out
|
||||
|
||||
|
||||
def _synthetic_report_layout(tmp_path: Path, version: str) -> tuple[Path, Path]:
|
||||
"""A fake SDK checkout with wheels and a passing preflight report."""
|
||||
sdk = tmp_path / "sdk"
|
||||
wheel_dir = sdk / "SDK" / version
|
||||
wheel_dir.mkdir(parents=True)
|
||||
wheel = wheel_dir / f"orisys-{version}-cp310-cp310-win_amd64.whl"
|
||||
wheel.write_bytes(b"wheel")
|
||||
|
||||
report_dir = sdk / "build" / "release" / "reports" / version
|
||||
report_dir.mkdir(parents=True)
|
||||
(report_dir / "preflight.json").write_text(
|
||||
json.dumps(_report_payload(version, [wheel])), encoding="utf-8"
|
||||
)
|
||||
return sdk, wheel
|
||||
|
||||
|
||||
def test_upload_phases_default_is_testpypi_only():
|
||||
assert pypi.upload_phases(pypi.Options(version="1.2.3")) == ["testpypi"]
|
||||
|
||||
|
||||
def test_upload_phases_confirm_pypi_adds_the_real_index():
|
||||
options = pypi.Options(version="1.2.3", confirm_pypi=True)
|
||||
assert pypi.upload_phases(options) == ["testpypi", "pypi"]
|
||||
|
||||
|
||||
def test_upload_phases_pypi_only_skips_the_rehearsal():
|
||||
# The relevant case: a production token cannot be used against TestPyPI.
|
||||
assert pypi.upload_phases(pypi.Options(version="1.2.3", pypi_only=True)) == ["pypi"]
|
||||
|
||||
|
||||
def test_pypi_only_dry_run_targets_pypi_only(tmp_path, monkeypatch, capsys):
|
||||
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
|
||||
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
|
||||
monkeypatch.setattr(pypi, "index_state", lambda index, **k: pypi.IndexState(versions=[]))
|
||||
from release import auth
|
||||
|
||||
auth.write_token("pypi", "pypi-" + "A" * 30, path=tmp_path / ".pypirc")
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
|
||||
exit_code = pypi.publish(
|
||||
pypi.Options(version="1.2.3", apply=False, pypi_only=True, skip_check=True)
|
||||
)
|
||||
output = capsys.readouterr().out
|
||||
assert exit_code == 0
|
||||
assert "--pypi-only" in output
|
||||
assert "upload 1 wheel(s) to pypi" in output
|
||||
assert "testpypi" not in output
|
||||
|
||||
|
||||
def test_pypi_only_and_confirm_pypi_are_mutually_exclusive():
|
||||
from release.cli import _cmd_pypi
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_cmd_pypi(["--version", "1.2.3", "--pypi-only", "--confirm-pypi"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
|
||||
def test_manifest_checks_use_the_python_placeholder():
|
||||
entry = github.manifest_entry()
|
||||
for command in entry["checks"]:
|
||||
assert command.startswith("{python} "), command
|
||||
|
||||
|
||||
# --- pypi -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_twine_upload_argv_targets_the_repository():
|
||||
argv = pypi.twine_upload_argv([Path("a.whl")], "testpypi")
|
||||
assert argv[:3] == [sys.executable, "-m", "twine"]
|
||||
assert "--repository" in argv and "testpypi" in argv
|
||||
assert argv[-1] == "a.whl"
|
||||
|
||||
|
||||
def test_twine_upload_argv_defaults_to_pypi():
|
||||
argv = pypi.twine_upload_argv([Path("a.whl")], None)
|
||||
assert "--repository" not in argv
|
||||
|
||||
|
||||
def test_credentials_hint_points_at_the_setup_command(tmp_path, monkeypatch):
|
||||
from release import auth
|
||||
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
|
||||
hint = pypi.credentials_hint("pypi")
|
||||
assert hint is not None
|
||||
assert "release auth --set-token pypi" in hint
|
||||
|
||||
|
||||
def test_credentials_hint_for_testpypi_mentions_pypi_only(tmp_path, monkeypatch):
|
||||
from release import auth
|
||||
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
|
||||
hint = pypi.credentials_hint("testpypi")
|
||||
assert hint is not None
|
||||
assert "--set-token testpypi" in hint
|
||||
assert "--pypi-only" in hint
|
||||
|
||||
|
||||
def test_credentials_hint_silent_when_configured(tmp_path, monkeypatch):
|
||||
from release import auth
|
||||
|
||||
path = tmp_path / ".pypirc"
|
||||
auth.write_token("pypi", "pypi-" + "A" * 30, path=path)
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: path)
|
||||
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
|
||||
assert pypi.credentials_hint("pypi") is None
|
||||
|
||||
|
||||
def test_index_state_parses_versions(monkeypatch):
|
||||
class Response:
|
||||
def __init__(self, payload):
|
||||
self._payload = json.dumps(payload).encode()
|
||||
|
||||
def read(self, *args):
|
||||
return self._payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
pypi.urllib.request,
|
||||
"urlopen",
|
||||
lambda *a, **k: Response({"releases": {"1.2.3": [{"filename": "a"}], "0.0.0": []}}),
|
||||
)
|
||||
state = pypi.index_state("pypi")
|
||||
assert state.known
|
||||
assert state.versions == ["1.2.3"]
|
||||
|
||||
|
||||
def test_index_state_reports_lookup_failure(monkeypatch):
|
||||
def _raise(*args, **kwargs):
|
||||
raise urllib.error.URLError("offline")
|
||||
|
||||
monkeypatch.setattr(pypi.urllib.request, "urlopen", _raise)
|
||||
state = pypi.index_state("pypi")
|
||||
assert not state.known
|
||||
assert "offline" in state.error
|
||||
|
||||
|
||||
def test_index_files_returns_none_when_version_absent(monkeypatch):
|
||||
class Response:
|
||||
def read(self, *args):
|
||||
return json.dumps({"releases": {}}).encode()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(pypi.urllib.request, "urlopen", lambda *a, **k: Response())
|
||||
assert pypi.index_files("pypi", "1.2.3") == (None, None)
|
||||
|
||||
|
||||
def test_index_files_distinguishes_lookup_failure_from_absence(monkeypatch):
|
||||
"""A network hiccup must not be reported as "not published yet"."""
|
||||
|
||||
def _raise(*args, **kwargs):
|
||||
raise urllib.error.URLError("connection reset")
|
||||
|
||||
monkeypatch.setattr(pypi.urllib.request, "urlopen", _raise)
|
||||
filenames, error = pypi.index_files("testpypi", "1.2.3")
|
||||
assert filenames is None
|
||||
assert error is not None and "connection reset" in error
|
||||
|
||||
|
||||
def test_index_files_returns_filenames(monkeypatch):
|
||||
class Response:
|
||||
def read(self, *args):
|
||||
return json.dumps(
|
||||
{"releases": {"1.2.3": [{"filename": "b.whl"}, {"filename": "a.whl"}]}}
|
||||
).encode()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(pypi.urllib.request, "urlopen", lambda *a, **k: Response())
|
||||
assert pypi.index_files("pypi", "1.2.3") == (["a.whl", "b.whl"], None)
|
||||
|
||||
|
||||
def test_wait_for_release_retries_until_indexing_finishes(monkeypatch):
|
||||
"""TestPyPI lags the upload response; the check must poll, not give up."""
|
||||
calls = {"n": 0}
|
||||
slept: list[float] = []
|
||||
|
||||
def _fake(index, version, *, timeout=20.0):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
return None, None
|
||||
return ["a.whl"], None
|
||||
|
||||
monkeypatch.setattr(pypi, "index_files", _fake)
|
||||
filenames, error = pypi.wait_for_release(
|
||||
"testpypi", "1.2.3", attempts=5, delay=1.0, sleep=slept.append
|
||||
)
|
||||
assert filenames == ["a.whl"]
|
||||
assert error is None
|
||||
assert calls["n"] == 3
|
||||
assert slept == [1.0, 1.0]
|
||||
|
||||
|
||||
def test_wait_for_release_reports_the_last_error(monkeypatch):
|
||||
def _fake(index, version, *, timeout=20.0):
|
||||
return None, "HTTP 503"
|
||||
|
||||
monkeypatch.setattr(pypi, "index_files", _fake)
|
||||
filenames, error = pypi.wait_for_release(
|
||||
"pypi", "1.2.3", attempts=2, delay=0.0, sleep=lambda _: None
|
||||
)
|
||||
assert filenames is None
|
||||
assert error == "HTTP 503"
|
||||
|
||||
|
||||
def test_existing_release_state_satisfied_conflict_absent(monkeypatch, capsys):
|
||||
expected = {"orisys-1.2.3-cp310-cp310-win_amd64.whl"}
|
||||
monkeypatch.setattr(pypi, "index_files", lambda i, v, **k: (None, None))
|
||||
assert pypi.existing_release_state("pypi", "1.2.3", expected) == "absent"
|
||||
|
||||
monkeypatch.setattr(pypi, "index_files", lambda i, v, **k: (sorted(expected), None))
|
||||
assert pypi.existing_release_state("pypi", "1.2.3", expected) == "satisfied"
|
||||
|
||||
monkeypatch.setattr(
|
||||
pypi, "index_files", lambda i, v, **k: (["orisys-1.2.3-cp311-cp311-win_amd64.whl"], None)
|
||||
)
|
||||
assert pypi.existing_release_state("pypi", "1.2.3", expected) == "conflict"
|
||||
output = capsys.readouterr().out
|
||||
assert "different file set" in output and "missing" in output
|
||||
|
||||
|
||||
def test_publish_rerun_after_success_is_not_a_failure(tmp_path, monkeypatch, capsys):
|
||||
"""Re-running a finished phase must succeed, not look like a new error."""
|
||||
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
|
||||
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
|
||||
from release import auth
|
||||
|
||||
auth.write_token("pypi", "pypi-" + "A" * 30, path=tmp_path / ".pypirc")
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
monkeypatch.setattr(
|
||||
pypi, "index_files", lambda i, v, **k: ([wheel.name], None)
|
||||
)
|
||||
|
||||
exit_code = pypi.publish(
|
||||
pypi.Options(version="1.2.3", apply=True, pypi_only=True, skip_check=True)
|
||||
)
|
||||
output = capsys.readouterr().out
|
||||
assert exit_code == 0
|
||||
assert "already serves orisys 1.2.3" in output
|
||||
assert "every requested index already has this release" in output
|
||||
|
||||
|
||||
def test_publish_aborts_on_a_conflicting_existing_release(tmp_path, monkeypatch, capsys):
|
||||
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
|
||||
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
|
||||
from release import auth
|
||||
|
||||
auth.write_token("pypi", "pypi-" + "A" * 30, path=tmp_path / ".pypirc")
|
||||
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
||||
monkeypatch.setattr(pypi, "index_files", lambda i, v, **k: (["other.whl"], None))
|
||||
|
||||
exit_code = pypi.publish(
|
||||
pypi.Options(version="1.2.3", apply=True, pypi_only=True, skip_check=True)
|
||||
)
|
||||
assert exit_code == 1
|
||||
assert "different file set" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_index_json_urls_cover_both_indexes():
|
||||
assert pypi.INDEX_JSON["testpypi"].startswith("https://test.pypi.org/")
|
||||
assert pypi.INDEX_JSON["pypi"].startswith("https://pypi.org/")
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Tests for build/release version guards, report writing and preflight steps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
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 = "1.2.3", 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-1.2.3-cp310-cp310-win_amd64.whl")) == "1.2.3"
|
||||
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", "1.2.3"):
|
||||
(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.9.0", "1.2.3"]
|
||||
|
||||
|
||||
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, "1.2.3")
|
||||
_stage_wheel(repo, "0.4.3")
|
||||
wheels = version_mod.sdk_wheels(repo, "1.2.3")
|
||||
assert [w.name for w in wheels] == ["orisys-1.2.3-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"}], "1.2.3": [{"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", "1.2.3"]
|
||||
|
||||
|
||||
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": "1.2.3",
|
||||
"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="1.2.3")
|
||||
step = Preflight(PreflightOptions(version="9.9.9"), repo_root=repo)._version_consistency()
|
||||
assert not step.ok
|
||||
assert "1.2.3" 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="1.2.3")
|
||||
step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._version_consistency()
|
||||
assert step.ok, step.detail
|
||||
|
||||
|
||||
def test_duplicate_guard_allows_staged_sdk_dir(tmp_path, monkeypatch):
|
||||
"""A populated SDK/<version>/ is the normal preflight input, not a hazard."""
|
||||
repo = _make_repo(tmp_path, version="1.2.3")
|
||||
_stage_wheel(repo, "1.2.3")
|
||||
monkeypatch.setattr(version_mod, "git_tags", lambda root: [])
|
||||
step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._duplicate_guard()
|
||||
assert step.ok, step.detail
|
||||
assert "staged wheel(s) to verify" in step.detail
|
||||
|
||||
|
||||
def test_duplicate_guard_blocks_released_tag(tmp_path, monkeypatch):
|
||||
repo = _make_repo(tmp_path, version="1.2.3")
|
||||
_stage_wheel(repo, "1.2.3")
|
||||
monkeypatch.setattr(version_mod, "git_tags", lambda root: ["v1.2.3", "v0.5.1"])
|
||||
step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._duplicate_guard()
|
||||
assert not step.ok
|
||||
assert "already mark 1.2.3 as released" in step.detail
|
||||
assert "--force" in step.detail
|
||||
|
||||
|
||||
def test_duplicate_guard_force_accepts_released_tag(tmp_path, monkeypatch):
|
||||
repo = _make_repo(tmp_path, version="1.2.3")
|
||||
monkeypatch.setattr(version_mod, "git_tags", lambda root: ["1.2.3"])
|
||||
step = Preflight(
|
||||
PreflightOptions(version="1.2.3", force=True), repo_root=repo
|
||||
)._duplicate_guard()
|
||||
assert step.ok, step.detail
|
||||
assert "accepted via --force" in step.detail
|
||||
|
||||
|
||||
def test_duplicate_guard_reports_unreleased_version(tmp_path, monkeypatch):
|
||||
repo = _make_repo(tmp_path, version="0.5.3")
|
||||
monkeypatch.setattr(version_mod, "git_tags", lambda root: ["v1.2.3"])
|
||||
step = Preflight(PreflightOptions(version="0.5.3"), repo_root=repo)._duplicate_guard()
|
||||
assert step.ok, step.detail
|
||||
assert "no local git tag marks 0.5.3" in step.detail
|
||||
|
||||
|
||||
def test_duplicate_guard_flags_published_version(tmp_path, monkeypatch):
|
||||
repo = _make_repo(tmp_path, version="1.2.3")
|
||||
monkeypatch.setattr(version_mod, "git_tags", lambda root: [])
|
||||
monkeypatch.setattr(version_mod, "pypi_released_versions", lambda *a, **k: (["1.2.3"], None))
|
||||
step = Preflight(
|
||||
PreflightOptions(version="1.2.3", check_pypi=True), repo_root=repo
|
||||
)._duplicate_guard()
|
||||
assert not step.ok
|
||||
assert "already published on PyPI" in step.detail
|
||||
|
||||
|
||||
def test_released_tags_for_matches_version_spellings():
|
||||
tags = ["v0.5.1", "0.5.1", "v0.4.3", "0.50"]
|
||||
assert version_mod.released_tags_for("0.5.1", tags) == ["0.5.1", "v0.5.1"]
|
||||
assert version_mod.released_tags_for("1.2.3", tags) == []
|
||||
|
||||
|
||||
def test_git_tags_reads_repo_tags(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
for command in (
|
||||
["git", "init", "-q"],
|
||||
["git", "config", "user.email", "test@example.invalid"],
|
||||
["git", "config", "user.name", "test"],
|
||||
):
|
||||
subprocess.run(command, cwd=repo, check=True, capture_output=True)
|
||||
(repo / "f.txt").write_text("x", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "f.txt"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-qm", "init"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "tag", "v1.2.3"], cwd=repo, check=True, capture_output=True)
|
||||
|
||||
assert "v1.2.3" in version_mod.git_tags(repo)
|
||||
|
||||
|
||||
def test_version_string_scan_flags_stale_literal(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
(repo / "docs").mkdir(parents=True)
|
||||
(repo / "docs" / "a.md").write_text("版本:0.4.0\n", encoding="utf-8")
|
||||
(repo / "README.md").write_text("release 1.2.3\n", encoding="utf-8")
|
||||
hits = version_mod.find_version_literals(repo, "1.2.3")
|
||||
assert [(path, line) for path, line, _ in hits] == [("docs/a.md", 1)]
|
||||
|
||||
|
||||
def test_version_string_scan_ignores_dependency_constraints(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "docs.md").write_text(
|
||||
"pip install numpy>=1.24.4 scipy==1.13 opencv-python>=4.10.0.84\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert version_mod.find_version_literals(repo, "1.2.3") == []
|
||||
|
||||
|
||||
def test_version_string_scan_honours_allowlist(tmp_path, monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
version_mod.VERSION_STRING_ALLOWLIST, "docs.md", frozenset({"0.4.0"})
|
||||
)
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "docs.md").write_text("历史版本:0.4.0\n", encoding="utf-8")
|
||||
assert version_mod.find_version_literals(repo, "1.2.3") == []
|
||||
|
||||
|
||||
def test_version_string_scan_skips_tests_directory(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
(repo / "tests").mkdir(parents=True)
|
||||
(repo / "tests" / "test_x.py").write_text('v = "0.1.2"\n', encoding="utf-8")
|
||||
assert version_mod.find_version_literals(repo, "1.2.3") == []
|
||||
|
||||
|
||||
def test_version_string_scan_accepts_current_version_and_v_prefix(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "README.md").write_text("current 1.2.3 and tag v1.2.3\n", encoding="utf-8")
|
||||
assert version_mod.find_version_literals(repo, "1.2.3") == []
|
||||
|
||||
|
||||
def test_preflight_version_strings_step_fails_on_leftover(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="1.2.3")
|
||||
(repo / "docs").mkdir()
|
||||
(repo / "docs" / "guide.md").write_text("版本:0.4.0\n", encoding="utf-8")
|
||||
step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._version_strings()
|
||||
assert not step.ok
|
||||
assert "0.4.0" in step.detail
|
||||
assert "VERSION_STRING_ALLOWLIST" in step.detail
|
||||
|
||||
|
||||
def test_preflight_version_strings_step_passes_on_clean_tree(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="1.2.3")
|
||||
(repo / "README.md").write_text("no version literal here\n", encoding="utf-8")
|
||||
step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._version_strings()
|
||||
assert step.ok, step.detail
|
||||
|
||||
|
||||
def test_repo_has_no_stale_version_literals():
|
||||
"""The real repository must satisfy its own version-string gate."""
|
||||
from common.pyproject_utils import get_version
|
||||
|
||||
version = get_version(REPO_ROOT)
|
||||
assert version_mod.find_version_literals(REPO_ROOT, version) == []
|
||||
|
||||
|
||||
def test_bundle_precheck_fails_when_pattern_resolves_nothing(tmp_path):
|
||||
repo = _make_repo(tmp_path, version="1.2.3")
|
||||
(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="1.2.3"), 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="1.2.3")
|
||||
step = Preflight(PreflightOptions(version="1.2.3"), 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="1.2.3")
|
||||
_stage_wheel(repo, "1.2.3")
|
||||
monkeypatch.setattr("release.preflight.default_verification_video", lambda root: None)
|
||||
preflight = Preflight(PreflightOptions(version="1.2.3"), 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="1.2.3")
|
||||
step = Preflight(
|
||||
PreflightOptions(version="1.2.3", 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="1.2.3")
|
||||
step = Preflight(
|
||||
PreflightOptions(version="1.2.3", 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="1.2.3")
|
||||
preflight = Preflight(PreflightOptions(version="1.2.3"), 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="1.2.3")
|
||||
preflight = Preflight(PreflightOptions(version="1.2.3", keep_going=True), repo_root=repo)
|
||||
preflight._execute(preflight._repo_state)
|
||||
assert not preflight.aborted
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.signal import butter, filtfilt
|
||||
|
||||
from orisys import Sensor
|
||||
from orisys import Sensor, configs
|
||||
from orisys.signal_processing import (
|
||||
NORMAL_FORCE_CAL_OFFSET,
|
||||
NORMAL_FORCE_CAL_SLOPE,
|
||||
@@ -224,12 +227,13 @@ def test_box_read_info_applies_force_calibration_json():
|
||||
|
||||
def test_read_info_force_stays_raw_without_calibration_json(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"orisys.sensor.configs.load_force_calibration",
|
||||
lambda _name: None,
|
||||
"orisys.calibration.load",
|
||||
lambda *args, **kwargs: None,
|
||||
)
|
||||
sensor = Sensor("external", config_name="box", verbose=False, backend="cpu")
|
||||
try:
|
||||
assert sensor._force_cal_sensor_name is None
|
||||
assert sensor.calibration is None
|
||||
sensor._force_raw = (111.0, -22.0, 33.0)
|
||||
sensor._apply_force_filter()
|
||||
fn, fx, fy = sensor.read_info(
|
||||
@@ -262,3 +266,239 @@ def test_configure_hhd_downsample_invalidates_solver_cache():
|
||||
sensor.configure(hhd_downsample="nope")
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
# ------------------------------------------------- bundle resolution anchors
|
||||
#
|
||||
# The bundle is chosen by the *running* root's folder layout, not by the root's
|
||||
# name and not by whichever checkout happens to be importable. These build small
|
||||
# real trees, because ``_runtime_roots`` asks the filesystem whether a directory
|
||||
# carries ``examples/`` beside ``sensors/``.
|
||||
_CAL_DOC = {
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {"method": "piecewise_linear", "points": [[0.0, 0.0], [1000.0, 1.0]]},
|
||||
"shear_x": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"points": [[0.0, 0.0], [500.0, 2.0]],
|
||||
},
|
||||
"shear_y": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"points": [[0.0, 0.0], [500.0, 3.0]],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _make_root(base, *, name="anything", sensor_json=True, calibration=True):
|
||||
"""A runnable SDK root: ``examples/`` beside ``sensors/<name>/``.
|
||||
|
||||
``sensor.json`` is copied from the shipped bundle because it is a *sensor
|
||||
config*: a stub document would make ``_load_config`` depend on whichever
|
||||
config the machine happens to resolve, which is not what these tests are
|
||||
about.
|
||||
"""
|
||||
root = base / name
|
||||
(root / "examples").mkdir(parents=True, exist_ok=True)
|
||||
(root / "examples" / "basic_pipeline.py").write_text("# demo\n", encoding="utf-8")
|
||||
bundle = root / "sensors" / "mini"
|
||||
bundle.mkdir(parents=True, exist_ok=True)
|
||||
if sensor_json:
|
||||
shipped = Path(__file__).resolve().parents[1] / "sensors" / "mini" / "sensor.json"
|
||||
(bundle / "sensor.json").write_text(
|
||||
shipped.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
if calibration:
|
||||
(bundle / "force_calibration.json").write_text(
|
||||
json.dumps(_CAL_DOC), encoding="utf-8"
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def _bare_sensor(**attrs):
|
||||
"""A Sensor instance without running ``__init__``/``_load_config``.
|
||||
|
||||
The note-formatting helpers touch no sensor state, so a whole sensor (and
|
||||
therefore a whole machine config) is not needed to test them.
|
||||
"""
|
||||
sensor = object.__new__(Sensor)
|
||||
for key, value in attrs.items():
|
||||
setattr(sensor, key, value)
|
||||
return sensor
|
||||
|
||||
|
||||
def test_runtime_root_anchor_keys_on_layout_not_on_the_folder_name(tmp_path):
|
||||
named = _make_root(tmp_path, name="some-random-name")
|
||||
assert configs._is_sdk_runtime_root(named) is True
|
||||
|
||||
no_examples = tmp_path / "dist-like"
|
||||
(no_examples / "sensors" / "mini").mkdir(parents=True)
|
||||
assert configs._is_sdk_runtime_root(no_examples) is False
|
||||
|
||||
neither = tmp_path / "plain"
|
||||
neither.mkdir()
|
||||
assert configs._is_sdk_runtime_root(neither) is False
|
||||
|
||||
|
||||
def test_runtime_roots_walk_up_from_a_subdirectory(tmp_path, monkeypatch):
|
||||
"""Launching from ``<root>/examples`` still finds ``<root>``.
|
||||
|
||||
Regression: the ancestor filter used to compare against *every* anchor, so a
|
||||
platform ancestor such as ``D:\\`` disqualified every root and this list came
|
||||
back empty.
|
||||
"""
|
||||
root = _make_root(tmp_path, name="renamed-release")
|
||||
subdir = root / "examples"
|
||||
monkeypatch.setattr(configs.Path, "cwd", classmethod(lambda cls: subdir))
|
||||
roots = [str(path) for path in configs._runtime_roots()]
|
||||
assert str(root) in roots, roots
|
||||
# The point of the regression: every returned entry is a genuine runnable
|
||||
# root, never a bare platform ancestor that merely sat above the launcher.
|
||||
for entry in roots:
|
||||
assert configs._is_sdk_runtime_root(Path(entry)), entry
|
||||
assert str(root.parent) not in roots
|
||||
assert str(root.drive + "\\") not in roots
|
||||
|
||||
|
||||
def test_a_parent_root_never_outranks_the_root_below_it(tmp_path, monkeypatch):
|
||||
"""Walking up must not let a parent's bundle shadow the real run root.
|
||||
|
||||
A parent that itself looks runnable (``examples/`` *and* ``sensors/``) must
|
||||
not outrank the root below it, or its bundle would shadow the one the
|
||||
operator is actually running from.
|
||||
"""
|
||||
parent = tmp_path / "workspace"
|
||||
inner = _make_root(parent, name="inner-release")
|
||||
# Give the PARENT its own bundle. ``_make_root(parent, name=…)`` would put
|
||||
# examples/ and sensors/ inside that named child, leaving the parent itself
|
||||
# not runnable -- which is what made this assertion fail twice.
|
||||
(parent / "examples").mkdir(parents=True, exist_ok=True)
|
||||
(parent / "sensors" / "mini").mkdir(parents=True, exist_ok=True)
|
||||
(parent / "sensors" / "mini" / "sensor.json").write_text(
|
||||
"{}", encoding="utf-8"
|
||||
)
|
||||
assert configs._is_sdk_runtime_root(parent) is True
|
||||
assert configs._is_sdk_runtime_root(inner) is True
|
||||
|
||||
monkeypatch.setattr(configs.Path, "cwd", classmethod(lambda cls: inner))
|
||||
roots = [str(path) for path in configs._runtime_roots()]
|
||||
assert str(inner) in roots, roots
|
||||
assert str(parent) not in roots, roots
|
||||
# Nothing nested below the launcher's root may appear either.
|
||||
assert not any(
|
||||
Path(entry).is_relative_to(inner) and entry != str(inner) for entry in roots
|
||||
)
|
||||
|
||||
|
||||
def test_the_running_root_bundle_beats_a_calibration_less_checkout(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""The reported bug: a nearer bundle without coefficients must not win."""
|
||||
running = _make_root(tmp_path, name="running-root")
|
||||
checkout = _make_root(tmp_path, name="importable-checkout", calibration=False)
|
||||
monkeypatch.setattr(configs, "_runtime_roots", lambda: [running, checkout])
|
||||
# Pinned to real directories, so a leftover or unrelated bundle elsewhere on
|
||||
# the machine cannot decide the outcome.
|
||||
monkeypatch.setattr(
|
||||
configs,
|
||||
"_candidate_sensor_roots",
|
||||
lambda: [running / "sensors", checkout / "sensors"],
|
||||
)
|
||||
|
||||
bundle = configs.resolve_sensor_bundle("mini")
|
||||
assert bundle is not None
|
||||
assert bundle.root == (running / "sensors" / "mini").resolve()
|
||||
assert bundle.force_calibration_json == (
|
||||
running / "sensors" / "mini" / "force_calibration.json"
|
||||
).resolve()
|
||||
|
||||
# The whole point: the running root's document is the one the SDK loads.
|
||||
from orisys.calibration import load as load_calibration
|
||||
|
||||
cal = load_calibration(sensor_type="mini")
|
||||
assert cal is not None
|
||||
assert cal.schema_version == 2
|
||||
assert sorted(cal.channels) == ["fn", "fx", "fy"]
|
||||
assert cal.source_path == (
|
||||
running / "sensors" / "mini" / "force_calibration.json"
|
||||
).resolve()
|
||||
|
||||
|
||||
def test_a_nearer_bundle_wins_even_without_coefficients(tmp_path, monkeypatch):
|
||||
"""Nearest bundle wins, so a calibration-less nearer one hides a farther one.
|
||||
|
||||
That is the chosen precedence (the running root is authoritative), and the
|
||||
diagnostic exists precisely so this stops being silent: this is the shape of
|
||||
the original report, where a checkout's ``mini`` has only ``sensor.json``.
|
||||
"""
|
||||
checkout = _make_root(tmp_path, name="checkout", calibration=False)
|
||||
running = _make_root(tmp_path, name="run-root")
|
||||
monkeypatch.setattr(configs, "_runtime_roots", lambda: [checkout, running])
|
||||
monkeypatch.setattr(
|
||||
configs,
|
||||
"_candidate_sensor_roots",
|
||||
lambda: [checkout / "sensors", running / "sensors"],
|
||||
)
|
||||
bundle = configs.resolve_sensor_bundle("mini")
|
||||
assert bundle is not None
|
||||
assert bundle.root == (checkout / "sensors" / "mini").resolve()
|
||||
assert bundle.force_calibration_json is None
|
||||
info = configs.describe_bundle_resolution("mini")
|
||||
assert info["found_root"] == (checkout / "sensors" / "mini").resolve()
|
||||
assert info["force_calibration_json"] is False
|
||||
|
||||
|
||||
def test_describe_bundle_resolution_names_the_bundle_and_the_searched_roots(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
checkout = _make_root(tmp_path, name="checkout", calibration=False)
|
||||
running = _make_root(tmp_path, name="run-root")
|
||||
roots = [checkout / "sensors", running / "sensors"]
|
||||
# Patched at the root list, so no real bundle on this machine can satisfy
|
||||
# the lookup while the "nothing found" case is being asserted.
|
||||
monkeypatch.setattr(configs, "_candidate_sensor_roots", lambda: list(roots))
|
||||
|
||||
info = configs.describe_bundle_resolution("mini")
|
||||
assert info["found_root"] == (checkout / "sensors" / "mini").resolve()
|
||||
assert info["force_calibration_json"] is False
|
||||
assert [str(path) for path in info["searched"]] == [str(p) for p in roots]
|
||||
|
||||
(checkout / "sensors" / "mini" / "sensor.json").unlink()
|
||||
(running / "sensors" / "mini" / "sensor.json").unlink()
|
||||
missing = configs.describe_bundle_resolution("mini")
|
||||
assert missing["found_root"] is None
|
||||
assert missing["sensor_json"] is None
|
||||
assert missing["force_calibration_json"] is False
|
||||
|
||||
|
||||
def test_the_calibration_note_names_the_bundle_that_was_found(tmp_path, monkeypatch):
|
||||
"""The note explains a bundle with no coefficients, not a "missing file"."""
|
||||
checkout = _make_root(tmp_path, name="checkout", calibration=False)
|
||||
roots = [checkout / "sensors"]
|
||||
monkeypatch.setattr(configs, "_candidate_sensor_roots", lambda: list(roots))
|
||||
|
||||
# Composed via _bare_sensor: the note reads only _force_cal_attempts, so this
|
||||
# needs no machine config and cannot fail on config resolution.
|
||||
sensor = _bare_sensor(
|
||||
_force_cal_attempts=[configs.describe_bundle_resolution("mini")],
|
||||
_force_cal_sensor_name=None,
|
||||
)
|
||||
note = sensor._force_calibration_note()
|
||||
assert "has no force_calibration.json" in note
|
||||
assert str((checkout / "sensors" / "mini").resolve()) in note
|
||||
assert "roots searched" in note
|
||||
|
||||
# No bundle anywhere: say that instead of naming a root.
|
||||
(checkout / "sensors" / "mini" / "sensor.json").unlink()
|
||||
sensor = _bare_sensor(
|
||||
_force_cal_attempts=[configs.describe_bundle_resolution("mini")],
|
||||
_force_cal_sensor_name=None,
|
||||
)
|
||||
note = sensor._force_calibration_note()
|
||||
assert "no sensor bundle resolved" in note
|
||||
# A dict always keys ``found_root``, so guard its value: an unguarded copy of
|
||||
# this branch prints "bundle found at None".
|
||||
assert "None" not in note
|
||||
|
||||
@@ -19,25 +19,25 @@ def wheel_package():
|
||||
|
||||
def test_compliant_manylinux_tags(wheel_package):
|
||||
assert wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.0-cp310-cp310-manylinux_2_17_x86_64.whl")
|
||||
Path("orisys-1.2.3-cp310-cp310-manylinux_2_17_x86_64.whl")
|
||||
)
|
||||
assert wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.0-cp310-cp310-manylinux2014_x86_64.whl")
|
||||
Path("orisys-1.2.3-cp310-cp310-manylinux2014_x86_64.whl")
|
||||
)
|
||||
assert wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.0-cp310-cp310-manylinux_2_28_x86_64.whl")
|
||||
Path("orisys-1.2.3-cp310-cp310-manylinux_2_28_x86_64.whl")
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_illegal_manylinux_tags(wheel_package):
|
||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.0-cp310-cp310-manylinux_x86_64.whl")
|
||||
Path("orisys-1.2.3-cp310-cp310-manylinux_x86_64.whl")
|
||||
)
|
||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.0-cp310-cp310-linux_x86_64.whl")
|
||||
Path("orisys-1.2.3-cp310-cp310-linux_x86_64.whl")
|
||||
)
|
||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||
Path("orisys-0.5.0-cp310-cp310-win_amd64.whl")
|
||||
Path("orisys-1.2.3-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 = "1.2.3"
|
||||
DIST_INFO = f"orisys-{VERSION}.dist-info"
|
||||
|
||||
CORE_DEPS = ("numpy>=1.24.4", "opencv-python>=4.10.0.84")
|
||||
EXTRAS = {"cuda12": ("cupy-cuda12x>=12.3.0",), "gui": ("open3d>=0.17.0",)}
|
||||
|
||||
PYPROJECT = f"""
|
||||
[project]
|
||||
name = "orisys"
|
||||
version = "{VERSION}"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [{", ".join(repr(dep) for dep in CORE_DEPS)}]
|
||||
|
||||
[project.optional-dependencies]
|
||||
cuda12 = ["cupy-cuda12x>=12.3.0"]
|
||||
gui = ["open3d>=0.17.0"]
|
||||
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "configs"
|
||||
source = "config"
|
||||
target = "configs"
|
||||
patterns = ["*.json"]
|
||||
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "sensors"
|
||||
source = "sensors"
|
||||
target = "sensors"
|
||||
patterns = ["**/*.json"]
|
||||
preserve_tree = true
|
||||
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "docs"
|
||||
source = "docs"
|
||||
target = "docs"
|
||||
patterns = ["*.md"]
|
||||
|
||||
[[tool.orisys.bundle.items]]
|
||||
name = "examples"
|
||||
source = "examples"
|
||||
target = "examples"
|
||||
patterns = ["*.py", "README.md"]
|
||||
preserve_tree = true
|
||||
"""
|
||||
|
||||
# Source tree files, keyed by path relative to the synthetic repo root.
|
||||
SOURCE_FILES = {
|
||||
"config/box.json": '{"name": "box"}',
|
||||
"sensors/box/sensor.json": '{"name": "box"}',
|
||||
"sensors/finger/manifest.json": '{"name": "finger"}',
|
||||
"docs/api.md": "# api\n",
|
||||
"examples/basic_pipeline.py": "print('hi')\n",
|
||||
"examples/README.md": "# examples\n",
|
||||
}
|
||||
|
||||
# Where each source file must land inside the wheel.
|
||||
BUNDLED_ENTRIES = {
|
||||
"orisys/configs/box.json": SOURCE_FILES["config/box.json"],
|
||||
"orisys/sensors/box/sensor.json": SOURCE_FILES["sensors/box/sensor.json"],
|
||||
"orisys/sensors/finger/manifest.json": SOURCE_FILES["sensors/finger/manifest.json"],
|
||||
"orisys/docs/api.md": SOURCE_FILES["docs/api.md"],
|
||||
"orisys/examples/basic_pipeline.py": SOURCE_FILES["examples/basic_pipeline.py"],
|
||||
"orisys/examples/README.md": SOURCE_FILES["examples/README.md"],
|
||||
}
|
||||
|
||||
|
||||
def _metadata_text(
|
||||
*,
|
||||
version: str = VERSION,
|
||||
requires_python: str | None = ">=3.10",
|
||||
classifiers: tuple[str, ...] = ("Programming Language :: Python :: 3.10",),
|
||||
project_urls: tuple[str, ...] = ("Homepage, https://example.invalid",),
|
||||
license_line: str | None = "License: Proprietary",
|
||||
core_deps: tuple[str, ...] = CORE_DEPS,
|
||||
extras: dict[str, tuple[str, ...]] | None = None,
|
||||
dynamic: tuple[str, ...] = (),
|
||||
name: str = "orisys",
|
||||
) -> str:
|
||||
if extras is None:
|
||||
extras = EXTRAS
|
||||
lines = ["Metadata-Version: 2.4", f"Name: {name}", f"Version: {version}", "Summary: synthetic"]
|
||||
if requires_python:
|
||||
lines.append(f"Requires-Python: {requires_python}")
|
||||
if license_line:
|
||||
lines.append(license_line)
|
||||
lines.extend(f"Classifier: {value}" for value in classifiers)
|
||||
lines.extend(f"Project-URL: {value}" for value in project_urls)
|
||||
lines.extend(f"Dynamic: {value}" for value in dynamic)
|
||||
lines.extend(f"Requires-Dist: {dep}" for dep in core_deps)
|
||||
for extra, requirements in extras.items():
|
||||
lines.append(f"Provides-Extra: {extra}")
|
||||
lines.extend(f'Requires-Dist: {req}; extra == "{extra}"' for req in requirements)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _write_source_tree(root: Path) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8")
|
||||
for relative, text in SOURCE_FILES.items():
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# write_bytes, not write_text: text mode would translate "\n" to "\r\n"
|
||||
# on Windows and the byte-for-byte bundle comparison would fail.
|
||||
path.write_bytes(text.encode("utf-8"))
|
||||
|
||||
|
||||
def _record(entries: dict[str, bytes]) -> str:
|
||||
lines = []
|
||||
for name, data in sorted(entries.items()):
|
||||
digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode()
|
||||
lines.append(f"{name},sha256={digest},{len(data)}")
|
||||
lines.append(f"{DIST_INFO}/RECORD,,")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _make_wheel(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
filename: str | None = None,
|
||||
metadata: str | None = None,
|
||||
drop: tuple[str, ...] = (),
|
||||
extra_entries: dict[str, bytes] | None = None,
|
||||
override_bytes: dict[str, bytes] | None = None,
|
||||
drop_record: bool = False,
|
||||
corrupt_record_hash: bool = False,
|
||||
) -> tuple[Path, Path]:
|
||||
"""Build a synthetic wheel plus its matching source tree."""
|
||||
repo = tmp_path / "repo"
|
||||
_write_source_tree(repo)
|
||||
|
||||
if metadata is None:
|
||||
metadata = _metadata_text()
|
||||
|
||||
entries: dict[str, bytes] = {name: data.encode("utf-8") for name, data in BUNDLED_ENTRIES.items()}
|
||||
entries["orisys/__init__.cp312-win_amd64.pyd"] = b"compiled"
|
||||
entries["orisys_blend/__init__.py"] = b"# blend\n"
|
||||
entries["orisys_blend/kernel.py"] = b"# kernel\n"
|
||||
entries[f"{DIST_INFO}/METADATA"] = metadata.encode("utf-8")
|
||||
entries[f"{DIST_INFO}/WHEEL"] = b"Wheel-Version: 1.0\n"
|
||||
|
||||
entries.update(extra_entries or {})
|
||||
entries.update(override_bytes or {})
|
||||
for name in drop:
|
||||
entries.pop(name, None)
|
||||
|
||||
if not drop_record:
|
||||
record = _record(entries)
|
||||
if corrupt_record_hash:
|
||||
record = record.replace("sha256=", "sha256=AAAA", 1)
|
||||
entries[f"{DIST_INFO}/RECORD"] = record.encode("utf-8")
|
||||
|
||||
wheel = tmp_path / (filename or f"orisys-{VERSION}-cp312-cp312-win_amd64.whl")
|
||||
with zipfile.ZipFile(wheel, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in entries.items():
|
||||
zf.writestr(name, data)
|
||||
return wheel, repo
|
||||
|
||||
|
||||
def _failed_names(result) -> set[str]:
|
||||
return {check.name for check in result.failures}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_repo_root(monkeypatch, tmp_path):
|
||||
"""Keep inspect_wheel's default repo root from leaking the real repo in."""
|
||||
monkeypatch.setattr(wheel_contents, "REPO_ROOT", tmp_path / "unused")
|
||||
|
||||
|
||||
def test_good_wheel_passes(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path)
|
||||
result = inspect_wheel(wheel, expected_version=VERSION, repo_root=repo)
|
||||
assert result.ok, result.render()
|
||||
assert result.version == VERSION
|
||||
assert result.platform_tag == "win_amd64"
|
||||
assert not result.warnings, result.warnings
|
||||
|
||||
|
||||
def test_missing_wheel_file_fails(tmp_path):
|
||||
result = inspect_wheel(tmp_path / "nope-1.2.3-cp312-cp312-win_amd64.whl")
|
||||
assert not result.ok
|
||||
assert "wheel-exists" in _failed_names(result)
|
||||
|
||||
|
||||
def test_unparsable_filename_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, filename="orisys-1.2.3-cp312-cp312.whl")
|
||||
result = inspect_wheel(wheel, repo_root=repo)
|
||||
assert "filename" in _failed_names(result)
|
||||
|
||||
|
||||
def test_version_mismatch_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path)
|
||||
result = inspect_wheel(wheel, expected_version="9.9.9", repo_root=repo)
|
||||
failed = _failed_names(result)
|
||||
assert {"filename", "metadata-version"} <= failed
|
||||
|
||||
|
||||
def test_missing_requires_python_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(requires_python=None))
|
||||
assert "metadata-requires-python" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_classifier_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(classifiers=()))
|
||||
assert "metadata-classifiers" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_project_url_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(project_urls=()))
|
||||
assert "metadata-project-urls" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_license_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path,
|
||||
metadata=_metadata_text(license_line=None, classifiers=("Intended Audience :: Developers",)),
|
||||
)
|
||||
assert "metadata-license" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_dynamic_version_is_rejected(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(dynamic=("version", "summary")))
|
||||
result = inspect_wheel(wheel, repo_root=repo)
|
||||
assert "metadata-no-dynamic-version" in _failed_names(result)
|
||||
assert any("Dynamic" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
def test_missing_core_requirement_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(core_deps=("numpy>=1.24.4",)))
|
||||
assert "metadata-requires-dist" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_extra_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, metadata=_metadata_text(extras={"cuda12": EXTRAS["cuda12"]}))
|
||||
assert "metadata-extras" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_compiled_extension_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, drop=("orisys/__init__.cp312-win_amd64.pyd",))
|
||||
assert "compiled-extension" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_python_source_leak_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, extra_entries={"orisys/sensor.py": b"# leaked\n"}
|
||||
)
|
||||
result = inspect_wheel(wheel, repo_root=repo)
|
||||
assert "no-python-sources" in _failed_names(result)
|
||||
assert "orisys/sensor.py" in result.render()
|
||||
|
||||
|
||||
def test_example_python_files_are_allowed(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, extra_entries={"orisys/examples/plot_data.py": b"# shipped on purpose\n"}
|
||||
)
|
||||
assert inspect_wheel(wheel, repo_root=repo).ok
|
||||
|
||||
|
||||
def test_missing_bundled_file_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, drop=("orisys/sensors/box/sensor.json",))
|
||||
result = inspect_wheel(wheel, repo_root=repo)
|
||||
assert "bundle-completeness" in _failed_names(result)
|
||||
assert "orisys/sensors/box/sensor.json" in result.render()
|
||||
|
||||
|
||||
def test_missing_bundled_file_warns_when_not_strict(tmp_path):
|
||||
"""A file added to the tree after an old wheel was built is not a defect."""
|
||||
wheel, repo = _make_wheel(tmp_path, drop=("orisys/sensors/box/sensor.json",))
|
||||
result = inspect_wheel(wheel, repo_root=repo, strict_bundle=False)
|
||||
assert result.ok, result.render()
|
||||
assert any("absent from the wheel" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
def test_bundle_drift_is_fatal_when_strict(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, override_bytes={"orisys/docs/api.md": b"# stale\n"}
|
||||
)
|
||||
assert "bundle-fidelity" in _failed_names(inspect_wheel(wheel, repo_root=repo, strict_bundle=True))
|
||||
|
||||
|
||||
def test_bundle_drift_warns_when_not_strict(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, override_bytes={"orisys/docs/api.md": b"# stale\n"}
|
||||
)
|
||||
result = inspect_wheel(wheel, repo_root=repo, strict_bundle=False)
|
||||
assert result.ok, result.render()
|
||||
assert any("differ from the source tree" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
def test_pycache_entry_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, extra_entries={"orisys/examples/__pycache__/x.pyc": b"junk"}
|
||||
)
|
||||
assert "no-forbidden-entries" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_scratch_directory_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, extra_entries={"tests/test_smoke.py": b"x"})
|
||||
assert "no-forbidden-entries" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_traversing_entry_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, extra_entries={"../escape.txt": b"x"})
|
||||
assert "archive-paths" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_record_hash_mismatch_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, corrupt_record_hash=True)
|
||||
assert "record" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_missing_record_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path, drop_record=True)
|
||||
assert "record" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
def test_size_limit_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(tmp_path)
|
||||
assert "size-limit" in _failed_names(inspect_wheel(wheel, repo_root=repo, max_bytes=1))
|
||||
|
||||
|
||||
def test_bad_manylinux_tag_fails(tmp_path):
|
||||
wheel, repo = _make_wheel(
|
||||
tmp_path, filename=f"orisys-{VERSION}-cp312-cp312-linux_x86_64.whl"
|
||||
)
|
||||
assert "platform-tag" in _failed_names(inspect_wheel(wheel, repo_root=repo))
|
||||
|
||||
|
||||
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 early 0.5.x releases 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="1.2.3",
|
||||
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: 1.2.3" 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."
|
||||
)
|
||||
@@ -5,21 +5,21 @@
|
||||
"name": "orisys-sdk-release",
|
||||
"path": "../orisys-sdk-release",
|
||||
"sync": [
|
||||
{"from": "docs", "to": "docs"},
|
||||
{"from": "examples", "to": "examples", "include": [
|
||||
"README.md",
|
||||
"basic_pipeline.py",
|
||||
"mini_pipeline.py",
|
||||
"finger_pipeline.py",
|
||||
"finger_pipeline_3dview.py",
|
||||
"plot_data.py",
|
||||
"two_sensor_pipeline.py"
|
||||
]},
|
||||
{"from": "config", "to": "config", "include": ["box.json", "finger.json", "mini.json"]}
|
||||
{"from": "docs", "to": "docs", "include": ["get_started.md", "api.md"]},
|
||||
{"from": "examples", "to": "examples", "exclude": ["__pycache__", "*.pyc", "*.pyo"]},
|
||||
{"from": "config", "to": "config", "include": ["box.json", "finger.json", "mini.json"]},
|
||||
{"from": "sensors", "to": "sensors", "include": [
|
||||
"box/sensor.json",
|
||||
"box/force_calibration.json",
|
||||
"finger/sensor.json",
|
||||
"finger/manifest.json",
|
||||
"mini/sensor.json"
|
||||
]}
|
||||
],
|
||||
"checks": [
|
||||
"python -m py_compile examples/basic_pipeline.py examples/finger_pipeline.py examples/finger_pipeline_3dview.py examples/plot_data.py",
|
||||
"python -c \"import pathlib; p=pathlib.Path('.'); hits=[f for f in p.rglob('*') if f.is_file() and 'examples/products' in f.read_text(encoding='utf-8', errors='ignore').replace('\\\\','/')]; print('stale products paths:' if hits else 'no stale products paths'); [print(x) for x in hits]\""
|
||||
"{python} -c \"import pathlib, py_compile; files=sorted(pathlib.Path('examples').rglob('*.py')); [py_compile.compile(str(f), doraise=True) for f in files]; print('compiled %d example module(s)' % len(files))\"",
|
||||
"{python} -c \"import pathlib, sys; bad=[str(p) for p in pathlib.Path('.').rglob('*') if p.is_file() and p.suffix in ('.md','.py') and 'examples/products' in p.read_text(encoding='utf-8', errors='ignore').replace(chr(92),'/')]; print('stale products paths:' if bad else 'no stale products paths'); [print(' ', x) for x in bad]; sys.exit(1 if bad else 0)\"",
|
||||
"{python} -c \"import pathlib, sys; leaked=[str(p) for p in pathlib.Path('.').rglob('*') if p.name in ('publishing.md','pypi_readme.md')]; print('internal docs leaked into the public repo:', leaked); sys.exit(1 if leaked else 0)\""
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{"from": "config", "to": "config", "include": ["box.json", "mini.json", "dd06-ov-0810.json"]}
|
||||
],
|
||||
"checks": [
|
||||
"python tests/test_smoke.py"
|
||||
"{python} tests/test_smoke.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -40,8 +40,8 @@
|
||||
{"from": "assets/finger", "to": "assets/finger"}
|
||||
],
|
||||
"checks": [
|
||||
"python tests/test_smoke.py",
|
||||
"python -c \"import pathlib; t=pathlib.Path('README.md').read_text(encoding='utf-8'); print('README still mentions examples/products' if 'examples/products' in t else 'README products path ok')\""
|
||||
"{python} tests/test_smoke.py",
|
||||
"{python} -c \"import pathlib; t=pathlib.Path('README.md').read_text(encoding='utf-8'); print('README still mentions examples/products' if 'examples/products' in t else 'README products path ok')\""
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+74
-23
@@ -2,7 +2,9 @@
|
||||
"""Plan and apply downstream file syncs from tools/downstream_sync_manifest.json.
|
||||
|
||||
The ``apply`` command copies files/directories listed in the manifest. It does
|
||||
not delete extra downstream files and does not run downstream tests.
|
||||
not delete extra downstream files and does not run downstream tests by default;
|
||||
``run-checks`` (or ``release github``) runs the manifest's ``checks`` commands
|
||||
with ``{python}`` replaced by the running interpreter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,13 +12,24 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MANIFEST_NAME = "downstream_sync_manifest.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncJob:
|
||||
"""One source -> destination copy, with optional name patterns to skip."""
|
||||
|
||||
source: Path
|
||||
dest: Path
|
||||
exclude: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def sdk_root() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -43,18 +56,19 @@ def selected_downstreams(manifest: dict, target: str | None) -> list[dict]:
|
||||
return matches
|
||||
|
||||
|
||||
def expand_jobs(root: Path, item: dict) -> list[tuple[Path, Path]]:
|
||||
def expand_jobs(root: Path, item: dict) -> list[SyncJob]:
|
||||
dest_root = (root / item["path"]).resolve()
|
||||
jobs: list[tuple[Path, Path]] = []
|
||||
jobs: list[SyncJob] = []
|
||||
for entry in item.get("sync", []):
|
||||
src_base = root / entry["from"]
|
||||
dest_base = dest_root / entry["to"]
|
||||
exclude = tuple(entry.get("exclude") or ())
|
||||
includes = entry.get("include")
|
||||
if includes:
|
||||
for name in includes:
|
||||
jobs.append((src_base / name, dest_base / name))
|
||||
jobs.append(SyncJob(src_base / name, dest_base / name, exclude))
|
||||
else:
|
||||
jobs.append((src_base, dest_base))
|
||||
jobs.append(SyncJob(src_base, dest_base, exclude))
|
||||
return jobs
|
||||
|
||||
|
||||
@@ -68,8 +82,9 @@ def print_plan(root: Path, items: list[dict]) -> None:
|
||||
jobs = expand_jobs(root, item)
|
||||
if jobs:
|
||||
print("sync:")
|
||||
for src, dest in jobs:
|
||||
print(f" {src} -> {dest}")
|
||||
for job in jobs:
|
||||
note = f" (excluding {', '.join(job.exclude)})" if job.exclude else ""
|
||||
print(f" {job.source} -> {job.dest}{note}")
|
||||
else:
|
||||
print("sync: (none)")
|
||||
checks = item.get("checks") or []
|
||||
@@ -94,14 +109,14 @@ def check_paths(root: Path, items: list[dict]) -> int:
|
||||
else:
|
||||
print(f"MISS downstream root: {dest_root}")
|
||||
missing.append(str(dest_root))
|
||||
for src, dest in expand_jobs(root, item):
|
||||
src_ok = src.exists()
|
||||
dest_note = "exists" if dest.exists() else "missing (ok until apply)"
|
||||
for job in expand_jobs(root, item):
|
||||
src_ok = job.source.exists()
|
||||
dest_note = "exists" if job.dest.exists() else "missing (ok until apply)"
|
||||
status = "ok " if src_ok else "MISS"
|
||||
print(f"{status} source: {src}")
|
||||
print(f" dest: {dest} [{dest_note}]")
|
||||
print(f"{status} source: {job.source}")
|
||||
print(f" dest: {job.dest} [{dest_note}]")
|
||||
if not src_ok:
|
||||
missing.append(str(src))
|
||||
missing.append(str(job.source))
|
||||
print()
|
||||
if missing:
|
||||
print(f"{len(missing)} missing path(s).")
|
||||
@@ -110,14 +125,16 @@ def check_paths(root: Path, items: list[dict]) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def copy_job(src: Path, dest: Path) -> None:
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(src)
|
||||
if src.is_dir():
|
||||
shutil.copytree(src, dest, dirs_exist_ok=True)
|
||||
def copy_job(job: SyncJob) -> None:
|
||||
if not job.source.exists():
|
||||
raise FileNotFoundError(job.source)
|
||||
if job.source.is_dir():
|
||||
# exclude keeps caches out of the public repo (e.g. __pycache__, *.pyc)
|
||||
ignore = shutil.ignore_patterns(*job.exclude) if job.exclude else None
|
||||
shutil.copytree(job.source, job.dest, dirs_exist_ok=True, ignore=ignore)
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
job.dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(job.source, job.dest)
|
||||
|
||||
|
||||
def apply_sync(root: Path, items: list[dict]) -> int:
|
||||
@@ -128,16 +145,45 @@ def apply_sync(root: Path, items: list[dict]) -> int:
|
||||
copied = 0
|
||||
for item in items:
|
||||
print(f"== {item['name']} ==")
|
||||
for src, dest in expand_jobs(root, item):
|
||||
copy_job(src, dest)
|
||||
for job in expand_jobs(root, item):
|
||||
copy_job(job)
|
||||
copied += 1
|
||||
print(f"copied {src} -> {dest}")
|
||||
print(f"copied {job.source} -> {job.dest}")
|
||||
print()
|
||||
print(f"copied {copied} sync job(s).")
|
||||
print("checks were not run; execute the listed check commands manually.")
|
||||
return 0
|
||||
|
||||
|
||||
def run_checks(root: Path, items: list[dict], *, python: str | None = None) -> int:
|
||||
"""Run each manifest ``checks`` command inside the downstream repo.
|
||||
|
||||
``{python}`` in a command is replaced with ``python`` (or the interpreter
|
||||
given). Returns the number of failed checks.
|
||||
"""
|
||||
interpreter = python or sys.executable
|
||||
failures = 0
|
||||
for item in items:
|
||||
dest_root = (root / item["path"]).resolve()
|
||||
checks = item.get("checks") or []
|
||||
if not checks:
|
||||
continue
|
||||
print(f"== {item['name']} ==")
|
||||
for command in checks:
|
||||
resolved = command.replace("{python}", interpreter)
|
||||
print(f"running: {resolved}")
|
||||
completed = subprocess.run(
|
||||
resolved, shell=True, cwd=dest_root, check=False
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
print(f"[FAIL] exit {completed.returncode}: {resolved}")
|
||||
failures += 1
|
||||
else:
|
||||
print("[ok ] check passed")
|
||||
print()
|
||||
return failures
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
@@ -151,6 +197,9 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
apply_cmd = sub.add_parser("apply", help="copy manifest sources to downstreams")
|
||||
apply_cmd.add_argument("--target", help="limit to one downstream name")
|
||||
|
||||
run = sub.add_parser("run-checks", help="run the manifest checks in each downstream")
|
||||
run.add_argument("--target", help="limit to one downstream name")
|
||||
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -165,6 +214,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return check_paths(root, items)
|
||||
if args.command == "apply":
|
||||
return apply_sync(root, items)
|
||||
if args.command == "run-checks":
|
||||
return 1 if run_checks(root, items) else 0
|
||||
raise SystemExit(f"unknown command: {args.command}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user