official 0.1.0 version

This commit is contained in:
ykcheungab
2026-09-16 09:43:57 +08:00
parent c538dbf074
commit 07d7c3bad6
7 changed files with 762 additions and 92 deletions
+235 -58
View File
@@ -1,90 +1,134 @@
#Requires -Version 5.1
<#
.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
Pass --clean to PyInstaller (slower; use when deps/spec change).
Pass --clean to PyInstaller (slower; use after dependency or spec changes).
.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(
[switch]$Clean,
[switch]$SkipInstall
[switch]$SkipInstall,
[switch]$RebuildVenv,
[switch]$SkipSmokeTest,
[switch]$StrictSmokeTest,
[string]$GlProfile = "desktop33"
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$StudioDir = $PSScriptRoot
$RepoRoot = (Resolve-Path (Join-Path $StudioDir "..\..")).Path
$VenvDir = Join-Path $RepoRoot ".venv-build"
$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"
$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"
if (-not (Test-Path $Python)) {
Write-Host "Creating build venv at $VenvDir"
python -m venv $VenvDir
# --------------------------------------------------------------- 1. provisioning
if ($RebuildVenv) {
Write-Host "Recreating the build environment ..."
& $Setup -Force
Assert-Ok "setup.ps1 -Force"
}
if (-not $SkipInstall) {
Write-Host "Installing build dependencies (editable shared + studio + orisys + pyinstaller)…"
& $Python -m pip install --upgrade pip
& $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
}
$stampRoot = ""
if (Test-Path $StampPath) {
$stampRoot = (Get-Content $StampPath -Raw | ConvertFrom-Json).checkout_root
}
$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 = @(
"--noconfirm",
"--distpath", $DistPath,
"--workpath", $WorkPath,
$Spec
)
if ($Clean) {
$pyiArgs = @("--clean") + $pyiArgs
}
if ($Clean) { $pyiArgs = @("--clean") + $pyiArgs }
Write-Host "Running PyInstaller…"
Write-Host "Running PyInstaller ..."
& $Python -m PyInstaller @pyiArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "PyInstaller failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
Assert-Ok "PyInstaller"
$OutDir = Join-Path $DistPath "OrisysStudio"
Write-Host ""
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"
$ConfigDst = Join-Path $OutDir "config"
if (Test-Path $ConfigSrc) {
Write-Host "Copying lean config to $ConfigDst …"
if (Test-Path $ConfigDst) {
Remove-Item $ConfigDst -Recurse -Force
}
Write-Host "Copying lean config to $ConfigDst ..."
if (Test-Path $ConfigDst) { Remove-Item $ConfigDst -Recurse -Force }
New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null
Get-ChildItem $ConfigSrc -Force | ForEach-Object {
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) {
$bytes = (Get-ChildItem $OutDir -Recurse -File -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum
$mb = [math]::Round($bytes / 1MB, 1)
Write-Host ("Output size: {0} MB" -f $mb)
$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."
}
Write-Host "Launch: $Exe"
}
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
+27
View File
@@ -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
+53 -2
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from PyInstaller.building.api import COLLECT, EXE, PYZ
@@ -13,6 +15,50 @@ SPEC_DIR = Path(SPECPATH).resolve()
REPO_ROOT = SPEC_DIR.parents[1]
BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding"
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]]:
@@ -30,7 +76,7 @@ block_cipher = None
# Config is copied beside the EXE by build.ps1 (writable / editable).
# Branding + package data go into _internal via Analysis datas.
datas = _branding_datas()
binaries: list = []
binaries: list = _extra_runtime_binaries()
hiddenimports = [
"cv2",
"PyQt5.sip",
@@ -68,7 +114,12 @@ a = Analysis(
runtime_hooks=[],
excludes=[
"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",
"matplotlib",
"IPython",
+21
View File
@@ -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%
+292
View File
@@ -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"