Compare commits

..

22 Commits

Author SHA1 Message Date
ykcheungab 14fbfec62e remove hand.py, replaced by pipeline.py 2026-09-14 13:55:30 +08:00
ykcheungab 11962cdc34 配置没写的滤波键等于"关",不再回落到会滤波的默认值 - What: DEFAULT_FORCE_MEDIAN_WINDOW 3 -> 1、DEFAULT_FLOW_EMA_ALPHA 0.5 -> 1.0(DEFAULT_FORCE_LOWPASS_HZ 本就是 0.0),并把注释改成"配置文件没写的键就是关,而不是我们觉得合理的那一档"——原因写在注释里:否则设备会去滤一个没人要求滤的测量,而文件里没有任何线索;实测的延迟/降噪数据保留在注释中,作为 balanced/low_noise 预设的依据。docs/filters.md 第 2 节增加"缺省 = 关"这一条并说明没有 filters 段的配置什么都不滤,预设表里 balanced 的用途由"默认建议"改为"建议起步档"(它不再是默认值)。examples/filters/README.md 补一句"整段删掉等价于 off"。tests/test_filter_presets.py 新增 test_a_config_with_no_filters_block_filters_nothing:把 box bundle 的 filters 段整段删掉后构造真实 Sensor,断言 filters_state() 报 ema 1.0 / median 1 / cutoff 0.0 且 any_stage_applied 为 False。 - Why: 上一版只把"非法值"改成回落到关,但"键根本不写"仍会走 DEFAULT_* 从而把 EMA 0.5 + 中值 3 打开——这与"配置自描述、文件说什么就跑什么、出厂默认全关"矛盾,且现实里存在大量没有 filters 段的手写配置(例如 _perf/cfg_res_280x300.json),它们会在无人察觉的情况下继续滤波。缺省开是这条链上最后一个隐藏默认值。 - Verification: 删掉 filters 段后实测 filters_state() = (1.0, 1, 0.0) 且 any_stage_applied=False,出厂 box/mini/finger 三份同样为全关;pytest tests/test_filter_presets.py + tests/test_calibration.py → 33 passed;SDK 全量 336 passed / 15 skipped(唯一失败仍是需要 1200 帧而样例只有 577 帧的 longrun 测试);app 侧 studio 85 项与 shared 142 项测试、ui_check 554 项检查在升级后的 0.5.2 下全绿。 - Not in this change: 出厂 bundle 的三个显式值未动(本来就全关);_perf 的两个校验脚本不在仓库内,已就地补上显式 filters 段(balanced)以保持"开 vs 关"对照仍有意义;app 侧 wrapper/齿轮/CSV 17 列未动。 2026-09-14 10:53:33 +08:00
ykcheungab ae5bf0c208 断开连接不再因无头 OpenCV 抛错 - What: Sensor.disconnect() 里的 cv2.destroyAllWindows() 包上 try/except cv2.error:无头构建(opencv-python-headless)没有 highgui,destroyAllWindows 会抛 "The function is not implemented",此前会让 finally 里 disconnect() 的调用方(测试、采集退出路径)拿到一个与传感器无关的异常。 - Why: 跑 SDK 测试时发现 tests/test_calibration.py::test_sensor_cal_path_v2_applies_to_read_info 与 test_set_calibration_swaps_curve 在本机(cv2 4.14.0 headless)稳定失败,断言本身都过了,报错在 finally 的 disconnect() 第 1098 行;这不是滤波改动引入的,但会一直污染每次验证,且 SDK 明确支持无头/CI 使用(backend="cpu"、external-frame 模式)。 - Verification: 修前该模块 7 failed / 13 passed(含上述两个既有测试),修后 20 passed;SDK 全量 335 passed / 15 skipped(唯一失败为需要 1200 帧而 data/sample/s1_480p.mp4 只有 577 帧的 longrun 测试)。 - Not in this change: 未改 disconnect() 的其他分支;未引入 highgui 依赖探测,只在真正调用失败时忽略。 2026-09-14 10:44:13 +08:00
ykcheungab 7f8e5d0975 四个滤波预设落成机器可读的配置文件,并加上防漂移测试 - What: 新增 examples/filters/filters_{off,low_latency,balanced,low_noise}.json —— 每个都是完整可整块替换的 filters 段(五键齐全:flow_ema_alpha / force_median_window / force_lowpass_cutoff_hz / force_lowpass_order / force_lowpass_sample_rate_hz),数值即 FILTER_PRESETS 展开后的结果;examples/filters/README.md 说明这四份副本的存在与用途;新增 tests/test_filter_presets.py(12 项):预设名恰为四个、每个示例文件 == 默认块叠加该预设(改预设或改文件都会红)、每份都是含三个开关键的真实 filters 段、三个出厂 bundle(box/mini/finger)的 filters 段仍为全关、且用 external-frame 模式构造真实 Sensor 核对 filters_state() 报的也是全关(文件与设备必须一致)。 - Why: 之前的实现计划里写的是"四个 sensor_<preset>.json 整机配置",但整机配置要按传感器各复制一份、且必然随 bundle 漂移;README 里内嵌的 JSON 又无法被测试覆盖,等于把"建议用哪档滤波"这句话留在了一处会过时的散文里。改成"filters 片段 + 断言与 FILTER_PRESETS 逐值一致的测试",既不谎称是完整配置,也不会静默过时。 - Verification: pytest tests/test_filter_presets.py → 12 passed;SDK 全量 335 passed / 15 skipped(唯一失败仍是需要 1200 帧而样例只有 577 帧的 longrun 测试)。 - Not in this change: 未生成按传感器复制的整机配置;docs/filters.md 的预设表未纳入测试(只有这四个文件与代码常量对齐);app 侧未动。 2026-09-14 10:44:07 +08:00
ykcheungab 35190562e5 标定指纹改读生效值并按测量/读数分组 - What: calibration.py 新增 FILTER_MEASUREMENT_KEYS(flow_ema_alpha)与 FILTER_READOUT_KEYS(中值窗口、低通截止/阶数/采样率/rate_auto)、filters_fingerprint()(把 Sensor.filters_state() 归一化成 {measurement, readout, preset, source}:非法值丢弃、来源没写的键不出现而不是补默认、空映射返回 {} 表示"无记录")、filter_mismatches()(按组判读差异)与 _filters_from_config / _flat_filters_from(扁平旧格式与 preset 展开的兼容解析,显式键优先);pipeline_from_sensor_json() 新增 filters_state= 关键字——传了生效值就用生效值,否则读文件的 filters 段并标注 source="sensor.json.filters",返回结构化 filters 键,删除原先会凭空补出 0.5/3/10 的扁平键与 enabled 覆盖逻辑;Calibration.to_dict() 写 meta.filters(同时保留扁平键供旧读者按名取用);parse() 把 filters 读回 pipeline(此前 to_dict 写进 meta 的滤波键在读回时被整段丢弃,指纹实际是"只写不读");check() 改用 filter_mismatches 分开报告——measurement 组差异进 warnings 且措辞含 not comparable,readout 组差异进新增字段 CheckResult.notes(只记录、不拦截);build(extra_meta=…) 把 filters 与扁平滤波键折进结构化组。tests/test_calibration.py 新增 11 项测试(分组、preset 展开与显式键优先、enabled:false 全关、非法值为空而非默认、写入-读回往返、旧扁平文档读回、生效值压过文件、四个预设逐档核对)。docs/filters.md 新增第 7 节。 - Why: 用户决策 B 要求"Sensor.filters_state() 作为唯一生效值出口,calibration.py 指纹与 session.json 都读它、不读配置文件,指纹分 measurement/readout 两组";动手时确认旧实现三个问题叠加:来源错(读文件、被 filters.enabled 覆盖、文件没写就补默认 0.5/3/10),写进 meta 的指纹在读回时被丢弃,check() 因此永远比不出滤波差异——审计链是断的,不是不精确。 - Verification: SDK 全量 pytest 335 passed / 15 skipped,唯一失败 test_vnormal_longrun_no_outlier_jumps 需要 1200 帧而 data/sample/s1_480p.mp4 只有 577 帧(环境/数据,与本改动无关);external-frame 模式逐预设构造真实 Sensor,filters_fingerprint(filters_state()) 得 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);写入→读回往返后 measurement/readout/preset 完整保留且 to_dict 幂等;旧扁平 meta 文档仍读回同一指纹;.repro/verify_cal_filters.py 50 项全绿。 - Not in this change: 未动 app(app 的 meta_from_sensor_json 只取 4 个键,不受影响);未升级 venv 内 SDK;examples/filters 的四个机器可读预设片段与 app 侧 wrapper/齿轮/CSV 17 列未动。 2026-09-14 10:43:58 +08:00
ykcheungab b956d071ea 滤波说明落到文档与 examples:三级位置/性质、四个预设、可追溯性清单 - What: 新增 docs/filters.md(json.load 不支持注释,故建议以"代码预设 + 文档 + 可复制配置"承载:三级滤波的作用位置与性质对照——flow_ema_alpha 在 compute_flow/HHD 之前、改变测量故两次录制不可比,中值与低通是读数滤波;取值规则:每级按值开关、非法值一律关、filters.enabled 仅作废弃别名、显式键优先于预设;FILTER_PRESETS 四个档位的延迟与用途表;configure()/filters_state() 用法;force_triplets() 两个 buffer 的语义与尖峰实测 10/999;可追溯性五条清单)与 examples/filters/README.md(off/low_latency/balanced/low_noise 四段可直接整块替换的 filters 配置、preset 等价写法、"从预设出发只改一个键"的写法、复制后用 filters_state() 核对生效值的片段)。 - Why: 用户提出"在 json 里加 comment 说明建议的滤波,或在 examples 里写",查证 configs.py 使用 json.load 且环境无 json5/commentjson,加注释会导致加载失败;因此把建议做成可执行预设并在文档与 examples 中给出可复制版本。 - Verification: 文档中的四个预设与 FILTER_PRESETS 逐值一致;示例片段与 sensors/*/sensor.json 的 filters 段结构一致;filters_state()/force_triplets() 的行为已用 external-frame 模式实测(预设解析结果与尖峰 10/999 对照)。 - Not in this change: 未生成四个独立的 sensor_<preset>.json 整机配置文件(README 里给的是整块 filters 段);calibration.py 指纹尚未改读 filters_state();venv 内 SDK 未升级;app 侧 wrapper/齿轮与 CSV 17 列未动。 2026-09-14 10:36:00 +08:00
ykcheungab 0beb36a1af 滤波预设与生效值出口:FILTER_PRESETS + filters_state() + force_triplets() - What: 新增 FILTER_PRESETS(off/low_latency/balanced/low_noise,数值取自 _perf 的延迟表,low_noise 含 10 Hz 低通)与 available_filter_presets() 供 UI 取用;配置可写 filters.preset 且显式键优先于预设值;新增 Sensor.filters_state()(preset + measurement 组 flow_ema_alpha/flow_ema_applied/hhd_downsample/dis_mode + readout 组中值与低通的窗口/截止/阶数/采样率/是否 auto/是否生效 + sample_rate_source + any_stage_applied + source="sensor.filters_state")作为唯一"生效值"出口;新增 Sensor.force_triplets() 一次返回 {filtered:{raw,newton}, unfiltered:{raw,newton}},并在 _apply_force_filter 中于链后、标定前保存 _force_filtered_raw(修正:此前滤波后的 raw 计数没有保存,force_triplets 的 filtered.raw 实际返回的是输入);reset() 清空未滤波缓存。 - Why: 用户要求配置自描述(A)并把"生效值"作为审计唯一依据(B),同时要保留滤波前数据以便"两个 buffer、两个单位都保留";顺带证实并修掉了 filtered.raw 指向输入这一标签错误。 - Verification: external-frame 模式逐预设构造真实 Sensor 打印 filters_state:off/low_latency/balanced/low_noise 分别为 (ema 1.0 off, median 1 off, lp 0.0 off) / (1.0 off, 3 on, 0.0 off) / (0.5 on, 3 on, 0.0 off) / (0.3 on, 3 on, 10.0 on);注入 10,10,10,999,10,10 观察力三元组,filtered.raw 在尖峰帧为 10.0、unfiltered.raw 为 999.0,证明中值生效且未滤波 buffer 完整;compileall 通过。 - Not in this change: calibration.py 指纹尚未改读 filters_state(),examples/filters 与 docs/filters.md 未加,venv 里的 SDK 未升级(app 行为不变,仍硬编码 10 Hz 低通),app 侧 wrapper/齿轮/CSV 17 列未动。 2026-09-14 10:35:51 +08:00
ykcheungab 1adb1c30f0 滤波配置自描述:去掉覆盖式总开关,每级按自己的值生效 - What: sensor.py 删除 filters.enabled 这个覆盖层——flow_ema_alpha / force_median_window / force_lowpass_cutoff_hz 现在各自按值生效(0<α<1 才滤、≤1 关中值、≤0 关低通),非法值一律回落到"关"而不再回落到"默认开"(原实现里非法取值的 except 分支会把 cutoff 变回 10 Hz);enabled 仅作废弃别名保留(显式 false = 三级全关)并在 verbose 时提示弃用,true 不再有覆盖作用;_filters_enabled 改为派生量(任一级在滤才为真)供旧调用方查询。三份 sensors/{box,mini,finger}/sensor.json 的 filters 段改为自描述且默认全关(flow_ema_alpha 1.0 / force_median_window 1 / force_lowpass_cutoff_hz 0.0 / order 2 / sample_rate "auto")。 - Why: 用户问"flow_ema_alpha 是不是受 filter_enabled 控制、这样合适吗",查证后确认三级都被同一总开关覆盖,且出厂配置写 enabled:false 却仍列着 0.5/3,导致文件说的与设备实际跑的不一致(审计/指纹会误报);同时 flow_ema_alpha 位于 HHD 之前、改变的是测量本身,与中值/低通这类读数滤波性质不同,不应被一个开关捆绑。 - Verification: 用 SDK 的 external-frame 模式逐个 bundle 构造真实 Sensor 并读取解析结果:box/mini/finger 均为 ema=1.0、median=1、cutoff=0.0、lowpass_on=False、any_stage=False;三份 JSON 解析通过;compileall 通过;SDK 内 _filters_enabled 仅剩派生定义一处引用。 - Not in this change: 未动采集路径与 read_info/force_triplets(第 2 步),未动 calibration.py 指纹(第 2 步),未升级 venv 里的 SDK(第 5 步)——因此 app 当前行为不变(它仍在运行时硬编码 lowpass_cutoff_hz=10,压过本配置),"默认关"要到 app 改用 FilterSettings.apply_to(sensor) 后才在界面体现。 2026-09-14 10:26:39 +08:00
ykcheungab 514874e2f4 added 5 sensors multi-stream support, 5 finger reach > 100 fps stable using .mp4 video 2026-09-13 12:18:27 +08:00
ykcheungab 0d5d8ff70d update filter, flow ema 0.5 + med 3 2026-09-12 19:36:36 +08:00
ykcheungab 5f78115429 fix pypi cannot check after upload 2026-09-12 17:43:40 +08:00
ykcheungab 488c8b0bc1 fixed testpypi upload 2026-09-12 17:34:46 +08:00
ykcheungab 94f38bc389 fix github release 2026-09-12 17:01:35 +08:00
ykcheungab 174bceabc4 automate publishing: release github + release pypi 2026-09-12 16:50:55 +08:00
ykcheungab 388f34ca85 faster linux build 2026-09-12 16:36:59 +08:00
ykcheungab 35af74f639 exclude docs from wheel; harden bundle item handling; single-source version 2026-09-12 16:19:34 +08:00
ykcheungab 57bef85e32 fix preflight 2026-09-12 16:00:40 +08:00
ykcheungab 9537cf91a1 added preflight check 2026-09-12 15:49:28 +08:00
zhangyijian 9c23ba3d50 Merge pull request 'Feature/cal json v2' (#21) from feature/cal_json_v2 into dev
Reviewed-on: #21
2026-09-10 21:48:55 +08:00
ykcheungab 96f4428230 added calibration data handling functions 2026-09-10 21:47:36 +08:00
ykcheungab 9cbba08d68 tuned sensitivity to max. by default 2026-09-03 15:23:51 +08:00
ykcheungab ad680b77dc 0.5.1 2026-09-02 21:47:51 +08:00
78 changed files with 11926 additions and 352 deletions
+5 -1
View File
@@ -10,6 +10,7 @@ __pycache__/
/build/dist/
/build/lib/
/build/pyinstaller_work/
/build/release/reports/
/build/*.spec
/dist/
/dist_obfuscated/
@@ -41,4 +42,7 @@ __pycache__/
# Legacy / scratch folders
/old_orisys/
/tools/calibration/
/tools/calibration/
# Scratch output of examples/ (multi_stream.py's default --out-dir)
/outputs/
+2
View File
@@ -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
View File
@@ -12,6 +12,7 @@ build/
README.md
common/ shared helpers (paths, pyproject, verify)
wheel/ SDK .whl (Nuitka + manylinux Docker)
release/ local release gate (preflight / audit)
gui/
common/ shared PyInstaller driver
ory_finger/ ORY-FINGER dashboard
@@ -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.
+17 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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, smallest first.
DEFAULT_VIDEO_CANDIDATES = (
Path("data") / "sample" / "s1_480p.mp4",
Path("data") / "sample" / "s1_720p.mp4",
Path("tests") / "idle_1min.mp4",
)
def default_verification_video(repo_root: Path) -> Path | None:
"""Resolve the clip used for the end-to-end runtime check, if any."""
env_video = os.getenv("ORISYS_VERIFY_VIDEO")
repo_root = Path(repo_root)
if env_video:
candidate = Path(env_video)
return candidate if candidate.is_absolute() else repo_root / candidate
for relative in DEFAULT_VIDEO_CANDIDATES:
candidate = repo_root / relative
if candidate.is_file():
return candidate
return None
def _venv_python(venv_dir: Path) -> Path:
if sys.platform == "win32":
@@ -45,18 +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
+501
View File
@@ -0,0 +1,501 @@
"""Static content and metadata validation for built orisys wheels.
Everything here is pure standard library: the wheel is opened as a zip and
inspected in place. No installation, no network, no hardware.
The checks encode the guarantees a Nuitka-compiled release must satisfy:
* the filename, ``METADATA`` and ``RECORD`` agree on name/version/tags;
* PyPI-visible metadata (``Requires-Python``, classifiers, project URLs,
license) is present instead of silently dropped by the hand-written
``setup.py`` template in :mod:`wheel.package`;
* no Python sources ship inside ``orisys/`` (IP containment) while the compiled
extension is present;
* every file declared by ``[[tool.orisys.bundle.items]]`` is present and
byte-identical to the source tree;
* no caches, build leftovers or scratch directories leak in.
"""
from __future__ import annotations
import base64
import hashlib
import re
import sys
import zipfile
from dataclasses import dataclass, field
from email.parser import Parser
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from common.pyproject_utils import bundle_items, extras_require, install_requires, project_metadata # noqa: E402
from wheel.layout import resolve_bundle_source_files # noqa: E402
from wheel.package import is_compliant_manylinux_wheel # noqa: E402
# PyPI's default per-project file limit is 100 MB; keep headroom.
MAX_WHEEL_BYTES = 90 * 1024 * 1024
_FILENAME_RE = re.compile(
r"^(?P<name>[^-]+)-(?P<version>[^-]+)-(?P<python>[^-]+)-(?P<abi>[^-]+)-(?P<platform>.+)\.whl$"
)
_COMPILED_EXT_RE = re.compile(r"^orisys/__init__\..+\.(?:pyd|so)$")
_FORBIDDEN_PREFIXES = (
"test/",
"tests/",
"data/",
"build/",
"dist/",
"sdk/",
".git",
)
_FORBIDDEN_SUFFIXES = (".pyc", ".pyo", ".c", ".pdb", ".map", ".spec")
@dataclass(frozen=True)
class Check:
name: str
ok: bool
detail: str = ""
def render(self) -> str:
return f"[{'ok' if self.ok else 'FAIL'}] {self.name}: {self.detail}"
@dataclass
class WheelCheckResult:
wheel: Path
version: str | None = None
name: str | None = None
platform_tag: str | None = None
entry_count: int = 0
uncompressed_bytes: int = 0
file_bytes: int = 0
checks: list[Check] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return all(check.ok for check in self.checks)
@property
def failures(self) -> list[Check]:
return [check for check in self.checks if not check.ok]
def add(self, name: str, ok: bool, detail: str = "") -> bool:
self.checks.append(Check(name=name, ok=ok, detail=detail))
return ok
def warn(self, message: str) -> None:
self.warnings.append(message)
def render(self) -> str:
lines = [
f"wheel: {self.wheel.name}",
f" version={self.version} platform={self.platform_tag} "
f"entries={self.entry_count} file_size={self.file_bytes} "
f"uncompressed={self.uncompressed_bytes}",
]
lines.extend(f" {check.render()}" for check in self.checks)
lines.extend(f" [warn] {message}" for message in self.warnings)
lines.append(f" RESULT: {'PASS' if self.ok else 'FAIL'}")
return "\n".join(lines)
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _requirement_name(spec: str) -> str:
return re.split(r"[<>=!~\[; ]", spec, maxsplit=1)[0].strip().lower().replace("_", "-")
def _metadata_message(zf: zipfile.ZipFile, result: WheelCheckResult):
candidates = [name for name in zf.namelist() if name.endswith(".dist-info/METADATA")]
if not candidates:
result.add("metadata-present", False, "no *.dist-info/METADATA entry in the wheel")
return None
raw = zf.read(candidates[0]).decode("utf-8", errors="replace")
message = Parser().parsestr(raw)
result.add("metadata-present", True, candidates[0])
return message
def _check_filename(result: WheelCheckResult, expected_version: str | None) -> None:
match = _FILENAME_RE.match(result.wheel.name)
if match is None:
result.add("filename", False, f"unparsable wheel filename: {result.wheel.name}")
return
result.name = match.group("name")
result.version = match.group("version")
result.platform_tag = match.group("platform")
problems = []
if result.name != "orisys":
problems.append(f"distribution name is {result.name!r}, expected 'orisys'")
if expected_version is not None and result.version != expected_version:
problems.append(f"filename version {result.version!r} != expected {expected_version!r}")
result.add("filename", not problems, "; ".join(problems) or result.wheel.name)
def _check_metadata(
result: WheelCheckResult,
message,
expected_version: str | None,
repo_root: Path | None,
) -> None:
if message is None:
return
result.add(
"metadata-name",
message.get("Name") == "orisys",
f"Name={message.get('Name')!r}",
)
declared = message.get("Version")
expected = expected_version or result.version
if expected is not None and declared != expected:
result.add(
"metadata-version",
False,
f"METADATA Version={declared!r} != expected {expected!r}",
)
else:
result.add("metadata-version", True, f"Version={declared!r}")
requires_python = (message.get("Requires-Python") or "").strip()
result.add(
"metadata-requires-python",
bool(requires_python),
f"Requires-Python={requires_python!r}" if requires_python else "Requires-Python is missing",
)
classifiers = message.get_all("Classifier") or []
result.add(
"metadata-classifiers",
bool(classifiers),
f"{len(classifiers)} classifier(s)" if classifiers else "no Classifier fields",
)
project_urls = message.get_all("Project-URL") or []
result.add(
"metadata-project-urls",
bool(project_urls),
f"{len(project_urls)} Project-URL(s)" if project_urls else "no Project-URL fields",
)
license_values = [
(message.get("License") or "").strip(),
(message.get("License-Expression") or "").strip(),
]
has_license = any(license_values) or any(
value.startswith("License ::") for value in classifiers
)
result.add(
"metadata-license",
has_license,
f"License={license_values[0]!r} License-Expression={license_values[1]!r}"
if has_license
else "no License / License-Expression / 'License ::' classifier",
)
dynamic = message.get_all("Dynamic") or []
result.add(
"metadata-no-dynamic-version",
"version" not in dynamic,
f"Dynamic={dynamic!r}" if dynamic else "no Dynamic fields",
)
if dynamic:
result.warn(f"METADATA still declares Dynamic fields: {dynamic!r}")
if repo_root is not None and (repo_root / "pyproject.toml").is_file():
requires_dist = message.get_all("Requires-Dist") or []
provided = {_requirement_name(entry) for entry in requires_dist}
missing_core = [
spec for spec in install_requires(repo_root) if _requirement_name(spec) not in provided
]
result.add(
"metadata-requires-dist",
not missing_core,
f"missing core Requires-Dist entries: {missing_core!r}"
if missing_core
else f"{len(requires_dist)} Requires-Dist entries",
)
extras = message.get_all("Provides-Extra") or []
missing_extras = sorted(set(extras_require(repo_root)) - set(extras))
result.add(
"metadata-extras",
not missing_extras,
f"missing Provides-Extra: {missing_extras!r}"
if missing_extras
else f"Provides-Extra={sorted(extras)!r}",
)
def _check_compiled_only(result: WheelCheckResult, names: list[str]) -> None:
compiled = [name for name in names if _COMPILED_EXT_RE.match(name)]
result.add(
"compiled-extension",
bool(compiled),
f"{compiled[0]}" if compiled else "no orisys/__init__.<abi>.pyd|.so entry",
)
# orisys/examples/** is shipped as package data on purpose; every other
# orisys/**/*.py means source code leaked past the Nuitka compile step.
sources = [
name
for name in names
if name.startswith("orisys/")
and name.endswith(".py")
and not name.startswith("orisys/examples/")
]
result.add(
"no-python-sources",
not sources,
f"python sources leaked into the wheel: {sources[:5]!r}"
if sources
else "compiled package ships no python sources (examples excluded by design)",
)
blend = [name for name in names if name.startswith("orisys_blend/")]
result.add(
"orisys-blend-present",
"orisys_blend/__init__.py" in blend and "orisys_blend/kernel.py" in blend,
f"{sorted(blend)!r}",
)
def _check_bundle_fidelity(
result: WheelCheckResult,
zf: zipfile.ZipFile,
names: set[str],
repo_root: Path | None,
strict: bool,
) -> None:
if repo_root is None or not (repo_root / "pyproject.toml").is_file():
result.warn("pyproject.toml not found; bundle fidelity checks skipped")
return
missing: list[str] = []
mismatched: list[str] = []
checked = 0
for item in bundle_items(repo_root):
target = str(item["target"]).replace("\\", "/")
source_dir = repo_root / item["source"]
for source_file in resolve_bundle_source_files(repo_root, item):
if item.get("preserve_tree", False):
relative = source_file.relative_to(source_dir).as_posix()
entry = f"orisys/{target}/{relative}"
else:
entry = f"orisys/{target}/{source_file.name}"
if entry not in names:
missing.append(entry)
continue
checked += 1
if _sha256(zf.read(entry)) != _sha256(source_file.read_bytes()):
mismatched.append(entry)
# Both entries here mean "the wheel does not match this revision of the
# source tree". Against a fresh build that is fatal; against an already
# released wheel it is expected, so ``strict`` (set by preflight, unset by
# audit) decides whether it fails or warns.
if missing:
detail = (
f"{len(missing)} declared file(s) absent from the wheel (added to the "
f"source tree after this build?): {missing[:5]!r}"
)
result.add("bundle-completeness", not strict, detail)
if not strict:
result.warn(detail)
else:
result.add(
"bundle-completeness", True, f"{checked} declared bundled file(s) present"
)
if mismatched:
detail = (
f"{len(mismatched)} of {checked} bundled file(s) differ from the source "
f"tree (wheel predates or postdates it): {mismatched[:5]!r}"
)
result.add("bundle-fidelity", not strict, detail)
if not strict:
result.warn(detail)
else:
result.add(
"bundle-fidelity",
True,
f"{checked} bundled file(s) byte-identical to source",
)
def _check_forbidden_entries(result: WheelCheckResult, names: list[str]) -> None:
offenders = []
for name in names:
lowered = name.lower()
if lowered.startswith(_FORBIDDEN_PREFIXES):
offenders.append(name)
elif lowered.endswith(_FORBIDDEN_SUFFIXES):
offenders.append(name)
elif "__pycache__" in lowered or ".egg-info" in lowered:
offenders.append(name)
result.add(
"no-forbidden-entries",
not offenders,
f"unexpected entries: {offenders[:5]!r}" if offenders else "no caches/leftovers/scratch paths",
)
def _check_archive_paths(result: WheelCheckResult, names: list[str]) -> None:
unsafe = [
name
for name in names
if name.startswith(("/", "\\")) or ".." in Path(name.replace("\\", "/")).parts
]
result.add(
"archive-paths",
not unsafe,
f"absolute or traversing entries: {unsafe[:5]!r}" if unsafe else "all entries are relative",
)
def _check_record(result: WheelCheckResult, zf: zipfile.ZipFile, names: list[str]) -> None:
records = [name for name in names if name.endswith(".dist-info/RECORD")]
if not records:
result.add("record", False, "no *.dist-info/RECORD entry")
return
bad_paths: list[str] = []
bad_hashes: list[str] = []
bad_sizes: list[str] = []
present = set(names)
for line in zf.read(records[0]).decode("utf-8").splitlines():
if not line.strip():
continue
parts = line.split(",")
while len(parts) < 3:
parts.append("")
path, hash_field, size_field = parts[0], parts[1], parts[2]
if path not in present:
bad_paths.append(path)
continue
if not hash_field.startswith("sha256="):
continue
expected = hash_field[len("sha256=") :]
actual = base64.urlsafe_b64encode(
hashlib.sha256(zf.read(path)).digest()
).rstrip(b"=").decode("ascii")
if actual != expected:
bad_hashes.append(path)
if size_field and size_field.isdigit():
if int(size_field) != len(zf.read(path)):
bad_sizes.append(path)
problems = []
if bad_paths:
problems.append(f"paths not in archive: {bad_paths[:5]!r}")
if bad_hashes:
problems.append(f"sha256 mismatch: {bad_hashes[:5]!r}")
if bad_sizes:
problems.append(f"size mismatch: {bad_sizes[:5]!r}")
result.add(
"record",
not problems,
"; ".join(problems) if problems else f"{records[0]} verifies",
)
def _check_platform_tag(result: WheelCheckResult) -> None:
tag = result.platform_tag
if tag is None:
return
if "win" in tag:
ok = tag == "win_amd64"
result.add("platform-tag", ok, f"{tag!r} (expected 'win_amd64')")
return
ok = is_compliant_manylinux_wheel(result.wheel)
result.add(
"platform-tag",
ok,
f"{tag!r}" if ok else f"{tag!r} is not a PyPI-accepted manylinux x86_64 tag",
)
def _check_size(result: WheelCheckResult, max_bytes: int) -> None:
result.add(
"size-limit",
result.file_bytes <= max_bytes,
f"{result.file_bytes} bytes (limit {max_bytes})",
)
def inspect_wheel(
wheel_path: Path,
*,
expected_version: str | None = None,
repo_root: Path | None = None,
max_bytes: int = MAX_WHEEL_BYTES,
strict_bundle: bool = True,
) -> WheelCheckResult:
"""Validate one built wheel without installing it.
``strict_bundle`` controls how the wheel's bundled files are compared with the
current source tree: ``True`` (gate a fresh build, used by
``release preflight``) fails on any missing or changed declared file;
``False`` (audit an already-released wheel) reports the same findings as
warnings.
"""
wheel_path = Path(wheel_path).resolve()
if repo_root is None:
repo_root = REPO_ROOT if (REPO_ROOT / "pyproject.toml").is_file() else None
else:
repo_root = Path(repo_root).resolve()
result = WheelCheckResult(wheel=wheel_path)
if not wheel_path.is_file():
result.add("wheel-exists", False, f"not a file: {wheel_path}")
return result
result.file_bytes = wheel_path.stat().st_size
result.add("wheel-exists", True, str(wheel_path))
_check_filename(result, expected_version)
_check_platform_tag(result)
_check_size(result, max_bytes)
try:
zf = zipfile.ZipFile(wheel_path)
except zipfile.BadZipFile as exc:
result.add("zip-integrity", False, f"not a valid zip: {exc}")
return result
with zf:
corrupt = zf.testzip()
result.add(
"zip-integrity",
corrupt is None,
"CRC check passed" if corrupt is None else f"corrupt entry: {corrupt}",
)
names = zf.namelist()
name_set = set(names)
result.entry_count = len(names)
result.uncompressed_bytes = sum(info.file_size for info in zf.infolist())
_check_archive_paths(result, names)
_check_forbidden_entries(result, names)
message = _metadata_message(zf, result)
_check_metadata(result, message, expected_version, repo_root)
_check_compiled_only(result, names)
_check_bundle_fidelity(result, zf, name_set, repo_root, strict_bundle)
_check_record(result, zf, names)
return result
+7
View File
@@ -0,0 +1,7 @@
"""Local release tooling: preflight gate and per-wheel audit.
Entry point is ``python build/run.py release <command>``; see
:mod:`release.preflight` for the gate steps and :mod:`release.cli` for the CLI.
Imports are intentionally not re-exported here so importing a submodule never
triggers the whole subsystem.
"""
+398
View File
@@ -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()
+416
View File
@@ -0,0 +1,416 @@
"""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. 0.5.2")
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. 0.5.2")
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())
+438
View File
@@ -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
+570
View File
@@ -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()
+158
View File
@@ -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,
)
+458
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
"""Structured preflight reports.
A preflight run writes both a machine-readable ``preflight.json`` (the contract a
future publish step will consume and re-verify) and a human ``preflight.txt``.
"""
from __future__ import annotations
import json
from pathlib import Path
REPORTS_RELPATH = Path("build") / "release" / "reports"
SCHEMA_VERSION = 1
def reports_root(repo_root: Path, override: Path | None = None) -> Path:
return Path(override) if override else Path(repo_root) / REPORTS_RELPATH
def render_text(payload: dict) -> str:
git = payload.get("git") or {}
interpreter = payload.get("interpreter") or {}
lines = [
f"orisys release preflight :: version {payload.get('version')}",
f" result: {'PASS' if payload.get('ok') else 'FAIL'}",
f" git: {git.get('branch')} @ {git.get('commit')} (dirty={git.get('dirty')})",
f" interpreter: {interpreter.get('version')} at {interpreter.get('executable')}",
f" duration: {payload.get('duration_s')}s",
"",
]
for step in payload.get("steps", []):
flag = "ok " if step.get("ok") else "FAIL"
lines.append(f"[{flag}] {step.get('name')} ({step.get('duration_s')}s)")
for detail_line in str(step.get("detail") or "").splitlines():
lines.append(f" {detail_line}")
lines.append("")
wheels = payload.get("wheels") or []
if wheels:
lines.append("wheels:")
for wheel in wheels:
lines.append(
f" {wheel.get('name')} {wheel.get('size')} bytes sha256={wheel.get('sha256')}"
)
lines.append("")
options = payload.get("options") or {}
waived = [
key
for key, value in options.items()
if value and key in {"skip_tests", "allow_dirty", "force", "no_runtime", "no_assets"}
]
if waived:
lines.append(f"waived guards: {', '.join(sorted(waived))}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def write_report(payload: dict, out_dir: Path) -> tuple[Path, Path]:
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
payload = {**payload, "schema": SCHEMA_VERSION}
json_path = out_dir / "preflight.json"
text_path = out_dir / "preflight.txt"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=False), encoding="utf-8")
text_path.write_text(render_text(payload), encoding="utf-8")
return json_path, text_path
+272
View File
@@ -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
View File
@@ -8,6 +8,8 @@ Usage (from repo root):
python build/run.py gui finger|mini|box
python build/run.py verify [wheel.whl]
python build/run.py verify --gui-dist [--product ory_finger]
python build/run.py release preflight [--version V]
python build/run.py release audit <wheel.whl>
See build/README.md for details.
"""
@@ -74,15 +76,23 @@ def _cmd_verify(argv: list[str]) -> int:
return verify_main(argv)
def _cmd_release(argv: list[str]) -> int:
from release.cli import main as release_main
return release_main(argv)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
print("Commands: wheel | wheel-win | manylinux | gui | verify")
print("Commands: wheel | wheel-win | manylinux | gui | verify | release")
print("Examples:")
print(" python build/run.py wheel --help")
print(" python build/run.py gui finger")
print(" python build/run.py manylinux -PythonVersions 3.10")
print(" python build/run.py release preflight --version <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
View File
@@ -30,7 +30,13 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repaired_wheel_dir, repo_path
from common.pyproject_utils import bundle_items, extras_require, get_version, install_requires
from common.pyproject_utils import (
bundle_items,
extras_require,
get_version,
install_requires,
project_metadata,
)
from common.verify.wheel import verify_built_wheel
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
from wheel.layout import (
@@ -41,6 +47,7 @@ from wheel.layout import (
from wheel.package import (
is_compliant_manylinux_wheel,
publish_wheel_to_sdk,
read_long_description,
repair_linux_wheel,
run_bdist_wheel,
write_wheel_setup,
@@ -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/ ---
+8
View File
@@ -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",
)
+44 -16
View File
@@ -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"]
+29 -1
View File
@@ -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
View File
@@ -19,6 +19,76 @@ _MANYLINUX_TAG_RE = re.compile(
r"(?:manylinux2014|manylinux_2_(?:17|24|27|28|31|34|35))_x86_64"
)
# Source of the PyPI project description (see read_long_description).
PYPI_README_RELPATH = Path("docs") / "pypi_readme.md"
def _license_text(project: dict) -> str | None:
"""Extract free-text license from either pyproject license form."""
declared = project.get("license")
if isinstance(declared, str):
return declared
if isinstance(declared, dict):
text = declared.get("text")
if isinstance(text, str):
return text
return None
def _person_strings(entries) -> list[str]:
"""Render PEP 621 author/maintainer tables as ``Name <email>`` strings."""
rendered: list[str] = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
name = (entry.get("name") or "").strip()
email = (entry.get("email") or "").strip()
if name and email:
rendered.append(f"{name} <{email}>")
elif name or email:
rendered.append(name or email)
return rendered
def metadata_kwargs(project: dict) -> dict:
"""Translate a pyproject ``[project]`` table into ``setup()`` keywords.
The Nuitka wheel is built by a generated ``setup.py`` outside the normal
setuptools pipeline, so every PyPI-visible field has to be passed through
explicitly. Omitting them is what left the 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,
+21 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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),则说明安装成功。
如果打印出版本号,则说明安装成功。
## 使用方法
### 运行示例程序
+399
View File
@@ -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 以上再试。
+63
View File
@@ -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
+3 -3
View File
@@ -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' 重置追踪器。")
+104
View File
@@ -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 节。
+7
View File
@@ -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"
}
+7
View File
@@ -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"
}
+7
View File
@@ -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"
}
-1
View File
@@ -7,7 +7,6 @@ ORY-MINI:单传感器基础流程
3. 按需计算接触区域
4. 读取并可视化结果
SDK version: 0.5.0
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+305
View File
@@ -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())
-1
View File
@@ -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
View File
@@ -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.
+4
View File
@@ -1,5 +1,9 @@
[pytest]
testpaths = tests
python_files = test_*.py
# Run against the checkout without requiring `pip install -e .`, so a stale
# orisys in site-packages cannot silently shadow src/orisys.
pythonpath = src
markers =
slow: long-running tests for release validation
hardware: requires a physical sensor/camera attached to the machine
+8 -1
View File
@@ -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,
+8 -1
View File
@@ -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,
+8 -1
View File
@@ -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
View File
@@ -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.2"
__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
+80
View File
@@ -1,4 +1,5 @@
import json
import shutil
from contextlib import ExitStack
from dataclasses import dataclass
from importlib.resources import as_file, files
@@ -166,6 +167,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.
+365
View File
@@ -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,
}
+70
View File
@@ -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())
+9
View File
@@ -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,
+16 -1
View File
@@ -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:
View File
+45
View File
@@ -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
View File
@@ -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,
+5 -1
View File
@@ -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)
+465
View File
@@ -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
+620 -69
View File
@@ -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,19 +215,122 @@ 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
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:
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_sensor_name:
print(
f"Force calibration: sensors/{self._force_cal_sensor_name}/force_calibration.json"
)
@@ -179,6 +345,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 +364,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 +394,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 +421,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 +439,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 +507,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 +570,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 +592,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 +599,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 +609,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 +632,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 +762,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):
flow = self.flow
def _apply_flow_ema(self, flow):
"""Temporally smooth the optical-flow field before HHD.
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 +880,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 +893,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 +907,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 +918,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 +1066,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 +1103,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 +1123,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 +1136,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 +1158,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 +1189,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 +1224,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 +1248,164 @@ 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 _init_force_calibration(self) -> None:
"""Load force calibration from ``cal_path`` or the product bundle."""
self.calibration = None
self._force_cal_sensor_name = 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
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
return
@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
return
if isinstance(cal, cal_mod.Calibration):
self.calibration = cal
return
self.calibration = cal_mod.load(cal, strict=True)
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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = (
+162
View File
@@ -0,0 +1,162 @@
"""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.
DEFAULT_VIDEO_CANDIDATES = (
Path("tests") / "idle_1min.mp4",
Path("data") / "sample" / "s1_480p.mp4",
Path("data") / "sample" / "s1_720p.mp4",
)
def _resolve_test_video() -> Path:
env_video = os.getenv("ORISYS_TEST_VIDEO")
if env_video:
raw = Path(env_video)
candidates = [
raw, # as provided (absolute or cwd-relative)
REPO_ROOT / raw, # repo-relative
]
# Common typo fallback: "test/..." -> "tests/..."
if env_video.startswith("test/") or env_video.startswith("test\\"):
fixed = Path("tests", *Path(env_video).parts[1:])
candidates.append(REPO_ROOT / fixed)
for candidate in candidates:
if candidate.exists():
return candidate
return raw
for relative in DEFAULT_VIDEO_CANDIDATES:
candidate = REPO_ROOT / relative
if candidate.is_file():
return candidate
# Keep the reported path informative when nothing is available; the
# ``test_video_path`` fixture turns this into a skip.
return REPO_ROOT / DEFAULT_VIDEO_CANDIDATES[-1]
@pytest.fixture(scope="session")
def test_video_path() -> Path:
path = _resolve_test_video()
if not path.exists():
pytest.skip(
"Test video not found. Set ORISYS_TEST_VIDEO to a valid video path."
)
return path
@pytest.fixture(scope="session")
def test_config_name() -> str:
requested = os.getenv("ORISYS_TEST_CONFIG")
def _is_usable_config(name: str) -> bool:
cfg = orisys_configs.load_config(name)
return "box_size" in cfg.get("stitcher", {})
if requested:
if not _is_usable_config(requested):
raise RuntimeError(
f"ORISYS_TEST_CONFIG='{requested}' is missing required key "
"'stitcher.box_size'. Use a config like 'ddjx01' or provide a full path."
)
return requested
# Auto-pick a stable config for tests (avoid prototype variants missing keys).
for name in orisys_configs.list_config():
try:
if _is_usable_config(name):
return name
except Exception:
continue
raise RuntimeError(
"No usable config found for tests (missing 'stitcher.box_size'). "
"Set ORISYS_TEST_CONFIG to a valid config JSON/path."
)
@pytest.fixture(scope="session")
def available_backends() -> list[str]:
backends = ["cpu"]
if check_cupy_available():
backends.append("cupy")
if check_opencl_available():
backends.append("opencl")
return backends
def make_sensor(
video_path: Path,
config_name: str,
backend: str = "cpu",
verbose: bool = False,
) -> Sensor:
use_cuda = backend in ("cupy", "opencl")
return Sensor(
vid_src=str(video_path),
cuda=use_cuda,
isstitch=True,
verbose=verbose,
config_name=config_name,
backend=backend,
)
@pytest.fixture
def sensor_factory(test_video_path, test_config_name):
def _factory(backend: str = "cpu", verbose: bool = False):
return make_sensor(
test_video_path,
config_name=test_config_name,
backend=backend,
verbose=verbose,
)
return _factory
-1
View File
@@ -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
+430
View File
@@ -0,0 +1,430 @@
"""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 == 1
assert cal.convert(10000.0, 36000.0, -36000.0) == pytest.approx((0.377, 1.19, -1.19))
# ------------------------------------------------------- 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)
+116
View File
@@ -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))
+281
View File
@@ -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"))
+48
View File
@@ -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)
+117
View File
@@ -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
+142
View File
@@ -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."
)
+8 -3
View File
@@ -70,17 +70,22 @@ def test_sensor_detect_motion_ignores_invalid_region():
backend="cpu",
)
try:
# get_img() resizes the frame to the config's camera.resolution_stitch
# and stores valid_mask at that same resolution, so the flow buffer must
# be sized from sensor.valid_mask rather than from the raw frame.
frame = np.zeros((728, 672, 3), dtype=np.uint8)
mask = np.zeros((728, 672), dtype=bool)
mask[180:540, 160:512] = True
sensor.get_img(frame=frame, already_processed=True, valid_mask=mask)
sensor.flow = np.zeros((728, 672, 2), dtype=np.float32)
sensor.flow[~mask] = 10.0
flow_shape = sensor.valid_mask.shape
assert sensor.valid_mask.any() and not sensor.valid_mask.all()
sensor.flow = np.zeros((*flow_shape, 2), dtype=np.float32)
sensor.flow[~sensor.valid_mask] = 10.0
assert not sensor.detect_motion(threshold=1.0)
sensor.flow[:] = 0.0
sensor.flow[mask] = 10.0
sensor.flow[sensor.valid_mask] = 10.0
assert sensor.detect_motion(threshold=1.0)
finally:
sensor.disconnect()
+72
View File
@@ -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))
+329
View File
@@ -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
View File
@@ -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
+387
View File
@@ -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
+624
View File
@@ -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/")
+405
View File
@@ -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
+3 -2
View File
@@ -224,12 +224,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(
+6 -6
View File
@@ -19,25 +19,25 @@ def wheel_package():
def test_compliant_manylinux_tags(wheel_package):
assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.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")
)
+378
View File
@@ -0,0 +1,378 @@
"""Tests for :mod:`common.verify.wheel_contents`.
Every check is exercised in both directions: a well-formed synthetic wheel must
pass, and each specific defect must fail with the check named in the report.
Synthetic wheels keep the tests fast and keep them independent of whatever
happens to sit in ``SDK/``.
"""
from __future__ import annotations
import base64
import hashlib
import sys
import zipfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
BUILD_ROOT = REPO_ROOT / "build"
if str(BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(BUILD_ROOT))
from common.verify import wheel_contents # noqa: E402
from common.verify.wheel_contents import inspect_wheel # noqa: E402
VERSION = "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."
)
+172
View File
@@ -0,0 +1,172 @@
"""Tests for build/wheel/package.py metadata plumbing.
The Nuitka wheel is built from a generated ``setup.py``, which is exactly where
the 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."
)
+16 -16
View File
@@ -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
View File
@@ -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}")