exclude docs from wheel; harden bundle item handling; single-source version

This commit is contained in:
ykcheungab
2026-09-12 16:19:34 +08:00
parent 57bef85e32
commit 35af74f639
24 changed files with 508 additions and 157 deletions
+2
View File
@@ -2,6 +2,8 @@
include pyproject.toml include pyproject.toml
include setup.py include setup.py
include README.md 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 config *.json
recursive-include sensors * recursive-include sensors *
recursive-include build/common *.py recursive-include build/common *.py
+8 -7
View File
@@ -48,16 +48,17 @@ Local only: never publishes, and makes no network call unless `--check-pypi` is
passed. See `docs/publishing.md` for the full flow. passed. See `docs/publishing.md` for the full flow.
```bash ```bash
python build/run.py release preflight --version 0.5.2 python build/run.py release preflight --version <version>
python build/run.py release preflight --all-wheel-pythons --check-pypi python build/run.py release preflight --all-wheel-pythons --check-pypi
python build/run.py release audit SDK\0.5.2\orisys-0.5.2-cp312-cp312-win_amd64.whl python build/run.py release audit SDK\<version>\orisys-<version>-cp312-cp312-win_amd64.whl
``` ```
`preflight` runs: repo state → version consistency → duplicate guard (fails only `preflight` runs: repo state → version consistency → version-string scan →
when the version is already released: a local git tag, or PyPI with duplicate guard (fails only when the version is already released: a local git
`--check-pypi`) → pytest → bundle precheck → per-wheel static content/metadata tag, or PyPI with `--check-pypi`) → pytest → bundle precheck → per-wheel static
assertions → isolated runtime verification (bundled assets + real frames, no content/metadata assertions → isolated runtime verification in one venv per wheel
camera). A populated `SDK/<version>/` is the expected input, so build first: (imports + bundled assets + real frames, no camera). A populated `SDK/<version>/`
is the expected input, so build first:
```bash ```bash
python build/run.py wheel-win python build/run.py wheel-win
+2
View File
@@ -3,6 +3,7 @@
from common.verify.gui import GUI_PRODUCTS, verify_gui_dist from common.verify.gui import GUI_PRODUCTS, verify_gui_dist
from common.verify.wheel import ( from common.verify.wheel import (
verify_built_wheel, verify_built_wheel,
verify_wheel,
verify_wheel_assets, verify_wheel_assets,
verify_wheel_runtime, verify_wheel_runtime,
) )
@@ -14,6 +15,7 @@ __all__ = [
"inspect_wheel", "inspect_wheel",
"verify_built_wheel", "verify_built_wheel",
"verify_gui_dist", "verify_gui_dist",
"verify_wheel",
"verify_wheel_assets", "verify_wheel_assets",
"verify_wheel_runtime", "verify_wheel_runtime",
] ]
+23 -1
View File
@@ -16,6 +16,7 @@ from common.verify import ( # noqa: E402
inspect_wheel, inspect_wheel,
verify_built_wheel, verify_built_wheel,
verify_gui_dist, verify_gui_dist,
verify_wheel,
verify_wheel_assets, verify_wheel_assets,
verify_wheel_runtime, verify_wheel_runtime,
) )
@@ -79,7 +80,28 @@ def main(argv: list[str] | None = None) -> int:
if not result.ok: if not result.ok:
exit_code = 1 exit_code = 1
if args.assets or args.runtime: 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: try:
print(verify_wheel_assets(args.wheel)) print(verify_wheel_assets(args.wheel))
except Exception as exc: except Exception as exc:
+89 -34
View File
@@ -20,6 +20,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import venv import venv
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
# Public SDK surface that must import from a released wheel. # Public SDK surface that must import from a released wheel.
@@ -302,25 +303,10 @@ class WheelVerificationError(RuntimeError):
""" """
def _run_in_fresh_venv( def _run_script(
wheel_path: Path, python: Path, script: str, args: list[str] | None = None, *, env: dict
script: str,
args: list[str] | None = None,
*,
python_executable: Path | None = None,
offline: bool = False,
prefix: str = "orisys-wheel-verify-",
) -> str: ) -> str:
wheel_path = wheel_path.resolve() """Run a verification script in an already-prepared venv."""
env = _hermetic_env()
with tempfile.TemporaryDirectory(prefix=prefix) as tmp:
venv_dir = Path(tmp) / "venv"
python = _create_venv(
venv_dir,
python_executable=python_executable,
system_site_packages=offline,
)
_install_wheel(python, wheel_path, offline=offline, env=env)
completed = subprocess.run( completed = subprocess.run(
[str(python), "-c", script, *(args or [])], [str(python), "-c", script, *(args or [])],
capture_output=True, capture_output=True,
@@ -341,21 +327,51 @@ def _run_in_fresh_venv(
return completed.stdout.strip() 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,
)
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: 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.""" """Install the wheel in a temporary venv and smoke-test public imports."""
_run_in_fresh_venv( with _installed_venv(wheel_path, python_executable=python_executable) as (python, env):
wheel_path, _SMOKE_IMPORT_SCRIPT, python_executable=python_executable _run_script(python, _SMOKE_IMPORT_SCRIPT, env=env)
)
def verify_wheel_assets(wheel_path: Path, *, python_executable: Path | None = None) -> str: def verify_wheel_assets(wheel_path: Path, *, python_executable: Path | None = None) -> str:
"""Assert the wheel carries loadable bundled configs and sensor assets.""" """Assert the wheel carries loadable bundled configs and sensor assets."""
return _run_in_fresh_venv( with _installed_venv(wheel_path, python_executable=python_executable) as (python, env):
wheel_path, return _run_script(python, _ASSET_SCRIPT, env=env)
_ASSET_SCRIPT,
python_executable=python_executable,
prefix="orisys-wheel-assets-",
)
def verify_wheel_runtime( def verify_wheel_runtime(
@@ -372,20 +388,59 @@ def verify_wheel_runtime(
This is the only check that proves the compiled extension plus the bundled This is the only check that proves the compiled extension plus the bundled
sensor assets actually produce finite flow/force output end to end. 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() video = Path(video).resolve()
if not video.is_file(): if not video.is_file():
raise FileNotFoundError(f"verification video not found: {video}") raise FileNotFoundError(f"verification video not found: {video}")
if frames < 1: if frames < 1:
raise ValueError("frames must be >= 1") raise ValueError("frames must be >= 1")
output = _run_in_fresh_venv( print(f" processing {frames} frame(s) with config {config_name!r} ...", flush=True)
wheel_path, output = _run_script(
_RUNTIME_SCRIPT, python, _RUNTIME_SCRIPT, [str(video), config_name, str(frames)], env=env
[str(video), config_name, str(frames)],
python_executable=python_executable,
offline=offline,
prefix="orisys-wheel-runtime-",
) )
import json import json
return json.loads(output.splitlines()[-1]) 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
+26 -1
View File
@@ -14,6 +14,7 @@ from common.paths import REPO_ROOT # noqa: E402
from common.verify.wheel import ( # noqa: E402 from common.verify.wheel import ( # noqa: E402
DEFAULT_RUNTIME_CONFIG, DEFAULT_RUNTIME_CONFIG,
default_verification_video, default_verification_video,
verify_wheel,
verify_wheel_assets, verify_wheel_assets,
verify_wheel_runtime, verify_wheel_runtime,
) )
@@ -116,7 +117,31 @@ def _cmd_audit(argv: list[str]) -> int:
if not result.ok: if not result.ok:
exit_code = 1 exit_code = 1
if args.assets or args.runtime: 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: try:
print(verify_wheel_assets(wheel, python_executable=args.python)) print(verify_wheel_assets(wheel, python_executable=args.python))
except Exception as exc: except Exception as exc:
+49 -16
View File
@@ -8,12 +8,13 @@ Steps, in order:
1. ``repo-state`` - git repository, clean working tree, recorded commit 1. ``repo-state`` - git repository, clean working tree, recorded commit
2. ``version-consistency`` - __version__ == pyproject dynamic attr == wheel names 2. ``version-consistency`` - __version__ == pyproject dynamic attr == wheel names
3. ``duplicate-guard`` - SDK/<version>/ already populated; optionally PyPI 3. ``version-strings`` - no stale version literal left in tracked prose
4. ``tests`` - pytest (slow/hardware markers excluded) 4. ``duplicate-guard`` - already-released version (git tag, optionally PyPI)
5. ``bundle-precheck`` - every bundle item resolves; required assets present 5. ``tests`` - pytest (slow/hardware markers excluded)
6. ``wheel-inspection`` - static metadata/content assertions per wheel 6. ``bundle-precheck`` - every bundle item resolves; required assets present
7. ``wheel-runtime`` - isolated venv: bundled assets load and real frames 7. ``wheel-inspection`` - static metadata/content assertions per wheel
flow through the public pipeline 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}``. Output lands in ``build/release/reports/<version>/preflight.{json,txt}``.
""" """
@@ -39,8 +40,7 @@ from common.pyproject_utils import bundle_items, get_version # noqa: E402
from common.verify.wheel import ( # noqa: E402 from common.verify.wheel import ( # noqa: E402
DEFAULT_RUNTIME_CONFIG, DEFAULT_RUNTIME_CONFIG,
default_verification_video, default_verification_video,
verify_wheel_assets, verify_wheel,
verify_wheel_runtime,
) )
from common.verify.wheel_contents import inspect_wheel # noqa: E402 from common.verify.wheel_contents import inspect_wheel # noqa: E402
from release import report as report_mod # noqa: E402 from release import report as report_mod # noqa: E402
@@ -108,7 +108,9 @@ class Preflight:
self.version = get_version(self.repo_root) self.version = get_version(self.repo_root)
self.version_source = "pyproject.toml dynamic attr" self.version_source = "pyproject.toml dynamic attr"
else: else:
self.version = "0.0.0" # 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.version_source = "unresolved"
self.report_dir = report_mod.reports_root(self.repo_root, options.report_dir) / self.version self.report_dir = report_mod.reports_root(self.repo_root, options.report_dir) / self.version
@@ -270,6 +272,34 @@ class Preflight:
detail += "\n" + "\n".join(problems) detail += "\n" + "\n".join(problems)
return Step("duplicate-guard", not problems, detail) 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: def _tests(self) -> Step:
if self.options.skip_tests: if self.options.skip_tests:
return Step( return Step(
@@ -439,17 +469,16 @@ class Preflight:
for tag, wheel, executable in targets: for tag, wheel, executable in targets:
label = f"{tag} {wheel.name}" label = f"{tag} {wheel.name}"
try: try:
if self.options.no_assets: # One venv per wheel: it runs the import smoke test, the bundled
assets_out = "asset check skipped (--no-assets)" # asset check and the frame pipeline together.
else: record = verify_wheel(
assets_out = verify_wheel_assets(wheel, python_executable=executable)
runtime_out = verify_wheel_runtime(
wheel, wheel,
video=video, video=video,
config_name=self.options.config_name, config_name=self.options.config_name,
frames=self.options.frames, frames=self.options.frames,
python_executable=executable, python_executable=executable,
offline=self.options.offline, offline=self.options.offline,
check_assets=not self.options.no_assets,
) )
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
problems.append(f"{label}: command failed ({exc})") problems.append(f"{label}: command failed ({exc})")
@@ -458,8 +487,11 @@ class Preflight:
problems.append(f"{label}: {type(exc).__name__}: {exc}") problems.append(f"{label}: {type(exc).__name__}: {exc}")
continue continue
lines.append(f"{label}: {assets_out} | frames={runtime_out['frames']} flow={runtime_out['flow_shape']}") lines.append(
self.runtime_records.append({"tag": tag, "wheel": wheel.name, **runtime_out}) f"{label}: {record['assets']} | frames={record['frames']} "
f"flow={record['flow_shape']}"
)
self.runtime_records.append({"tag": tag, **record})
detail = "\n".join(lines) detail = "\n".join(lines)
if problems: if problems:
@@ -480,6 +512,7 @@ class Preflight:
for step_fn in ( for step_fn in (
self._repo_state, self._repo_state,
self._version_consistency, self._version_consistency,
self._version_strings,
self._duplicate_guard, self._duplicate_guard,
self._tests, self._tests,
self._bundle_precheck, self._bundle_precheck,
+91
View File
@@ -143,6 +143,97 @@ def released_tags_for(version: str, tags: list[str]) -> list[str]:
return sorted({version, f"v{version}"} & set(tags)) 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: def conda_envs_root() -> Path | None:
"""Locate the conda envs directory the same way build/wheel/win.bat does.""" """Locate the conda envs directory the same way build/wheel/win.bat does."""
override = os.getenv("CONDA_ENVS_ROOT") override = os.getenv("CONDA_ENVS_ROOT")
+2 -2
View File
@@ -91,8 +91,8 @@ def main(argv: list[str] | None = None) -> int:
print(" python build/run.py wheel --help") print(" python build/run.py wheel --help")
print(" python build/run.py gui finger") print(" python build/run.py gui finger")
print(" python build/run.py manylinux -PythonVersions 3.10") print(" python build/run.py manylinux -PythonVersions 3.10")
print(" python build/run.py release preflight --version 0.5.2") print(" python build/run.py release preflight --version <version>")
print(" python build/run.py release audit SDK\\0.5.2\\orisys-0.5.2-cp312-cp312-win_amd64.whl") print(" python build/run.py release audit SDK\\<version>\\orisys-<version>-cp312-cp312-win_amd64.whl")
return 0 return 0
command, rest = argv[0], argv[1:] command, rest = argv[0], argv[1:]
+6 -4
View File
@@ -118,10 +118,12 @@ def build(*, target: str = "internal", verify: bool | None = None) -> Path:
# Bundle rules live in pyproject.toml → [[tool.orisys.bundle.items]] # Bundle rules live in pyproject.toml → [[tool.orisys.bundle.items]]
bundle = bundle_items(REPO_ROOT) bundle = bundle_items(REPO_ROOT)
copied_counts = copy_bundle_items(REPO_ROOT, pkg_dir, bundle) copied_counts = copy_bundle_items(REPO_ROOT, pkg_dir, bundle)
print(f"Copied {copied_counts['configs']} JSON config files") for item in bundle:
print(f"Copied {copied_counts['examples']} example files to orisys/examples/") destination = f"orisys/{item['target']}/"
print(f"Copied {copied_counts['docs']} doc files to orisys/docs/") print(
print(f"Copied {copied_counts['sensors']} sensor bundle files to orisys/sensors/") f"Copied {copied_counts.get(item['name'], 0)} file(s) "
f"for bundle item {item['name']!r} to {destination}"
)
write_wheel_setup( write_wheel_setup(
NUITKA_WHEEL_DIR, NUITKA_WHEEL_DIR,
+2 -2
View File
@@ -55,8 +55,8 @@ def metadata_kwargs(project: dict) -> dict:
The Nuitka wheel is built by a generated ``setup.py`` outside the normal 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 setuptools pipeline, so every PyPI-visible field has to be passed through
explicitly. Omitting them is what left the 0.5.2 release with no explicitly. Omitting them is what left the early PyPI releases with no
``Requires-Python``, classifiers, project URLs or license on PyPI. ``Requires-Python``, classifiers, project URLs or license on the project page.
""" """
authors = _person_strings(project.get("authors")) authors = _person_strings(project.get("authors"))
maintainers = _person_strings(project.get("maintainers")) maintainers = _person_strings(project.get("maintainers"))
+4 -6
View File
@@ -5,8 +5,6 @@ description: 安装并运行 Orisys SDK 的基础步骤
# 快速开始 # 快速开始
版本:0.5.2
## 简介 ## 简介
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。 本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
支持操作系统:Windows、Ubuntu 支持操作系统:Windows、Ubuntu
@@ -84,9 +82,9 @@ pip install --upgrade orisys
### 使用 .whl 文件安装(离线或指定版本) ### 使用 .whl 文件安装(离线或指定版本)
从官方渠道获取 .whl 文件,文件名格式通常为: 从官方渠道获取 .whl 文件,文件名格式通常为:
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
例如:orisys-0.5.2-cp310-cp310-win_amd64.whl 表示: 例如 `orisys-<版本>-cp310-cp310-win_amd64.whl` 表示:
版本:0.5.2 版本:`<版本>`(以实际下载的文件名为准,可用 `python -c "import orisys; print(orisys.__version__)"` 确认)
Python 版本:3.10(cp310) Python 版本:3.10(cp310)
操作系统:Windows(win) 操作系统:Windows(win)
硬件架构:64位(amd64) 硬件架构:64位(amd64)
@@ -97,7 +95,7 @@ Python 版本:3.10(cp310)
```bash ```bash
# 请将文件名替换为您实际下载的文件名 # 请将文件名替换为您实际下载的文件名
pip install orisys-0.5.2-cp310-cp310-win_amd64.whl pip install orisys-<版本>-cp310-cp310-win_amd64.whl
``` ```
### 安装 CuPy(可选) ### 安装 CuPy(可选)
@@ -139,7 +137,7 @@ python
import orisys import orisys
print(orisys.__version__) print(orisys.__version__)
``` ```
如果输出版本号(例如 0.5.2),则说明安装成功。 如果打印出版本号,则说明安装成功。
## 使用方法 ## 使用方法
### 运行示例程序 ### 运行示例程序
+49 -9
View File
@@ -46,20 +46,22 @@ python build/run.py wheel-manylinux -PythonVersions 3.10
## 2. 发布前门禁:`release preflight` ## 2. 发布前门禁:`release preflight`
```powershell ```powershell
python build/run.py release preflight --version 0.5.2 # --version 可省略:默认取 pyproject 的 dynamic version(即 src/orisys/__init__.py 里的 __version__)
python build/run.py release preflight
``` ```
按顺序执行 7 个步骤,任一步失败即整体失败(`--keep-going` 可改为收集全部失败): 按顺序执行 8 个步骤,任一步失败即整体失败(`--keep-going` 可改为收集全部失败):
| 步骤 | 检查内容 | | 步骤 | 检查内容 |
| --- | --- | | --- | --- |
| `repo-state` | 是 git 仓库、工作树干净、记录 HEAD(`--allow-dirty` 可豁免,用于提交前预检) | | `repo-state` | 是 git 仓库、工作树干净、记录 HEAD(`--allow-dirty` 可豁免,用于提交前预检) |
| `version-consistency` | `src/orisys/__init__.py::__version__` == pyproject 的 dynamic attr == `SDK/<version>/` 中 wheel 文件名里的版本 | | `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 的正常输入,不是错误 | | `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) | | `tests` | `pytest -m "not slow and not hardware"`(`--longrun` 时包含 slow) |
| `bundle-precheck` | 每个 `[[tool.orisys.bundle.items]]` 至少解析出 1 个文件;finger 必需资产存在 | | `bundle-precheck` | 每个 `[[tool.orisys.bundle.items]]` 至少解析出 1 个文件;finger 必需资产存在 |
| `wheel-inspection` | 逐个 wheel 做静态断言:文件名/版本/METADATA 字段/RECORD 哈希/压缩包路径/体积上限/无 pyc 与缓存/无 `orisys/**/*.py` 源码泄漏/与源码树逐文件字节一致 | | `wheel-inspection` | 逐个 wheel 做静态断言:文件名/版本/METADATA 字段/RECORD 哈希/压缩包路径/体积上限/无 pyc 与缓存/无 `orisys/**/*.py` 源码泄漏/与源码树逐文件字节一致 |
| `wheel-runtime` | 在一次性 venv 中安装该 wheel,断言内置 configs/sensors 资产可加载,并对真实视频跑 40 帧公开流水线(无需相机、无 GUI) | | `wheel-runtime` | 每个 wheel **一个** venv,在里面依次做:导入冒烟 → 内置 configs/sensors 资产可加载 → 对真实视频跑 40 帧公开流水线(无需相机、无 GUI) |
正常顺序是**先构建、再 preflight**,不需要额外开关: 正常顺序是**先构建、再 preflight**,不需要额外开关:
@@ -98,10 +100,10 @@ python build/run.py release preflight --all-wheel-pythons --check-pypi
对单个 wheel 做静态断言(+ 可选运行时校验),不需要版本上下文。适合体检历史产物: 对单个 wheel 做静态断言(+ 可选运行时校验),不需要版本上下文。适合体检历史产物:
```powershell ```powershell
python build/run.py release audit SDK\0.5.2\orisys-0.5.2-cp312-cp312-win_amd64.whl python build/run.py release audit SDK\<version>\orisys-<version>-cp312-cp312-win_amd64.whl
python build/run.py release audit dist\repaired\orisys-0.5.2-cp310-cp310-win_amd64.whl --strict-bundle python build/run.py release audit 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 python build/run.py release audit <wheel> --assets --runtime --video data/sample/s1_480p.mp4
``` ```
@@ -129,8 +131,8 @@ python build/run.py verify --gui-dist --product ory_finger
4. PyPI:先 TestPyPI 再正式仓库。 4. PyPI:先 TestPyPI 再正式仓库。
```powershell ```powershell
python -m twine upload --repository testpypi SDK/0.5.2/*.whl python -m twine upload --repository testpypi SDK/<version>/*.whl
python -m twine upload SDK/0.5.2/*.whl python -m twine upload SDK/<version>/*.whl
``` ```
**不要把 API token 写进仓库**。`release preflight --check-pypi` 只做只读查询,不需要 token。 **不要把 API token 写进仓库**。`release preflight --check-pypi` 只做只读查询,不需要 token。
@@ -163,7 +165,7 @@ python -m twine upload SDK/0.5.2/*.whl
--- ---
## 5. 两个必须记住的坑 ## 5. 三个必须记住的坑
### 5.1 Nuitka 编译包的 `__file__` 是假的 ### 5.1 Nuitka 编译包的 `__file__` 是假的
@@ -194,3 +196,41 @@ root = distribution("orisys").locate_file("orisys") # site-packages/orisys
处置:在构建环境里执行 `pip uninstall -y orisys`,或改用不带 `--offline` 的完全隔离 venv(会联网安装依赖)。 处置:在构建环境里执行 `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/` 都塞进去。
+1 -1
View File
@@ -2,7 +2,7 @@
叠动科技视触觉传感器配套的 Python SDK,提供图像采集、拼接、稠密光流、Helmholtz-Hodge 分解、接触与深度估计,以及可运行的示例。 叠动科技视触觉传感器配套的 Python SDK,提供图像采集、拼接、稠密光流、Helmholtz-Hodge 分解、接触与深度估计,以及可运行的示例。
本文件是 PyPI 项目描述(`long_description`)的源文件。它按构建配置 `PYPI_README_RELPATH` 打包进 wheel,因此**不要**在这里写内部仓库布局、构建流水线或发布步骤——那些内容属于仓库内的 `README.md` 与 `docs/publishing.md`。 本文件是 PyPI 项目描述(`long_description`)的源文件:构建时由 `docs/publishing.md` 所述流程从**检出目录**读取(`build/wheel/package.py::PYPI_README_RELPATH`),**不会**被打进 wheel。因此不要在这里写内部仓库布局、构建流水线或发布步骤——那些内容属于仓库内的 `README.md` 与 `docs/publishing.md`。
```bash ```bash
python -m pip install orisys python -m pip install orisys
-1
View File
@@ -7,7 +7,6 @@ ORY-BOX:单传感器基础流程
3. 按需计算接触区域 3. 按需计算接触区域
4. 读取并可视化结果 4. 读取并可视化结果
SDK version: 0.5.2
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
-1
View File
@@ -7,7 +7,6 @@ ORY-MINI:单传感器基础流程
3. 按需计算接触区域 3. 按需计算接触区域
4. 读取并可视化结果 4. 读取并可视化结果
SDK version: 0.5.2
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
-1
View File
@@ -18,7 +18,6 @@
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support() - Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
- 每个传感器进程都会占用独立的 GPU 显存 - 每个传感器进程都会占用独立的 GPU 显存
SDK version: 0.5.2
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+8 -5
View File
@@ -99,9 +99,12 @@ target = "examples"
patterns = ["*.py", "README.md", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"] patterns = ["*.py", "README.md", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"]
preserve_tree = true preserve_tree = true
[[tool.orisys.bundle.items]] # NOTE: docs/ is deliberately NOT bundled into the wheel.
name = "docs" # Documentation ships with the GitHub repository / PyPI project page only:
source = "docs" # - docs/publishing.md is an internal process document
target = "docs" # - docs/pypi_readme.md is read from the checkout at build time as the PyPI
patterns = ["*.md", "*.pdf"] # long description (build/wheel/package.py::PYPI_README_RELPATH), never shipped
# - keeping docs out means editing a doc no longer invalidates built wheels
# Should an offline API reference be needed later, add a narrower item that ships
# only docs/api.md.
-1
View File
@@ -8,7 +8,6 @@ Flow:
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True) 4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
5. Compute deformation/contact and visualize 5. Compute deformation/contact and visualize
SDK version: 0.5.2
""" """
from __future__ import annotations from __future__ import annotations
+15 -2
View File
@@ -28,14 +28,25 @@ def test_pyproject_dependencies(packaging_utils):
def test_pyproject_dynamic_version(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 import sys
build_dir = REPO_ROOT / "build" build_dir = REPO_ROOT / "build"
if str(build_dir) not in sys.path: if str(build_dir) not in sys.path:
sys.path.insert(0, str(build_dir)) sys.path.insert(0, str(build_dir))
from common.pyproject_utils import get_version from common.pyproject_utils import get_version
from release.version import dynamic_version_attr
assert get_version(REPO_ROOT) == "0.5.2" 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): def test_pyproject_bundle_manifest(packaging_utils):
@@ -48,7 +59,9 @@ def test_pyproject_bundle_manifest(packaging_utils):
items = bundle_items(REPO_ROOT) items = bundle_items(REPO_ROOT)
names = {item["name"] for item in items} 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)) data_files = dict(bundle_data_files(REPO_ROOT))
assert "orisys/configs" in data_files assert "orisys/configs" in data_files
+115 -47
View File
@@ -30,7 +30,7 @@ version = {attr = "orisys.__version__"}
""".lstrip() """.lstrip()
def _make_repo(tmp_path: Path, *, version: str = "0.5.2", with_git: bool = False) -> Path: 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") (tmp_path / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8")
package = tmp_path / "src" / "orisys" package = tmp_path / "src" / "orisys"
package.mkdir(parents=True) package.mkdir(parents=True)
@@ -63,16 +63,16 @@ class _FakeResponse:
def test_wheel_version_parsing(): def test_wheel_version_parsing():
assert version_mod.wheel_version(Path("orisys-0.5.2-cp310-cp310-win_amd64.whl")) == "0.5.2" assert version_mod.wheel_version(Path("orisys-1.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("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 assert version_mod.wheel_version(Path("something-else.whl")) is None
def test_sdk_versions_are_numerically_sorted(tmp_path): def test_sdk_versions_are_numerically_sorted(tmp_path):
for name in ("0.9.0", "0.4.10", "0.5.2"): for name in ("0.9.0", "0.4.10", "1.2.3"):
(tmp_path / "SDK" / name).mkdir(parents=True) (tmp_path / "SDK" / name).mkdir(parents=True)
(tmp_path / "SDK" / "not-a-version").mkdir() (tmp_path / "SDK" / "not-a-version").mkdir()
assert version_mod.sdk_versions(tmp_path)[:3] == ["0.4.10", "0.5.2", "0.9.0"] 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): def test_sdk_versions_empty_without_sdk_dir(tmp_path):
@@ -81,10 +81,10 @@ def test_sdk_versions_empty_without_sdk_dir(tmp_path):
def test_sdk_wheels_lists_only_that_version(tmp_path): def test_sdk_wheels_lists_only_that_version(tmp_path):
repo = _make_repo(tmp_path) repo = _make_repo(tmp_path)
_stage_wheel(repo, "0.5.2") _stage_wheel(repo, "1.2.3")
_stage_wheel(repo, "0.4.3") _stage_wheel(repo, "0.4.3")
wheels = version_mod.sdk_wheels(repo, "0.5.2") wheels = version_mod.sdk_wheels(repo, "1.2.3")
assert [w.name for w in wheels] == ["orisys-0.5.2-cp310-cp310-win_amd64.whl"] 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): def test_dynamic_version_attr_read_from_pyproject(tmp_path):
@@ -98,12 +98,12 @@ def test_dynamic_version_attr_none_when_absent(tmp_path):
def test_pypi_released_versions_parses_response(tmp_path, monkeypatch): def test_pypi_released_versions_parses_response(tmp_path, monkeypatch):
fake = _FakeResponse({"releases": {"0.4.2": [{"filename": "a"}], "0.5.2": [{"filename": "b"}], "0.0.0": []}}) 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) monkeypatch.setattr(version_mod.urllib.request, "urlopen", lambda *a, **k: fake)
versions, error = version_mod.pypi_released_versions() versions, error = version_mod.pypi_released_versions()
assert error is None assert error is None
# An empty file list means nothing was ever uploaded for that version. # An empty file list means nothing was ever uploaded for that version.
assert versions == ["0.4.2", "0.5.2"] assert versions == ["0.4.2", "1.2.3"]
def test_pypi_released_versions_reports_http_404_as_empty(monkeypatch): def test_pypi_released_versions_reports_http_404_as_empty(monkeypatch):
@@ -137,7 +137,7 @@ def test_python_for_tag_uses_envs_root(tmp_path):
def test_write_report_round_trips(tmp_path): def test_write_report_round_trips(tmp_path):
payload = { payload = {
"version": "0.5.2", "version": "1.2.3",
"ok": False, "ok": False,
"git": {"commit": "abc", "branch": "dev", "dirty": True}, "git": {"commit": "abc", "branch": "dev", "dirty": True},
"interpreter": {"version": "3.10.19", "executable": "python"}, "interpreter": {"version": "3.10.19", "executable": "python"},
@@ -182,43 +182,43 @@ def test_repo_state_fails_outside_git(tmp_path, capsys):
def test_version_consistency_detects_mismatch(tmp_path): def test_version_consistency_detects_mismatch(tmp_path):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
step = Preflight(PreflightOptions(version="9.9.9"), repo_root=repo)._version_consistency() step = Preflight(PreflightOptions(version="9.9.9"), repo_root=repo)._version_consistency()
assert not step.ok assert not step.ok
assert "0.5.2" in step.detail and "9.9.9" in step.detail assert "1.2.3" in step.detail and "9.9.9" in step.detail
def test_version_consistency_passes_on_match(tmp_path): def test_version_consistency_passes_on_match(tmp_path):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._version_consistency() step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._version_consistency()
assert step.ok, step.detail assert step.ok, step.detail
def test_duplicate_guard_allows_staged_sdk_dir(tmp_path, monkeypatch): def test_duplicate_guard_allows_staged_sdk_dir(tmp_path, monkeypatch):
"""A populated SDK/<version>/ is the normal preflight input, not a hazard.""" """A populated SDK/<version>/ is the normal preflight input, not a hazard."""
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
_stage_wheel(repo, "0.5.2") _stage_wheel(repo, "1.2.3")
monkeypatch.setattr(version_mod, "git_tags", lambda root: []) monkeypatch.setattr(version_mod, "git_tags", lambda root: [])
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._duplicate_guard() step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._duplicate_guard()
assert step.ok, step.detail assert step.ok, step.detail
assert "staged wheel(s) to verify" in step.detail assert "staged wheel(s) to verify" in step.detail
def test_duplicate_guard_blocks_released_tag(tmp_path, monkeypatch): def test_duplicate_guard_blocks_released_tag(tmp_path, monkeypatch):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
_stage_wheel(repo, "0.5.2") _stage_wheel(repo, "1.2.3")
monkeypatch.setattr(version_mod, "git_tags", lambda root: ["v0.5.2", "v0.5.1"]) monkeypatch.setattr(version_mod, "git_tags", lambda root: ["v1.2.3", "v0.5.1"])
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._duplicate_guard() step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._duplicate_guard()
assert not step.ok assert not step.ok
assert "already mark 0.5.2 as released" in step.detail assert "already mark 1.2.3 as released" in step.detail
assert "--force" in step.detail assert "--force" in step.detail
def test_duplicate_guard_force_accepts_released_tag(tmp_path, monkeypatch): def test_duplicate_guard_force_accepts_released_tag(tmp_path, monkeypatch):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
monkeypatch.setattr(version_mod, "git_tags", lambda root: ["0.5.2"]) monkeypatch.setattr(version_mod, "git_tags", lambda root: ["1.2.3"])
step = Preflight( step = Preflight(
PreflightOptions(version="0.5.2", force=True), repo_root=repo PreflightOptions(version="1.2.3", force=True), repo_root=repo
)._duplicate_guard() )._duplicate_guard()
assert step.ok, step.detail assert step.ok, step.detail
assert "accepted via --force" in step.detail assert "accepted via --force" in step.detail
@@ -226,18 +226,18 @@ def test_duplicate_guard_force_accepts_released_tag(tmp_path, monkeypatch):
def test_duplicate_guard_reports_unreleased_version(tmp_path, monkeypatch): def test_duplicate_guard_reports_unreleased_version(tmp_path, monkeypatch):
repo = _make_repo(tmp_path, version="0.5.3") repo = _make_repo(tmp_path, version="0.5.3")
monkeypatch.setattr(version_mod, "git_tags", lambda root: ["v0.5.2"]) monkeypatch.setattr(version_mod, "git_tags", lambda root: ["v1.2.3"])
step = Preflight(PreflightOptions(version="0.5.3"), repo_root=repo)._duplicate_guard() step = Preflight(PreflightOptions(version="0.5.3"), repo_root=repo)._duplicate_guard()
assert step.ok, step.detail assert step.ok, step.detail
assert "no local git tag marks 0.5.3" in step.detail assert "no local git tag marks 0.5.3" in step.detail
def test_duplicate_guard_flags_published_version(tmp_path, monkeypatch): def test_duplicate_guard_flags_published_version(tmp_path, monkeypatch):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
monkeypatch.setattr(version_mod, "git_tags", lambda root: []) monkeypatch.setattr(version_mod, "git_tags", lambda root: [])
monkeypatch.setattr(version_mod, "pypi_released_versions", lambda *a, **k: (["0.5.2"], None)) monkeypatch.setattr(version_mod, "pypi_released_versions", lambda *a, **k: (["1.2.3"], None))
step = Preflight( step = Preflight(
PreflightOptions(version="0.5.2", check_pypi=True), repo_root=repo PreflightOptions(version="1.2.3", check_pypi=True), repo_root=repo
)._duplicate_guard() )._duplicate_guard()
assert not step.ok assert not step.ok
assert "already published on PyPI" in step.detail assert "already published on PyPI" in step.detail
@@ -246,7 +246,7 @@ def test_duplicate_guard_flags_published_version(tmp_path, monkeypatch):
def test_released_tags_for_matches_version_spellings(): def test_released_tags_for_matches_version_spellings():
tags = ["v0.5.1", "0.5.1", "v0.4.3", "0.50"] 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("0.5.1", tags) == ["0.5.1", "v0.5.1"]
assert version_mod.released_tags_for("0.5.2", tags) == [] assert version_mod.released_tags_for("1.2.3", tags) == []
def test_git_tags_reads_repo_tags(tmp_path): def test_git_tags_reads_repo_tags(tmp_path):
@@ -261,13 +261,81 @@ def test_git_tags_reads_repo_tags(tmp_path):
(repo / "f.txt").write_text("x", encoding="utf-8") (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", "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", "commit", "-qm", "init"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "tag", "v0.5.2"], cwd=repo, check=True, capture_output=True) subprocess.run(["git", "tag", "v1.2.3"], cwd=repo, check=True, capture_output=True)
assert "v0.5.2" in version_mod.git_tags(repo) 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): def test_bundle_precheck_fails_when_pattern_resolves_nothing(tmp_path):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
(repo / "pyproject.toml").write_text( (repo / "pyproject.toml").write_text(
PYPROJECT PYPROJECT
+ """ + """
@@ -279,23 +347,23 @@ patterns = ["*.json"]
""", """,
encoding="utf-8", encoding="utf-8",
) )
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._bundle_precheck() step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._bundle_precheck()
assert not step.ok assert not step.ok
assert "resolved 0 files" in step.detail assert "resolved 0 files" in step.detail
def test_wheel_inspection_fails_without_wheels(tmp_path): def test_wheel_inspection_fails_without_wheels(tmp_path):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._wheel_inspection() step = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)._wheel_inspection()
assert not step.ok assert not step.ok
assert "does not exist" in step.detail assert "does not exist" in step.detail
def test_wheel_runtime_fails_without_video(tmp_path, monkeypatch): def test_wheel_runtime_fails_without_video(tmp_path, monkeypatch):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
_stage_wheel(repo, "0.5.2") _stage_wheel(repo, "1.2.3")
monkeypatch.setattr("release.preflight.default_verification_video", lambda root: None) monkeypatch.setattr("release.preflight.default_verification_video", lambda root: None)
preflight = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo) preflight = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)
preflight._discover_wheels() preflight._discover_wheels()
step = preflight._wheel_runtime() step = preflight._wheel_runtime()
assert not step.ok assert not step.ok
@@ -303,18 +371,18 @@ def test_wheel_runtime_fails_without_video(tmp_path, monkeypatch):
def test_wheel_runtime_skip_is_recorded(tmp_path): def test_wheel_runtime_skip_is_recorded(tmp_path):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
step = Preflight( step = Preflight(
PreflightOptions(version="0.5.2", no_runtime=True), repo_root=repo PreflightOptions(version="1.2.3", no_runtime=True), repo_root=repo
)._wheel_runtime() )._wheel_runtime()
assert step.ok assert step.ok
assert "SKIPPED" in step.detail assert "SKIPPED" in step.detail
def test_tests_skip_is_loud_and_recorded(tmp_path): def test_tests_skip_is_loud_and_recorded(tmp_path):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
step = Preflight( step = Preflight(
PreflightOptions(version="0.5.2", skip_tests=True), repo_root=repo PreflightOptions(version="1.2.3", skip_tests=True), repo_root=repo
)._tests() )._tests()
assert step.ok assert step.ok
assert "SKIPPED" in step.detail assert "SKIPPED" in step.detail
@@ -322,8 +390,8 @@ def test_tests_skip_is_loud_and_recorded(tmp_path):
def test_execute_marks_failure_and_aborts(tmp_path, capsys): def test_execute_marks_failure_and_aborts(tmp_path, capsys):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
preflight = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo) preflight = Preflight(PreflightOptions(version="1.2.3"), repo_root=repo)
preflight._execute(preflight._repo_state) preflight._execute(preflight._repo_state)
assert preflight.aborted assert preflight.aborted
assert preflight.steps[-1].name == "repo-state" assert preflight.steps[-1].name == "repo-state"
@@ -331,7 +399,7 @@ def test_execute_marks_failure_and_aborts(tmp_path, capsys):
def test_execute_keep_going_does_not_abort(tmp_path): def test_execute_keep_going_does_not_abort(tmp_path):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="1.2.3")
preflight = Preflight(PreflightOptions(version="0.5.2", keep_going=True), repo_root=repo) preflight = Preflight(PreflightOptions(version="1.2.3", keep_going=True), repo_root=repo)
preflight._execute(preflight._repo_state) preflight._execute(preflight._repo_state)
assert not preflight.aborted assert not preflight.aborted
+6 -6
View File
@@ -19,25 +19,25 @@ def wheel_package():
def test_compliant_manylinux_tags(wheel_package): def test_compliant_manylinux_tags(wheel_package):
assert wheel_package.is_compliant_manylinux_wheel( assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.2-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( assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.2-cp310-cp310-manylinux2014_x86_64.whl") Path("orisys-1.2.3-cp310-cp310-manylinux2014_x86_64.whl")
) )
assert wheel_package.is_compliant_manylinux_wheel( assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.2-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): def test_rejects_illegal_manylinux_tags(wheel_package):
assert not wheel_package.is_compliant_manylinux_wheel( assert not wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.2-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( assert not wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.2-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( assert not wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.2-cp310-cp310-win_amd64.whl") Path("orisys-1.2.3-cp310-cp310-win_amd64.whl")
) )
+3 -3
View File
@@ -24,7 +24,7 @@ if str(BUILD_ROOT) not in sys.path:
from common.verify import wheel_contents # noqa: E402 from common.verify import wheel_contents # noqa: E402
from common.verify.wheel_contents import inspect_wheel # noqa: E402 from common.verify.wheel_contents import inspect_wheel # noqa: E402
VERSION = "0.5.2" VERSION = "1.2.3"
DIST_INFO = f"orisys-{VERSION}.dist-info" DIST_INFO = f"orisys-{VERSION}.dist-info"
CORE_DEPS = ("numpy>=1.24.4", "opencv-python>=4.10.0.84") CORE_DEPS = ("numpy>=1.24.4", "opencv-python>=4.10.0.84")
@@ -201,13 +201,13 @@ def test_good_wheel_passes(tmp_path):
def test_missing_wheel_file_fails(tmp_path): def test_missing_wheel_file_fails(tmp_path):
result = inspect_wheel(tmp_path / "nope-0.5.2-cp312-cp312-win_amd64.whl") result = inspect_wheel(tmp_path / "nope-1.2.3-cp312-cp312-win_amd64.whl")
assert not result.ok assert not result.ok
assert "wheel-exists" in _failed_names(result) assert "wheel-exists" in _failed_names(result)
def test_unparsable_filename_fails(tmp_path): def test_unparsable_filename_fails(tmp_path):
wheel, repo = _make_wheel(tmp_path, filename="orisys-0.5.2-cp312-cp312.whl") wheel, repo = _make_wheel(tmp_path, filename="orisys-1.2.3-cp312-cp312.whl")
result = inspect_wheel(wheel, repo_root=repo) result = inspect_wheel(wheel, repo_root=repo)
assert "filename" in _failed_names(result) assert "filename" in _failed_names(result)
+3 -3
View File
@@ -1,7 +1,7 @@
"""Tests for build/wheel/package.py metadata plumbing. """Tests for build/wheel/package.py metadata plumbing.
The Nuitka wheel is built from a generated ``setup.py``, which is exactly where The Nuitka wheel is built from a generated ``setup.py``, which is exactly where
the 0.5.2 release silently lost ``Requires-Python``, classifiers, project URLs 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 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. package tree) and assert on the METADATA it produces.
""" """
@@ -57,7 +57,7 @@ def _generate(wheel_dir: Path, tmp_path: Path) -> Path:
_stub_wheel_root(wheel_dir) _stub_wheel_root(wheel_dir)
write_wheel_setup( write_wheel_setup(
wheel_dir, wheel_dir,
version="0.5.2", version="1.2.3",
package_data={"orisys": ["*.pyd"]}, package_data={"orisys": ["*.pyd"]},
install_requires=["numpy>=1.24.4"], install_requires=["numpy>=1.24.4"],
extras_require={"gui": ["open3d>=0.17.0"]}, extras_require={"gui": ["open3d>=0.17.0"]},
@@ -117,7 +117,7 @@ def test_generated_wheel_metadata_is_complete(tmp_path):
text = metadata_path.read_text(encoding="utf-8") text = metadata_path.read_text(encoding="utf-8")
assert "Name: orisys" in text assert "Name: orisys" in text
assert "Version: 0.5.2" in text assert "Version: 1.2.3" in text
assert f"Summary: {PROJECT['description']}" in text assert f"Summary: {PROJECT['description']}" in text
assert "Requires-Python: >=3.10" in text assert "Requires-Python: >=3.10" in text
assert "License: Proprietary" in text assert "License: Proprietary" in text