#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 `\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 ." } 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 (PySide6 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" } # PyQt5 must not be in the build environment, even though the SDK's `gui` extra asks for it # when someone installs that: `pyqtgraph` binds to whichever toolkit it imports first, and a # frozen app built where PyQt5 is present can end up with plots built from the wrong binding. # The apps are PySide6-only (see apps/studio/tests/test_binding_purity.py). Write-Host "Removing any other Qt binding from the build venv ..." & $VenvPy -m pip uninstall -y PyQt5 PyQt5-Qt5 PyQt5-sip PyQt6 PySide2 2>$null | Out-Null # 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, PySide6.QtWidgets, PySide6.QtOpenGLWidgets, PyInstaller; print('imports OK')" if ($LASTEXITCODE -ne 0) { throw "the venv cannot import what the app imports (see above)" } # A second binding in the venv is the failure mode the uninstall above exists for: it # imports fine here and breaks `pyqtgraph` at run time. & $VenvPy -c "import importlib.util as u; bad=[m for m in ('PyQt5','PyQt6','PySide2') if u.find_spec(m)]; assert not bad, 'another Qt binding is installed: '+str(bad); print('binding OK: PySide6 only')" if ($LASTEXITCODE -ne 0) { throw "the build venv has more than one Qt binding" } # 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"