official 0.1.0 version
This commit is contained in:
@@ -5,6 +5,7 @@ __pycache__/
|
|||||||
.eggs/
|
.eggs/
|
||||||
dist/
|
dist/
|
||||||
build/_pyinstaller/
|
build/_pyinstaller/
|
||||||
|
build/_smoke/
|
||||||
*.egg
|
*.egg
|
||||||
.venv/
|
.venv/
|
||||||
.venv-build/
|
.venv-build/
|
||||||
|
|||||||
+129
-28
@@ -1,40 +1,79 @@
|
|||||||
# Build
|
# Build
|
||||||
|
|
||||||
Windows packaging for the studio desktop app.
|
Windows packaging for the studio desktop app: **one provisioning step per machine, then
|
||||||
|
repeatable builds.**
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
build/
|
build/
|
||||||
studio/
|
studio/
|
||||||
|
setup.bat / setup.ps1 # provision the build venv (run once per machine)
|
||||||
|
constraints-build.txt # pinned build-time versions
|
||||||
|
build.bat / build.ps1 # build the EXE and verify it
|
||||||
entry.py # frozen entry (no source chdir)
|
entry.py # frozen entry (no source chdir)
|
||||||
orisys-studio.spec # PyInstaller onedir spec
|
orisys-studio.spec # PyInstaller onedir spec
|
||||||
build.ps1 # create/reuse .venv-build and build
|
|
||||||
_pyinstaller/ # PyInstaller workpath (gitignored)
|
_pyinstaller/ # PyInstaller workpath (gitignored)
|
||||||
|
_smoke/ # log of the last build's start-up smoke test
|
||||||
dist/
|
dist/
|
||||||
OrisysStudio/
|
OrisysStudio/
|
||||||
OrisysStudio.exe
|
OrisysStudio.exe
|
||||||
config/ # lean locales + sensors (no finger visualization/)
|
config/ # lean locales + sensors (no finger visualization/)
|
||||||
_internal/ # Python, PyQt5, opencv-headless, orisys, …
|
_internal/ # Python, PyQt5, opencv-headless, orisys, OpenGL, …
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Why there are two steps
|
||||||
|
|
||||||
- Windows + Python 3.10+ on PATH
|
A PyInstaller bundle depends on the *machine* as much as on the sources: which interpreter,
|
||||||
- `orisys` installable into the build venv (`pip install orisys>=0.5`)
|
whether it is Anaconda (whose `libexpat`/`libffi`/OpenSSL DLLs live outside the venv and are
|
||||||
- Do **not** install `orisys[gui]` / Open3D for this build
|
therefore **not** bundled), and where the `orisys` SDK checkout is. Those facts are decided
|
||||||
|
once, by `setup.ps1`, and recorded in `.venv-build\orisys-build-env.json`.
|
||||||
|
|
||||||
|
`build.ps1` then installs nothing and guesses nothing: it verifies the environment, builds,
|
||||||
|
and verifies the artefact. That split is what keeps a build reproducible instead of
|
||||||
|
"works on my machine".
|
||||||
|
|
||||||
|
## First time on a machine
|
||||||
|
|
||||||
|
```bat
|
||||||
|
cd build\studio
|
||||||
|
setup.bat
|
||||||
|
setup.bat -Python C:\Python310\python.exe
|
||||||
|
setup.bat -AllowConda
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup.bat` finds an interpreter, creates or repairs `.venv-build`, installs the pinned
|
||||||
|
toolchain plus the app's extras (including the studio's `three-d` extra — without it the 3D
|
||||||
|
pane silently reports "PyOpenGL is not installed" on the customer's machine), writes the
|
||||||
|
stamp, and then verifies the result by importing what the frozen app imports.
|
||||||
|
|
||||||
|
Interpreter resolution, in order: `-Python <path>`; `py -3.10`; `python`; the interpreter
|
||||||
|
already behind `.venv-build`. The Microsoft Store `python.exe` alias is refused (it is not an
|
||||||
|
interpreter), and an Anaconda interpreter is refused unless `-AllowConda` is passed — in that
|
||||||
|
case its `Library\bin` is recorded and the spec adds the runtime DLLs, because PyInstaller
|
||||||
|
does not search there and the EXE would otherwise die at start-up with
|
||||||
|
`ImportError: DLL load failed while importing pyexpat`. Building with python.org CPython is
|
||||||
|
still the preferred answer.
|
||||||
|
|
||||||
|
The SDK is installed from `..\..\..\orisys-sdk` when that checkout exists (`-SdkPath` to point
|
||||||
|
elsewhere, `-FromPypi` to install the published wheel instead). Installing the published SDK
|
||||||
|
is how a build once shipped with no `orisys.finger` at all.
|
||||||
|
|
||||||
|
Re-running `setup.bat` is safe: with a valid stamp for this checkout it reports and exits;
|
||||||
|
without one it repairs the existing venv rather than deleting it. `-Force` recreates it.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
From `build\studio` (recommended):
|
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
build.bat
|
build.bat
|
||||||
build.bat -Clean
|
build.bat -Clean # after dependency/spec changes
|
||||||
build.bat -SkipInstall
|
build.bat -RebuildVenv # setup.bat -Force, then build
|
||||||
|
build.bat -SkipInstall # never provision: fail instead
|
||||||
|
build.bat -SkipSmokeTest # do not launch the produced EXE
|
||||||
|
build.bat -StrictSmokeTest # treat a 3D-context warning as a failure
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from repo root:
|
Or from the repo root:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
|
powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
|
||||||
@@ -42,35 +81,97 @@ powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
|
|||||||
|
|
||||||
`build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags.
|
`build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags.
|
||||||
|
|
||||||
Flags:
|
## What the build verifies, and why
|
||||||
|
|
||||||
| Flag | Meaning |
|
Every silent failure this packaging has produced looked like a *successful* build, so the
|
||||||
|------|---------|
|
script checks the things that only show up when the EXE runs:
|
||||||
| `-Clean` | Pass `--clean` to PyInstaller (use after dep/spec changes) |
|
|
||||||
| `-SkipInstall` | Reuse packages already in `.venv-build` (faster iteration) |
|
|
||||||
|
|
||||||
Output: `dist\OrisysStudio\OrisysStudio.exe`
|
| check | failure it prevents |
|
||||||
|
|---|---|
|
||||||
|
| the venv imports `orisys_app_shared`, `orisys_studio`, `orisys`, `OpenGL`, `cv2`, `PyInstaller` | a stale editable install still builds (the spec's `pathex` finds the modules) and produces an EXE that cannot start |
|
||||||
|
| `collect_data_files('orisys_app_shared', includes=['theme/*.json'])` is non-empty | the theme colours are package data; without them the app dies in `build_qss()` before logging anything |
|
||||||
|
| every `config\sensors\**\*.oryscene` is present, and the finger manifest/`sensor.json`/unroll map survive the config strip | the 3D pane refuses the scene when the device description cannot be derived |
|
||||||
|
| no `open3d*` under `dist` | the offline tool leaking into the shipped bundle |
|
||||||
|
| **smoke test**: start the produced EXE, wait for its own log to say `Studio shell started` (and `3D scene loaded` when a finger scene shipped), then stop it | a bundle that cannot start at all — missing runtime DLLs, an excluded module, missing data. The log is kept under `build\_smoke\` |
|
||||||
|
|
||||||
|
The smoke test seeds a `preference.json` beside the EXE to select the finger sensor with the
|
||||||
|
3D pane on, and restores (or removes) it afterwards. A *GL context* failure is reported as a
|
||||||
|
warning, because it is environment-dependent; `-StrictSmokeTest` turns it into a failure.
|
||||||
|
|
||||||
|
## Where the app's own code lives in the bundle
|
||||||
|
|
||||||
|
`_internal\` contains **native code and data files only**. Every pure-Python module —
|
||||||
|
`orisys_studio.*`, `orisys_app_shared.*`, `orisys.*` — is compiled into the `PYZ` archive that
|
||||||
|
sits *inside* `OrisysStudio.exe` (that is most of the EXE's ~13 MB). So there is deliberately
|
||||||
|
no `_internal\orisys_studio\` folder, and no `.py` files anywhere: PyInstaller ships bytecode,
|
||||||
|
never sources.
|
||||||
|
|
||||||
|
`_internal` therefore shows only these orisys-related paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_internal\orisys_app_shared\theme\colors_dark.json <- package data, bundled (qss.py reads it)
|
||||||
|
_internal\orisys_app_shared\theme\colors_light.json
|
||||||
|
_internal\orisys\.gitignore <- stray non-.py file of the SDK package
|
||||||
|
_internal\branding\... <- logos
|
||||||
|
```
|
||||||
|
|
||||||
|
(`collect_data_files("orisys")` exists for the case where the SDK is installed from a wheel that
|
||||||
|
bundles `sensors/` assets; with a local editable checkout the only non-`.py` file is that
|
||||||
|
`.gitignore`. It is harmless — the app prefers `config\sensors` — but it does look odd.)
|
||||||
|
|
||||||
|
To list what is really in the archive:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
.venv-build\Scripts\python.exe -m PyInstaller.utils.cliutils.archive_viewer ^
|
||||||
|
-l -r -b dist\OrisysStudio\OrisysStudio.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `PYZ-00.toc` under `build\_pyinstaller\orisys-studio\` is the same information at build
|
||||||
|
time, and `build\_smoke\*.log` is the runtime proof: "Studio shell started" and "3D scene
|
||||||
|
loaded" can only be printed if those modules imported.)
|
||||||
|
|
||||||
## Dependency notes
|
## Dependency notes
|
||||||
|
|
||||||
- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI).
|
- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI). `setup.ps1` uninstalls
|
||||||
- Upstream `orisys` may still declare `opencv-python`. `build.ps1` uninstalls that wheel and installs **`opencv-python-headless>=4.10,<5`** (5.x has shipped broken wheels without a `cv2` module). The script verifies `import cv2` before packaging.
|
any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped wheels
|
||||||
- **Open3D is excluded** (spec + build env). Finger 3D remains a placeholder in the UI.
|
without a `cv2` module) and verifies `import cv2`.
|
||||||
|
- **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra.
|
||||||
|
Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped.
|
||||||
|
- **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is
|
||||||
|
Qt `QOpenGLWidget` + PyOpenGL.
|
||||||
|
- `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force`
|
||||||
|
and a passing `build.bat` — the smoke test is the acceptance evidence, not the file.
|
||||||
|
|
||||||
|
## GL note
|
||||||
|
|
||||||
|
The pane asks for the profile in `gl_profile` (`desktop33` by default, `gles30` otherwise). A
|
||||||
|
`desktop33` request needs a desktop GL 3.3 core context; on some sessions Qt falls back to its
|
||||||
|
ANGLE (GLES) backend, which cannot satisfy it, and the widget then reports no context on a
|
||||||
|
machine whose GPU is fine. `QT_OPENGL=desktop` is the workaround; the durable fix is to pin
|
||||||
|
the backend in `orisys_studio.main` before `QApplication` is constructed
|
||||||
|
(`Qt.AA_UseDesktopOpenGL` / `Qt.AA_UseOpenGLES`). `build.bat -StrictSmokeTest` makes this a
|
||||||
|
build failure.
|
||||||
|
|
||||||
## Size / startup
|
## Size / startup
|
||||||
|
|
||||||
- **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder.
|
- **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder.
|
||||||
- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved).
|
- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved). Nothing
|
||||||
|
in the app reads it (`manifest.json`'s `viz_pose` is used only by the SDK examples); the 3D
|
||||||
|
pane needs `scene/*.oryscene` plus `assets/30fps-0703`, and the build checks both.
|
||||||
- Studio shell defers OpenCV / acquisition imports until Preview or Run.
|
- Studio shell defers OpenCV / acquisition imports until Preview or Run.
|
||||||
|
|
||||||
Expect hundreds of MB total (Qt + numpy/scipy/numba + OpenCV). Without Open3D this stays smaller than a full `orisys[gui]` bundle.
|
Expect ~450 MB total (Qt + numpy/scipy/numba + OpenCV + PyOpenGL).
|
||||||
|
|
||||||
## Verification
|
## Verification on a new machine
|
||||||
|
|
||||||
1. Launch `OrisysStudio.exe` on the build machine (and ideally a clean PC without Python).
|
1. `setup.bat` (see its report: interpreter, conda or not, SDK source).
|
||||||
2. Theme / locale / prefs work; `preference.json` and `outputs\` appear next to the EXE.
|
2. `build.bat` — its smoke test must print "the application started" and "the packaged 3D
|
||||||
3. Camera preview + box/mini/finger Run (video) with image modes and force plots.
|
scene loaded".
|
||||||
4. Confirm no `open3d*` files under `dist\OrisysStudio`.
|
3. Launch `OrisysStudio.exe` and check theme/locale/prefs; `preference.json` and `outputs\`
|
||||||
|
appear next to the EXE.
|
||||||
|
4. Camera preview + box/mini/finger Run (video) with image modes and force plots.
|
||||||
|
5. Open the 3D pane on a finger source: the surface draws, the legend shows the field.
|
||||||
|
6. Confirm no `open3d*` files under `dist\OrisysStudio`.
|
||||||
|
|
||||||
## Editable run (unchanged)
|
## Editable run (unchanged)
|
||||||
|
|
||||||
|
|||||||
+235
-58
@@ -1,90 +1,134 @@
|
|||||||
#Requires -Version 5.1
|
#Requires -Version 5.1
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Build Orisys Studio Windows onedir EXE with PyInstaller.
|
Build the Orisys Studio Windows onedir EXE with PyInstaller.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script installs nothing. Provisioning belongs to `setup.ps1`, which decides the
|
||||||
|
machine-specific facts once (interpreter, Anaconda or not, where the SDK checkout is) and
|
||||||
|
records them in `.venv-build\orisys-build-env.json`. Here we only:
|
||||||
|
|
||||||
|
1. make sure that provisioning happened (and run `setup.ps1` if it plainly has not),
|
||||||
|
2. verify the environment can import what the frozen app imports,
|
||||||
|
3. build,
|
||||||
|
4. verify the artefact — config, excludes, and a real start-up of the produced EXE.
|
||||||
|
|
||||||
|
Step 2 and step 4 exist because the interesting failures are silent ones: a stale venv
|
||||||
|
still builds (the spec's pathex finds the modules) yet loses the theme data; an excluded
|
||||||
|
module still builds yet makes `import orisys.finger` fail; a missing runtime DLL still
|
||||||
|
builds yet the EXE never writes a log. Each of those looked like a successful build.
|
||||||
|
|
||||||
.PARAMETER Clean
|
.PARAMETER Clean
|
||||||
Pass --clean to PyInstaller (slower; use when deps/spec change).
|
Pass --clean to PyInstaller (slower; use after dependency or spec changes).
|
||||||
|
|
||||||
.PARAMETER SkipInstall
|
.PARAMETER SkipInstall
|
||||||
Skip pip install steps (reuse existing .venv-build packages).
|
Do not run `setup.ps1`, even if the environment looks unprovisioned. Fails instead of
|
||||||
|
provisioning silently.
|
||||||
|
|
||||||
|
.PARAMETER RebuildVenv
|
||||||
|
Run `setup.ps1 -Force` first (recreate the venv from scratch), then build.
|
||||||
|
|
||||||
|
.PARAMETER SkipSmokeTest
|
||||||
|
Do not launch the produced EXE. The smoke test is the only step that proves the bundle
|
||||||
|
starts; skip it only where a desktop session is genuinely unavailable.
|
||||||
|
|
||||||
|
.PARAMETER StrictSmokeTest
|
||||||
|
Treat a 3D-context warning from the smoke test as a failure.
|
||||||
|
|
||||||
|
.PARAMETER GlProfile
|
||||||
|
GL profile the smoke test starts the app with (default: the shipped `desktop33`).
|
||||||
#>
|
#>
|
||||||
param(
|
param(
|
||||||
[switch]$Clean,
|
[switch]$Clean,
|
||||||
[switch]$SkipInstall
|
[switch]$SkipInstall,
|
||||||
|
[switch]$RebuildVenv,
|
||||||
|
[switch]$SkipSmokeTest,
|
||||||
|
[switch]$StrictSmokeTest,
|
||||||
|
[string]$GlProfile = "desktop33"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
$StudioDir = $PSScriptRoot
|
||||||
|
$RepoRoot = (Resolve-Path (Join-Path $StudioDir "..\..")).Path
|
||||||
$VenvDir = Join-Path $RepoRoot ".venv-build"
|
$VenvDir = Join-Path $RepoRoot ".venv-build"
|
||||||
$Python = Join-Path $VenvDir "Scripts\python.exe"
|
$Python = Join-Path $VenvDir "Scripts\python.exe"
|
||||||
$Spec = Join-Path $PSScriptRoot "orisys-studio.spec"
|
$StampPath = Join-Path $VenvDir "orisys-build-env.json"
|
||||||
|
$Spec = Join-Path $StudioDir "orisys-studio.spec"
|
||||||
|
$Setup = Join-Path $StudioDir "setup.ps1"
|
||||||
$WorkPath = Join-Path $RepoRoot "build\_pyinstaller"
|
$WorkPath = Join-Path $RepoRoot "build\_pyinstaller"
|
||||||
$DistPath = Join-Path $RepoRoot "dist"
|
$DistPath = Join-Path $RepoRoot "dist"
|
||||||
|
$OutDir = Join-Path $DistPath "OrisysStudio"
|
||||||
|
$Exe = Join-Path $OutDir "OrisysStudio.exe"
|
||||||
|
$SmokeKeep = Join-Path $RepoRoot "build\_smoke"
|
||||||
|
|
||||||
|
function Assert-Ok([string]$what) {
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "$what failed (exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# PowerShell 5.1's `Set-Content -Encoding UTF8` writes a BOM, and a BOM makes Python's
|
||||||
|
# `json.loads` raise ("Unexpected UTF-8 BOM") — the app then silently falls back to its
|
||||||
|
# defaults, which is exactly the kind of quiet failure this script exists to avoid.
|
||||||
|
function Write-JsonFile([string]$path, [string]$json) {
|
||||||
|
[System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Repo: $RepoRoot"
|
Write-Host "Repo: $RepoRoot"
|
||||||
|
|
||||||
if (-not (Test-Path $Python)) {
|
# --------------------------------------------------------------- 1. provisioning
|
||||||
Write-Host "Creating build venv at $VenvDir"
|
if ($RebuildVenv) {
|
||||||
python -m venv $VenvDir
|
Write-Host "Recreating the build environment ..."
|
||||||
|
& $Setup -Force
|
||||||
|
Assert-Ok "setup.ps1 -Force"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $SkipInstall) {
|
$stampRoot = ""
|
||||||
Write-Host "Installing build dependencies (editable shared + studio + orisys + pyinstaller)…"
|
if (Test-Path $StampPath) {
|
||||||
& $Python -m pip install --upgrade pip
|
$stampRoot = (Get-Content $StampPath -Raw | ConvertFrom-Json).checkout_root
|
||||||
& $Python -m pip install "pyinstaller>=6.0"
|
|
||||||
& $Python -m pip install -e (Join-Path $RepoRoot "shared")
|
|
||||||
& $Python -m pip install -e (Join-Path $RepoRoot "apps\studio")
|
|
||||||
# orisys may already be installed; ensure present without [gui]/open3d
|
|
||||||
& $Python -m pip install "orisys>=0.5"
|
|
||||||
|
|
||||||
Write-Host "Forcing opencv-python-headless 4.x (orisys may have pulled opencv-python; 5.x can be broken)…"
|
|
||||||
& $Python -m pip uninstall -y opencv-python opencv-python-headless 2>$null
|
|
||||||
& $Python -m pip install "opencv-python-headless>=4.10.0.84,<5"
|
|
||||||
& $Python -c "import cv2; print('cv2 OK', cv2.__version__)"
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "cv2 import failed after installing opencv-python-headless"
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
}
|
|
||||||
|
|
||||||
# Guard: Open3D must not be present in the build env
|
|
||||||
$hasOpen3d = & $Python -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
|
|
||||||
if ($hasOpen3d -eq "True") {
|
|
||||||
Write-Host "Uninstalling open3d from build venv…"
|
|
||||||
& $Python -m pip uninstall -y open3d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
$needsSetup = (-not (Test-Path $Python)) -or ($stampRoot -ne $RepoRoot)
|
||||||
|
if ($needsSetup) {
|
||||||
|
if ($SkipInstall) {
|
||||||
|
throw "the build environment is missing or was provisioned for another checkout. Run setup.bat (or drop -SkipInstall)."
|
||||||
|
}
|
||||||
|
Write-Host "No usable build environment for this checkout: running setup.ps1 ..."
|
||||||
|
& $Setup
|
||||||
|
Assert-Ok "setup.ps1"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $Spec)) { throw "spec not found: $Spec" }
|
||||||
|
|
||||||
|
# ---------------------------------------------------- 2. environment verification
|
||||||
|
# Cheap (about a second) and it catches the two provisioning mistakes that build silently:
|
||||||
|
# an editable install pointing at another checkout, and a missing GL binding.
|
||||||
|
Write-Host "Verifying the build environment ..."
|
||||||
|
& $Python -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyInstaller; print('imports OK')"
|
||||||
|
Assert-Ok "the build venv cannot import what the app imports"
|
||||||
|
& $Python -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')"
|
||||||
|
Assert-Ok "theme data would not be bundled"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 3. PyInstaller
|
||||||
$pyiArgs = @(
|
$pyiArgs = @(
|
||||||
"--noconfirm",
|
"--noconfirm",
|
||||||
"--distpath", $DistPath,
|
"--distpath", $DistPath,
|
||||||
"--workpath", $WorkPath,
|
"--workpath", $WorkPath,
|
||||||
$Spec
|
$Spec
|
||||||
)
|
)
|
||||||
if ($Clean) {
|
if ($Clean) { $pyiArgs = @("--clean") + $pyiArgs }
|
||||||
$pyiArgs = @("--clean") + $pyiArgs
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Running PyInstaller…"
|
Write-Host "Running PyInstaller ..."
|
||||||
& $Python -m PyInstaller @pyiArgs
|
& $Python -m PyInstaller @pyiArgs
|
||||||
if ($LASTEXITCODE -ne 0) {
|
Assert-Ok "PyInstaller"
|
||||||
Write-Error "PyInstaller failed with exit code $LASTEXITCODE"
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
}
|
|
||||||
|
|
||||||
$OutDir = Join-Path $DistPath "OrisysStudio"
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Build complete: $OutDir"
|
Write-Host "Build complete: $OutDir"
|
||||||
|
|
||||||
# Lean config beside EXE (exclude finger visualization assets)
|
# -------------------------------------------------------------- 4. shipped config
|
||||||
|
# Lean config beside the EXE (the finger visualization bundle is a development artefact).
|
||||||
$ConfigSrc = Join-Path $RepoRoot "apps\studio\config"
|
$ConfigSrc = Join-Path $RepoRoot "apps\studio\config"
|
||||||
$ConfigDst = Join-Path $OutDir "config"
|
$ConfigDst = Join-Path $OutDir "config"
|
||||||
if (Test-Path $ConfigSrc) {
|
if (Test-Path $ConfigSrc) {
|
||||||
Write-Host "Copying lean config to $ConfigDst …"
|
Write-Host "Copying lean config to $ConfigDst ..."
|
||||||
if (Test-Path $ConfigDst) {
|
if (Test-Path $ConfigDst) { Remove-Item $ConfigDst -Recurse -Force }
|
||||||
Remove-Item $ConfigDst -Recurse -Force
|
|
||||||
}
|
|
||||||
New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null
|
New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null
|
||||||
Get-ChildItem $ConfigSrc -Force | ForEach-Object {
|
Get-ChildItem $ConfigSrc -Force | ForEach-Object {
|
||||||
Copy-Item $_.FullName -Destination $ConfigDst -Recurse -Force
|
Copy-Item $_.FullName -Destination $ConfigDst -Recurse -Force
|
||||||
@@ -96,21 +140,154 @@ if (Test-Path $ConfigSrc) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Launch: $(Join-Path $OutDir 'OrisysStudio.exe')"
|
# The 3D pane refuses the whole scene when the device description cannot be derived, and that
|
||||||
|
# description needs the sensor manifest, the unroll map and the compiled package. A strip
|
||||||
|
# step that removed one of them would leave a pane that explains itself instead of drawing.
|
||||||
|
Write-Host "Checking the shipped 3D prerequisites ..."
|
||||||
|
$scenePackages = @(Get-ChildItem (Join-Path $ConfigDst "sensors") -Recurse -Filter "*.oryscene" -ErrorAction SilentlyContinue)
|
||||||
|
if ($scenePackages.Count -eq 0) {
|
||||||
|
Write-Warning "No *.oryscene package under config\sensors: the 3D pane will report 'no compiled scene package'."
|
||||||
|
} else {
|
||||||
|
foreach ($pkg in $scenePackages) { Write-Host " scene: $($pkg.FullName.Substring($ConfigDst.Length + 1))" }
|
||||||
|
}
|
||||||
|
$fingerCfg = Join-Path $ConfigDst "sensors\finger"
|
||||||
|
if (Test-Path $fingerCfg) {
|
||||||
|
$required = @("manifest.json", "sensor.json")
|
||||||
|
foreach ($name in $required) {
|
||||||
|
if (-not (Test-Path (Join-Path $fingerCfg $name))) { throw "the shipped finger config is missing $name" }
|
||||||
|
}
|
||||||
|
$manifest = Get-Content (Join-Path $fingerCfg "manifest.json") -Raw | ConvertFrom-Json
|
||||||
|
$assets = Join-Path $fingerCfg ([string]$manifest.assets_dir)
|
||||||
|
$sensor = Get-Content (Join-Path $fingerCfg "sensor.json") -Raw | ConvertFrom-Json
|
||||||
|
$scale = $sensor.camera.source_scale
|
||||||
|
$mapName = if ($null -eq $scale -or [double]$scale -eq 1.0) { "unroll_map_uv.npz" } else { "unroll_map_uv_s$([double]$scale).npz" }
|
||||||
|
if (-not (Test-Path (Join-Path $assets $mapName))) {
|
||||||
|
throw "the shipped finger assets have no $mapName (the device geometry description needs it; the pane would refuse the scene)"
|
||||||
|
}
|
||||||
|
Write-Host " finger assets: $($manifest.assets_dir) ($mapName present)"
|
||||||
|
}
|
||||||
|
|
||||||
# Size summary
|
# ------------------------------------------------------------------- 5. no Open3D
|
||||||
|
$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -match 'open3d' }
|
||||||
|
if ($o3dHits) {
|
||||||
|
Write-Warning "Open3D-related files found under dist (should be empty):"
|
||||||
|
$o3dHits | ForEach-Object { Write-Warning $_.FullName }
|
||||||
|
} else {
|
||||||
|
Write-Host "Open3D check: none found under dist."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- 6. smoke test
|
||||||
|
# Start the EXE the way a user would and require the application's own log to say it came up.
|
||||||
|
# This is the step that would have caught every silent failure found in this build.
|
||||||
|
$smokeFailed = $false
|
||||||
|
$smokeWarned = $false
|
||||||
|
if (-not $SkipSmokeTest -and (Test-Path $Exe)) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Smoke test: starting $Exe ..."
|
||||||
|
$prefPath = Join-Path $OutDir "preference.json"
|
||||||
|
$hadPref = Test-Path $prefPath
|
||||||
|
$prefBackup = $null
|
||||||
|
if ($hadPref) { $prefBackup = Get-Content $prefPath -Raw }
|
||||||
|
$logsDir = Join-Path $OutDir "outputs\logs"
|
||||||
|
$before = @()
|
||||||
|
$outputsExisted = Test-Path (Join-Path $OutDir "outputs")
|
||||||
|
if (Test-Path $logsDir) { $before = @(Get-ChildItem $logsDir -Filter *.log | ForEach-Object { $_.Name }) }
|
||||||
|
|
||||||
|
# The pane is shown for the finger sensor only; seeding the preference is how the build
|
||||||
|
# proves the *packaged* asset loads, not just that the window opened.
|
||||||
|
$sensorFor3D = ""
|
||||||
|
if (Test-Path $fingerCfg) { $sensorFor3D = "finger" }
|
||||||
|
$seed = [ordered]@{ language = "en_US"; theme = "dark"; view_preset = "live"; show_finger_3d = $true }
|
||||||
|
if ($sensorFor3D) { $seed["sensor_type"] = $sensorFor3D }
|
||||||
|
($seed | ConvertTo-Json) | ForEach-Object { Write-JsonFile $prefPath $_ }
|
||||||
|
|
||||||
|
$oldQpa = $env:QT_QPA_PLATFORM
|
||||||
|
$env:QT_QPA_PLATFORM = "windows"
|
||||||
|
|
||||||
|
$proc = $null
|
||||||
|
try {
|
||||||
|
$proc = Start-Process -FilePath $Exe -WorkingDirectory $OutDir -PassThru
|
||||||
|
# The scene is read on a worker thread *after* the shell is up, so the two lines
|
||||||
|
# arrive separately: waiting for both (rather than reading the log once) is what
|
||||||
|
# keeps this test from being a race.
|
||||||
|
$wanted = @("Studio shell started")
|
||||||
|
if ($sensorFor3D) { $wanted += "3D scene loaded" }
|
||||||
|
$deadline = (Get-Date).AddSeconds(75)
|
||||||
|
$logText = ""
|
||||||
|
$logPath = ""
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 1000
|
||||||
|
$fresh = Get-ChildItem $logsDir -Filter *.log -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $before -notcontains $_.Name }
|
||||||
|
if ($fresh) {
|
||||||
|
$newest = $fresh | Sort-Object LastWriteTime | Select-Object -Last 1
|
||||||
|
$logPath = $newest.FullName
|
||||||
|
$logText = Get-Content $logPath -Raw -ErrorAction SilentlyContinue
|
||||||
|
$missing = @($wanted | Where-Object { $logText -notmatch [regex]::Escape($_) })
|
||||||
|
if ($missing.Count -eq 0) { break }
|
||||||
|
}
|
||||||
|
if ($proc.HasExited) { break }
|
||||||
|
}
|
||||||
|
if (-not $logText -or $logText -notmatch "Studio shell started") {
|
||||||
|
$smokeFailed = $true
|
||||||
|
Write-Warning "The produced EXE did not reach 'Studio shell started'."
|
||||||
|
if ($logPath) { Write-Warning "log: $logPath" } else { Write-Warning "no log was written at all" }
|
||||||
|
} else {
|
||||||
|
Write-Host " the application started"
|
||||||
|
if ($sensorFor3D -and $logText -notmatch "3D scene loaded") {
|
||||||
|
$smokeFailed = $true
|
||||||
|
Write-Warning "The 3D scene did not load from the shipped package."
|
||||||
|
# The loader logs the diagnostic it refused with; quoting it here is the
|
||||||
|
# difference between "the build failed" and "this asset was rejected".
|
||||||
|
$rejection = @($logText -split "`r?`n" | Where-Object { $_ -match "rejected|no compiled scene|asset_" })
|
||||||
|
if ($rejection) { Write-Warning (" " + $rejection[-1]) }
|
||||||
|
} elseif ($sensorFor3D) {
|
||||||
|
Write-Host " the packaged 3D scene loaded"
|
||||||
|
}
|
||||||
|
# A fallback to Qt's ANGLE backend cannot satisfy a desktop 3.3 core request, and
|
||||||
|
# the pane then reports itself unavailable on a machine whose GPU is fine. It is
|
||||||
|
# environment-dependent, so it is a warning unless -StrictSmokeTest is asked for.
|
||||||
|
if ($logText -match "QWindowsEGLContext|Failed to create context|context creation failed") {
|
||||||
|
$smokeWarned = $true
|
||||||
|
Write-Warning "The 3D widget could not create a GL context in this session."
|
||||||
|
Write-Warning " desktop33: set QT_OPENGL=desktop (Qt otherwise falls back to ANGLE, which is GLES-only)"
|
||||||
|
Write-Warning " or pin it in the app (Qt.AA_UseDesktopOpenGL / AA_UseOpenGLES) before QApplication."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($logPath) {
|
||||||
|
New-Item -ItemType Directory -Path $SmokeKeep -Force | Out-Null
|
||||||
|
$keepName = "smoke-{0}.log" -f (Get-Date).ToString("yyyyMMdd_HHmmss")
|
||||||
|
Copy-Item $logPath (Join-Path $SmokeKeep $keepName) -Force
|
||||||
|
Write-Host " smoke log kept: build\_smoke\$keepName"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if ($proc -and -not $proc.HasExited) {
|
||||||
|
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
# The app is not given the chance to save preferences; restore what was there.
|
||||||
|
if ($hadPref) { Write-JsonFile $prefPath $prefBackup }
|
||||||
|
elseif (Test-Path $prefPath) { Remove-Item $prefPath -Force -ErrorAction SilentlyContinue }
|
||||||
|
if (-not $outputsExisted -and (Test-Path (Join-Path $OutDir "outputs"))) {
|
||||||
|
Remove-Item (Join-Path $OutDir "outputs") -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
if ($null -eq $oldQpa) { Remove-Item Env:\QT_QPA_PLATFORM -ErrorAction SilentlyContinue }
|
||||||
|
else { $env:QT_QPA_PLATFORM = $oldQpa }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- 7. summary
|
||||||
if (Test-Path $OutDir) {
|
if (Test-Path $OutDir) {
|
||||||
$bytes = (Get-ChildItem $OutDir -Recurse -File -ErrorAction SilentlyContinue |
|
$bytes = (Get-ChildItem $OutDir -Recurse -File -ErrorAction SilentlyContinue |
|
||||||
Measure-Object -Property Length -Sum).Sum
|
Measure-Object -Property Length -Sum).Sum
|
||||||
$mb = [math]::Round($bytes / 1MB, 1)
|
$mb = [math]::Round($bytes / 1MB, 1)
|
||||||
Write-Host ("Output size: {0} MB" -f $mb)
|
Write-Host ("Output size: {0} MB" -f $mb)
|
||||||
|
Write-Host "Launch: $Exe"
|
||||||
$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue |
|
|
||||||
Where-Object { $_.Name -match 'open3d' }
|
|
||||||
if ($o3dHits) {
|
|
||||||
Write-Warning "Open3D-related files found under dist (should be empty):"
|
|
||||||
$o3dHits | ForEach-Object { Write-Warning $_.FullName }
|
|
||||||
} else {
|
|
||||||
Write-Host "Open3D check: none found under dist."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($smokeFailed -or ($StrictSmokeTest -and $smokeWarned)) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "FAILED: the build produced an EXE that did not pass the smoke test." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Pinned build-time versions, applied with `pip install -c` by setup.ps1.
|
||||||
|
#
|
||||||
|
# The point is not tidiness: PyInstaller's output depends on the *environment* as much as
|
||||||
|
# on the spec (which Qt, which numpy, which hooks-contrib), so a build that "works" on one
|
||||||
|
# machine and produces a non-starting EXE on another is usually a version difference that
|
||||||
|
# nobody wrote down. `-c` constrains whatever else asks for these packages, so the app's
|
||||||
|
# own loose requirements (PyQt5>=5.15, scipy>=1.10.1, ...) resolve to exactly this set.
|
||||||
|
#
|
||||||
|
# `orisys` is deliberately absent: it is installed from a local checkout (or deliberately
|
||||||
|
# from PyPI with -FromPypi), and pinning it by name would fight the editable install.
|
||||||
|
#
|
||||||
|
# To move a pin: change it here, run `setup.bat -Force`, and run the build's smoke test.
|
||||||
|
# That test is the acceptance evidence, not this file.
|
||||||
|
|
||||||
|
PyInstaller==6.22.2
|
||||||
|
pyinstaller-hooks-contrib==2026.7
|
||||||
|
PyOpenGL==3.1.10
|
||||||
|
PyQt5==5.15.11
|
||||||
|
PyQt5-Qt5==5.15.2
|
||||||
|
PyQt5-sip==12.19.0
|
||||||
|
numpy==2.2.6
|
||||||
|
scipy==1.15.3
|
||||||
|
numba==0.67.0
|
||||||
|
llvmlite==0.49.0
|
||||||
|
opencv-python-headless==4.14.0.94
|
||||||
|
pyqtgraph==0.14.0
|
||||||
|
cryptography==49.0.0
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyInstaller.building.api import COLLECT, EXE, PYZ
|
from PyInstaller.building.api import COLLECT, EXE, PYZ
|
||||||
@@ -13,6 +15,50 @@ SPEC_DIR = Path(SPECPATH).resolve()
|
|||||||
REPO_ROOT = SPEC_DIR.parents[1]
|
REPO_ROOT = SPEC_DIR.parents[1]
|
||||||
BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding"
|
BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding"
|
||||||
ENTRY = SPEC_DIR / "entry.py"
|
ENTRY = SPEC_DIR / "entry.py"
|
||||||
|
#: What ``setup.ps1`` recorded about this machine (interpreter, and where its runtime DLLs
|
||||||
|
#: live when that interpreter is not a python.org one).
|
||||||
|
BUILD_ENV_STAMP = REPO_ROOT / ".venv-build" / "orisys-build-env.json"
|
||||||
|
|
||||||
|
#: The runtime DLLs an Anaconda interpreter keeps in ``<prefix>\Library\bin`` or
|
||||||
|
#: ``<prefix>\DLLs``. PyInstaller does not search those directories, so a frozen app built
|
||||||
|
#: without them dies before it can log anything:
|
||||||
|
#: ``ImportError: DLL load failed while importing pyexpat`` (libexpat)
|
||||||
|
#: ``ImportError: DLL load failed while importing _ctypes`` (ffi-8)
|
||||||
|
#: Building with python.org CPython avoids the whole list; when ``setup.ps1`` accepts a conda
|
||||||
|
#: interpreter it records the directory, and this is where that decision is honoured.
|
||||||
|
_RUNTIME_DLLS = (
|
||||||
|
"libexpat.dll",
|
||||||
|
"expat.dll",
|
||||||
|
"ffi-8.dll",
|
||||||
|
"ffi-7.dll",
|
||||||
|
"ffi.dll",
|
||||||
|
"libssl-3-x64.dll",
|
||||||
|
"libcrypto-3-x64.dll",
|
||||||
|
"sqlite3.dll",
|
||||||
|
"liblzma.dll",
|
||||||
|
"libbz2.dll",
|
||||||
|
"zlib.dll",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extra_runtime_binaries() -> list[tuple[str, str]]:
|
||||||
|
"""Runtime DLLs from a non-python.org interpreter, as ``(source, dest)`` pairs."""
|
||||||
|
folders: list[str] = []
|
||||||
|
try:
|
||||||
|
stamp = json.loads(BUILD_ENV_STAMP.read_text(encoding="utf-8"))
|
||||||
|
folders.extend(str(item) for item in (stamp.get("extra_dll_dirs") or ()))
|
||||||
|
except Exception:
|
||||||
|
# No stamp (a spec run by hand): fall back to the environment, if it says it is conda.
|
||||||
|
prefix = os.environ.get("CONDA_PREFIX")
|
||||||
|
if prefix:
|
||||||
|
folders.append(os.path.join(prefix, "Library", "bin"))
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
for folder in folders:
|
||||||
|
for name in _RUNTIME_DLLS:
|
||||||
|
candidate = Path(folder) / name
|
||||||
|
if candidate.is_file():
|
||||||
|
out.append((str(candidate), "."))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _branding_datas() -> list[tuple[str, str]]:
|
def _branding_datas() -> list[tuple[str, str]]:
|
||||||
@@ -30,7 +76,7 @@ block_cipher = None
|
|||||||
# Config is copied beside the EXE by build.ps1 (writable / editable).
|
# Config is copied beside the EXE by build.ps1 (writable / editable).
|
||||||
# Branding + package data go into _internal via Analysis datas.
|
# Branding + package data go into _internal via Analysis datas.
|
||||||
datas = _branding_datas()
|
datas = _branding_datas()
|
||||||
binaries: list = []
|
binaries: list = _extra_runtime_binaries()
|
||||||
hiddenimports = [
|
hiddenimports = [
|
||||||
"cv2",
|
"cv2",
|
||||||
"PyQt5.sip",
|
"PyQt5.sip",
|
||||||
@@ -68,7 +114,12 @@ a = Analysis(
|
|||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[
|
excludes=[
|
||||||
"open3d",
|
"open3d",
|
||||||
"orisys.finger.viewer",
|
# NOTE: `orisys.finger` imports `.viewer` at module level, and `finger/viewer.py`
|
||||||
|
# imports open3d *inside* its functions — so bundling it costs a few KB of Python and
|
||||||
|
# pulls in no Open3D. Excluding it made `import orisys.finger` raise
|
||||||
|
# ModuleNotFoundError in the frozen app, which took the whole window down with it
|
||||||
|
# (the app cannot even reach logging). The `open3d` entry above is the one that
|
||||||
|
# matters.
|
||||||
"tkinter",
|
"tkinter",
|
||||||
"matplotlib",
|
"matplotlib",
|
||||||
"IPython",
|
"IPython",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions
|
||||||
|
REM Provision the Orisys Studio build environment (run once per machine).
|
||||||
|
REM Works from any cwd; forwards args to setup.ps1
|
||||||
|
REM Examples:
|
||||||
|
REM setup.bat
|
||||||
|
REM setup.bat -Force
|
||||||
|
REM setup.bat -Python C:\Python310\python.exe
|
||||||
|
REM setup.bat -AllowConda
|
||||||
|
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
set "PS1=%SCRIPT_DIR%setup.ps1"
|
||||||
|
|
||||||
|
if not exist "%PS1%" (
|
||||||
|
echo ERROR: setup.ps1 not found at "%PS1%"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS1%" %*
|
||||||
|
set "ERR=%ERRORLEVEL%"
|
||||||
|
exit /b %ERR%
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Provision the Windows packaging environment for Orisys Studio. Run once per machine.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Building the EXE has two kinds of requirement, and mixing them is what makes a build
|
||||||
|
"work on my machine":
|
||||||
|
|
||||||
|
* things that are true of *this machine* — which interpreter exists, whether it is
|
||||||
|
Anaconda (whose libexpat/libffi DLLs live outside the venv and are therefore not
|
||||||
|
bundled), where the orisys SDK checkout is. Those are decided **here**, once, and
|
||||||
|
recorded in `.venv-build\orisys-build-env.json`.
|
||||||
|
* things that are true of the *sources* — the specs, the excludes, the assets. Those
|
||||||
|
belong to `build.ps1`, which then installs nothing and guesses nothing.
|
||||||
|
|
||||||
|
So this script is the only place that installs anything. It creates `.venv-build` (or
|
||||||
|
repairs it), installs the pinned toolchain plus the app's own extras, writes the stamp that
|
||||||
|
`build.ps1` and the spec read, and verifies the result by importing what the frozen app
|
||||||
|
imports before anything is packaged.
|
||||||
|
|
||||||
|
Re-running is safe: with a valid stamp and no -Force it reports and exits; without a stamp
|
||||||
|
it repairs the existing venv instead of deleting it.
|
||||||
|
|
||||||
|
.PARAMETER Python
|
||||||
|
Interpreter used to create the venv. Default: `py -3.10` when available, else `python`,
|
||||||
|
else the interpreter that already backs `.venv-build`.
|
||||||
|
|
||||||
|
.PARAMETER SdkPath
|
||||||
|
Local `orisys` SDK checkout to install editable. Default: `..\..\..\orisys-sdk` when that
|
||||||
|
exists. Without it (or with -FromPypi) the SDK comes from PyPI, which is *not* the code
|
||||||
|
under test — that is how a build once shipped with no `orisys.finger` at all.
|
||||||
|
|
||||||
|
.PARAMETER Force
|
||||||
|
Recreate the venv from scratch.
|
||||||
|
|
||||||
|
.PARAMETER AllowConda
|
||||||
|
Accept an Anaconda interpreter. Anaconda keeps libexpat/libffi/OpenSSL/SQLite in
|
||||||
|
`<prefix>\Library\bin`, where PyInstaller does not look; the stamp records that directory
|
||||||
|
and the spec adds those DLLs. Building with a python.org CPython is still preferred.
|
||||||
|
|
||||||
|
.PARAMETER FromPypi
|
||||||
|
Install `orisys` from PyPI instead of a local checkout.
|
||||||
|
|
||||||
|
.PARAMETER NoVerify
|
||||||
|
Skip the post-install verification (not recommended; that verification is what catches a
|
||||||
|
provisioning which would produce an EXE that cannot start).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
setup.bat
|
||||||
|
setup.bat -Force
|
||||||
|
setup.bat -Python C:\Python310\python.exe
|
||||||
|
setup.bat -AllowConda
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$Python = "",
|
||||||
|
[string]$SdkPath = "",
|
||||||
|
[switch]$Force,
|
||||||
|
[switch]$AllowConda,
|
||||||
|
[switch]$FromPypi,
|
||||||
|
[switch]$NoVerify
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$StudioDir = $PSScriptRoot
|
||||||
|
$RepoRoot = (Resolve-Path (Join-Path $StudioDir "..\..")).Path
|
||||||
|
$VenvDir = Join-Path $RepoRoot ".venv-build"
|
||||||
|
$VenvPy = Join-Path $VenvDir "Scripts\python.exe"
|
||||||
|
$VenvCfg = Join-Path $VenvDir "pyvenv.cfg"
|
||||||
|
$StampPath = Join-Path $VenvDir "orisys-build-env.json"
|
||||||
|
$Constraints = Join-Path $StudioDir "constraints-build.txt"
|
||||||
|
#: Bumped when the stamp's meaning changes: an old stamp must not be read as valid.
|
||||||
|
$StampVersion = 1
|
||||||
|
#: `pyinstaller` is pinned here as well as in the constraints file: it is the tool, not a
|
||||||
|
#: dependency of the app, and its version decides what the bundle contains.
|
||||||
|
$PyInstallerPin = "pyinstaller==6.22.2"
|
||||||
|
#: opencv-python 5.x has shipped wheels without a `cv2` module; the app is tested on 4.x.
|
||||||
|
$CvPin = "opencv-python-headless>=4.10.0.84,<5"
|
||||||
|
|
||||||
|
# A condition the operator has to fix, with the instructions in the message itself.
|
||||||
|
function Stop-With([string]$message) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host $message -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# PowerShell 5.1's `Set-Content -Encoding UTF8` writes a BOM, and a BOM makes Python's
|
||||||
|
# `json.loads` raise ("Unexpected UTF-8 BOM"). The spec reads this stamp, so a BOM here would
|
||||||
|
# quietly drop the extra runtime DLLs and the EXE would not start.
|
||||||
|
function Write-JsonFile([string]$path, [string]$json) {
|
||||||
|
[System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
# A path that is really a Python: the Microsoft Store alias on PATH answers `python` but is
|
||||||
|
# not an interpreter, and it would otherwise be silently chosen.
|
||||||
|
function Test-BasePython([string]$exe) {
|
||||||
|
if (-not $exe) { return $null }
|
||||||
|
if ($exe -match '\\WindowsApps\\') { return $null }
|
||||||
|
if (-not (Test-Path $exe)) { return $null }
|
||||||
|
try {
|
||||||
|
$out = & $exe -c "import sys; print(sys.executable)" 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $out) { return $null }
|
||||||
|
return ($out | Select-Object -Last 1).Trim()
|
||||||
|
} catch {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-BasePython([string]$explicit) {
|
||||||
|
if ($explicit) {
|
||||||
|
$ok = Test-BasePython $explicit
|
||||||
|
if (-not $ok) { Stop-With "-Python is not a usable interpreter: $explicit" }
|
||||||
|
return $ok
|
||||||
|
}
|
||||||
|
$tried = @()
|
||||||
|
if (Get-Command py.exe -ErrorAction SilentlyContinue) {
|
||||||
|
$out = & py -3.10 -c "import sys; print(sys.executable)" 2>$null
|
||||||
|
$tried += "py -3.10"
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $out) {
|
||||||
|
$ok = Test-BasePython (($out | Select-Object -Last 1).Trim())
|
||||||
|
if ($ok) { return $ok }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($name in @("python3.10.exe", "python.exe")) {
|
||||||
|
$found = Get-Command $name -ErrorAction SilentlyContinue
|
||||||
|
if (-not $found) { continue }
|
||||||
|
$tried += $found.Source
|
||||||
|
$ok = Test-BasePython $found.Source
|
||||||
|
if ($ok) { return $ok }
|
||||||
|
}
|
||||||
|
# Last resort: whatever already backs this venv. An existing build environment is
|
||||||
|
# evidence of an interpreter that works on this machine.
|
||||||
|
if (Test-Path $VenvCfg) {
|
||||||
|
$match = Select-String -Path $VenvCfg -Pattern '^home\s*=\s*(.+)$'
|
||||||
|
if ($match) {
|
||||||
|
$baseHome = $match.Matches[0].Groups[1].Value.Trim()
|
||||||
|
$tried += "$baseHome (from pyvenv.cfg)"
|
||||||
|
$ok = Test-BasePython (Join-Path $baseHome "python.exe")
|
||||||
|
if ($ok) { return $ok }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stop-With "no usable Python found (tried: $($tried -join ', ')). Install CPython 3.10+ and pass -Python <path>."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PythonFacts([string]$exe) {
|
||||||
|
$json = & $exe -c "import json,os,sys;print(json.dumps({'exe':sys.executable,'version':sys.version.split()[0],'prefix':sys.prefix,'conda':os.path.isdir(os.path.join(sys.prefix,'conda-meta')) or 'conda' in sys.prefix.lower()}))"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "could not query $exe" }
|
||||||
|
return ($json | Select-Object -Last 1 | ConvertFrom-Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Orisys Studio build environment"
|
||||||
|
Write-Host " repo : $RepoRoot"
|
||||||
|
Write-Host " venv : $VenvDir"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- stamp check
|
||||||
|
if ((Test-Path $VenvPy) -and (Test-Path $StampPath) -and -not $Force) {
|
||||||
|
$stamp = Get-Content $StampPath -Raw | ConvertFrom-Json
|
||||||
|
if ($stamp.stamp_version -eq $StampVersion -and $stamp.checkout_root -eq $RepoRoot) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Already provisioned ($($stamp.created)); nothing to do."
|
||||||
|
Write-Host " base python : $($stamp.base_python) ($($stamp.python_version))"
|
||||||
|
Write-Host " sdk : $($stamp.sdk)"
|
||||||
|
Write-Host "Use -Force to rebuild the venv, or go straight to ..\studio\build.bat"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
Write-Host "The existing venv was provisioned for another checkout or an older stamp: repairing it."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- interpreter
|
||||||
|
$BasePy = Resolve-BasePython $Python
|
||||||
|
$facts = Get-PythonFacts $BasePy
|
||||||
|
Write-Host " base python : $($facts.exe) ($($facts.version))"
|
||||||
|
|
||||||
|
$extraDllDirs = @()
|
||||||
|
if ($facts.conda) {
|
||||||
|
$libraryBin = Join-Path $facts.prefix "Library\bin"
|
||||||
|
if (-not $AllowConda) {
|
||||||
|
Stop-With @"
|
||||||
|
$($facts.exe) is an Anaconda interpreter.
|
||||||
|
|
||||||
|
PyInstaller does not search Anaconda's Library\bin, where libexpat.dll, ffi-8.dll,
|
||||||
|
libssl/libcrypto and sqlite3.dll live, so the EXE it produces fails at start-up with
|
||||||
|
"ImportError: DLL load failed while importing pyexpat". Either:
|
||||||
|
|
||||||
|
* build with python.org CPython (recommended): setup.bat -Python C:\Python310\python.exe
|
||||||
|
* or accept the risk: setup.bat -AllowConda
|
||||||
|
|
||||||
|
-AllowConda records $libraryBin in the build stamp and the spec adds those DLLs.
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
if (Test-Path $libraryBin) {
|
||||||
|
$extraDllDirs += $libraryBin
|
||||||
|
Write-Host " conda : accepted; runtime DLLs will be taken from $libraryBin"
|
||||||
|
} else {
|
||||||
|
Write-Warning "conda prefix $($facts.prefix) has no Library\bin; the EXE may not start"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- the venv
|
||||||
|
if ($Force -and (Test-Path $VenvDir)) {
|
||||||
|
Write-Host "Removing $VenvDir"
|
||||||
|
Remove-Item $VenvDir -Recurse -Force
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $VenvPy)) {
|
||||||
|
Write-Host "Creating venv with $BasePy"
|
||||||
|
& $BasePy -m venv $VenvDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "python -m venv failed" }
|
||||||
|
} else {
|
||||||
|
Write-Host "Reusing the existing venv (use -Force to recreate it)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ the SDK
|
||||||
|
$sdkSource = ""
|
||||||
|
if (-not $FromPypi) {
|
||||||
|
$candidate = if ($SdkPath) { $SdkPath } else { Join-Path (Split-Path $RepoRoot -Parent) "orisys-sdk" }
|
||||||
|
if ($candidate -and (Test-Path (Join-Path $candidate "pyproject.toml"))) {
|
||||||
|
$sdkSource = (Resolve-Path $candidate).Path
|
||||||
|
} elseif ($SdkPath) {
|
||||||
|
throw "-SdkPath has no pyproject.toml: $SdkPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $sdkSource) {
|
||||||
|
Write-Warning "No local orisys SDK checkout found; installing 'orisys>=0.5' from PyPI. The EXE will not contain the SDK under test unless that is what you intend."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- install
|
||||||
|
Write-Host "Installing the pinned toolchain and the app's own packages ..."
|
||||||
|
$install = @("-m", "pip", "install", "--disable-pip-version-check")
|
||||||
|
if (Test-Path $Constraints) { $install += @("-c", $Constraints) }
|
||||||
|
if ($sdkSource) { $install += @("-e", $sdkSource) } else { $install += "orisys>=0.5" }
|
||||||
|
$install += @("-e", (Join-Path $RepoRoot "shared"))
|
||||||
|
# The `three-d` extra is the GL binding. Without it the app still builds, and the 3D pane
|
||||||
|
# silently reports "PyOpenGL is not installed" on the customer's machine.
|
||||||
|
$install += @("-e", "$(Join-Path $RepoRoot 'apps\studio')[three-d]")
|
||||||
|
$install += @($PyInstallerPin, $CvPin)
|
||||||
|
& $VenvPy @install
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pip install failed" }
|
||||||
|
|
||||||
|
# Upstream `orisys` may declare opencv-python; the app needs the headless wheel (PyQt owns
|
||||||
|
# the GUI), and 5.x can arrive without a `cv2` module.
|
||||||
|
& $VenvPy -m pip uninstall -y opencv-python opencv-python-headless 2>$null | Out-Null
|
||||||
|
& $VenvPy -m pip install --disable-pip-version-check -c $Constraints $CvPin
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "opencv-python-headless install failed" }
|
||||||
|
|
||||||
|
# Open3D must not be in the build env: it is an offline tool, and the spec excludes it.
|
||||||
|
$hasOpen3d = & $VenvPy -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
|
||||||
|
if ($hasOpen3d -eq "True") {
|
||||||
|
Write-Host "Uninstalling open3d from the build venv ..."
|
||||||
|
& $VenvPy -m pip uninstall -y open3d
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- verification
|
||||||
|
if (-not $NoVerify) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Verifying the provisioned environment ..."
|
||||||
|
# These are the imports the frozen app makes before it can draw anything. Checking them
|
||||||
|
# here is what turns "the build succeeded" into "the build can start".
|
||||||
|
& $VenvPy -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyQt5.QtWidgets, PyInstaller; print('imports OK')"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "the venv cannot import what the app imports (see above)" }
|
||||||
|
# Theme colours are package data: PyInstaller only bundles them if the package is
|
||||||
|
# importable at build time, so a stale editable install loses them silently.
|
||||||
|
& $VenvPy -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "theme data would not be bundled" }
|
||||||
|
# When a local checkout was asked for, the installed SDK must be that checkout.
|
||||||
|
if ($sdkSource) {
|
||||||
|
& $VenvPy -c "import orisys, os; p=os.path.dirname(orisys.__file__); assert os.path.isfile(os.path.join(p,'finger','__init__.py')), 'orisys.finger missing from '+p; print('SDK OK:', p)"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "the installed SDK has no finger package" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ stamp
|
||||||
|
$stampData = [ordered]@{
|
||||||
|
stamp_version = $StampVersion
|
||||||
|
checkout_root = $RepoRoot
|
||||||
|
created = (Get-Date).ToString("o")
|
||||||
|
base_python = $facts.exe
|
||||||
|
python_version = $facts.version
|
||||||
|
is_conda = [bool]$facts.conda
|
||||||
|
conda_prefix = $facts.prefix
|
||||||
|
extra_dll_dirs = @($extraDllDirs)
|
||||||
|
sdk = $sdkSource
|
||||||
|
venv = $VenvDir
|
||||||
|
}
|
||||||
|
Write-JsonFile $StampPath ($stampData | ConvertTo-Json -Depth 4)
|
||||||
|
Write-Host "Wrote $StampPath"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Environment ready. Next:"
|
||||||
|
Write-Host " build.bat # build the EXE"
|
||||||
|
Write-Host " build.bat -Clean # after dependency/spec changes"
|
||||||
|
Write-Host " build.bat -SkipSmokeTest # skip launching the produced EXE"
|
||||||
Reference in New Issue
Block a user