Files
2026-10-08 06:30:54 +02:00

229 lines
9.3 KiB
PowerShell

# Build the Windows executable (dist\opcda-diagnozer.exe) with PyInstaller.
#
# Usage (from the repo root):
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build_exe.ps1
# .\scripts\build_exe.ps1 # onedir (default): dist\opcda-diagnozer\
# .\scripts\build_exe.ps1 -Onefile # single exe: dist\opcda-diagnozer.exe
# .\scripts\build_exe.ps1 -Clean # remove build\ + dist\ first
#
# The exe is a console application hosting every CLI verb (check-config,
# list-servers, list-devices, list-tags, the future run/probe/service verbs).
# It is not itself a service binary: the Phase 3 service host will register
# this same exe with the SCM via the 'install' verb (IMPLEMENTATION_PLAN 6).
#
# Hard rules enforced here:
# 1. 32-bit interpreter (SPECIFICATION 11): the OPC DA Automation Wrapper
# (OPCAutomation.dll) is a 32-bit in-proc COM DLL - a 64-bit exe cannot
# load it and every COM call fails at runtime. The produced PE is
# re-verified to be 32-bit (IMAGE_FILE_MACHINE_I386) after the build.
# 2. PyInstaller must see the modules the code imports lazily inside
# functions (COM, browse, service host) - declared via -HiddenImport.
[CmdletBinding()]
param(
[switch]$Onefile,
[switch]$Clean
)
$ErrorActionPreference = 'Stop'
$Repo = Split-Path -Parent $PSScriptRoot
$VenvPython = Join-Path $Repo '.venv\Scripts\python.exe'
$Entry = Join-Path $Repo 'src\opcda_diagnozer\__main__.py'
$Dist = Join-Path $Repo 'dist'
$Build = Join-Path $Repo 'build'
$Name = 'opcda-diagnozer'
$VersionFile = Join-Path $PSScriptRoot '.exe_version_info.txt'
# Modules imported lazily inside functions (import guards, IMPLEMENTATION_PLAN 9);
# PyInstaller's static analysis misses those, so they are declared explicitly.
$HiddenImports = @(
# pywin32 / COM - deferred into methods in opc/automation.py, opc/discovery.py
'pythoncom',
'pywintypes',
'win32com.client',
# pywintypes.datetime(VARIANT DATE -> datetime conversion) lazily imports
# win32timezone at CONVERSION time - missing from the bundle, every read of
# item.TimeStamp raised SystemError('InvokeTypes ... exception set') frozen
# (observed 2026-10-07: all tags failed in the exe while the venv worked).
'win32timezone',
# opc backend modules imported lazily in main.py CLI verbs
'opcda_diagnozer.opc.automation',
'opcda_diagnozer.opc.browse',
# future service host (Phase 3) - keep it in the bundle from day one
'opcda_diagnozer.service'
)
function Fail {
param([string]$Message)
Write-Host 'build_exe: FAILED' -ForegroundColor Red
Write-Host " $Message" -ForegroundColor Red
exit 1
}
function Invoke-Captured {
# Run a native command and capture stdout+stderr with its exit code.
# $ErrorActionPreference must be relaxed around 2>&1 on PS 5.1: with 'Stop',
# redirecting native stderr raises a NativeCommandError and kills the script.
param([string]$FilePath, [string[]]$Arguments)
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$lines = & $FilePath @Arguments 2>&1
$text = ($lines | ForEach-Object { "$_" }) -join [Environment]::NewLine
[pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $text }
}
finally {
$ErrorActionPreference = $prev
}
}
# --- environment checks ------------------------------------------------------
if (-not (Test-Path $VenvPython)) {
Fail ("the 32-bit venv was not found: $VenvPython - create it first " +
"(README 'Development setup'): " +
'& "C:\...\Python314-32\python.exe" -m venv .venv; ' +
'& .\.venv\Scripts\python.exe -m pip install -e ".[dev]"')
}
$bitness = (Invoke-Captured -FilePath $VenvPython -Arguments @('-c', "import struct; print(struct.calcsize('P') * 8)")).Output.Trim()
if ($bitness -ne '32') {
Fail ("the venv interpreter is ${bitness}-bit, but the OPC DA wrappers are " +
'32-bit in-proc DLLs (SPECIFICATION 11): a 64-bit exe cannot load ' +
"them. Rebuild the venv with the 32-bit Python (README 'Development setup').")
}
Write-Host 'build_exe: OK - 32-bit interpreter (SPECIFICATION 11 bitness rule)'
$pyi = Invoke-Captured -FilePath $VenvPython -Arguments @('-m', 'PyInstaller', '--version')
if ($pyi.ExitCode -ne 0) {
Write-Host 'build_exe: pyinstaller missing - installing into the venv ...'
& $VenvPython -m pip install pyinstaller
if ($LASTEXITCODE -ne 0) { Fail 'pip install pyinstaller failed' }
}
if ($Clean) {
foreach ($folder in @($Build, $Dist)) {
if (Test-Path $folder) { Remove-Item -Recurse -Force $folder }
}
Write-Host 'build_exe: cleaned build/ and dist/'
}
# --- version metadata (exe properties -> Details tab) -------------------------
$initText = Get-Content (Join-Path $Repo 'src\opcda_diagnozer\__init__.py') -Raw
if ($initText -notmatch '__version__\s*=\s*"([^"]+)"') {
Fail 'could not read __version__ from src\opcda_diagnozer\__init__.py'
}
$Version = $Matches[1]
$parts = $Version.Split('.')
$major = $parts[0]
$minor = $parts[1]
# Keyword names must match PyInstaller's FixedFileInfo.__init__ exactly:
# filevers, prodvers, mask, flags, OS, fileType, subtype, date.
# [IO.File]::WriteAllText writes UTF-8 WITHOUT a BOM - Set-Content -Encoding
# UTF8 on PS 5.1 prepends one, and the BOM breaks PyInstaller's eval().
$VersionInfo = @"
# UTF-8
VSVersionInfo(
ffi=FixedFileInfo(
filevers=($major, $minor, 0, 0),
prodvers=($major, $minor, 0, 0),
mask=0x3F,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo(
[
StringTable(
'040904B0',
[
StringStruct('CompanyName', 'Vivab'),
StringStruct('FileDescription', 'OPC DA Diagnozer - read-only OPC DA reference diagnostic client'),
StringStruct('FileVersion', '$Version.0'),
StringStruct('InternalName', 'opcda-diagnozer'),
StringStruct('OriginalFilename', 'opcda-diagnozer.exe'),
StringStruct('ProductName', 'OPC DA Diagnozer'),
StringStruct('ProductVersion', '$Version')
]
)
]
),
VarFileInfo([VarStruct('Translation', [1033, 1200])])
]
)
"@
[IO.File]::WriteAllText($VersionFile, $VersionInfo)
# --- the build itself ---------------------------------------------------------
$pyArgs = @(
'-m', 'PyInstaller',
'--noconfirm', '--clean',
'--name', $Name,
'--distpath', $Dist,
'--workpath', $Build,
'--specpath', $Build,
'--console',
'--version-file', $VersionFile
)
foreach ($hidden in $HiddenImports) { $pyArgs += @('--hidden-import', $hidden) }
$pyArgs += $(if ($Onefile) { '--onefile' } else { '--onedir' })
$pyArgs += $Entry
Write-Host 'build_exe: running PyInstaller...'
& $VenvPython @pyArgs
if ($LASTEXITCODE -ne 0) { Fail "PyInstaller exited $LASTEXITCODE" }
$Exe = if ($Onefile) { Join-Path $Dist "$Name.exe" } else { Join-Path $Dist "$Name\$Name.exe" }
if (-not (Test-Path $Exe)) { Fail "expected exe not found: $Exe" }
Write-Host "build_exe: OK - $Exe"
# --- final bitness guard: the produced PE must be 32-bit (SPECIFICATION 11) --
$bytes = [System.IO.File]::ReadAllBytes($Exe)
$peOffset = [System.BitConverter]::ToInt32($bytes, 0x3C)
$machine = [System.BitConverter]::ToUInt16($bytes, $peOffset + 4)
if ($machine -ne 0x14C) {
Fail ("the built exe is not 32-bit (PE machine 0x{0:X4}) - build from the 32-bit venv" -f $machine)
}
Write-Host 'build_exe: OK - exe is 32-bit (PE machine 0x014C, IMAGE_FILE_MACHINE_I386)'
# --- smoke test ---------------------------------------------------------------
Write-Host "build_exe: smoke-testing $Name.exe ..."
$ExampleConfig = Join-Path $Repo 'config.example.yaml'
$checks = @(
@{ Label = '--version'; Arguments = @('--version') },
@{ Label = 'check-config'; Arguments = @('check-config', '--config', $ExampleConfig) },
@{ Label = 'list-servers (registry)'; Arguments = @('list-servers', '--source', 'registry') }
)
foreach ($check in $checks) {
$result = Invoke-Captured -FilePath $Exe -Arguments $check.Arguments
$first = ($result.Output -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 1)
Write-Host (" {0} -> exit {1}: {2}" -f $check.Label, $result.ExitCode, $first)
if ($result.ExitCode -ne 0) { Fail ("smoke test failed: " + $check.Label) }
}
# Frozen-read canary: the COM timestamp conversion (item.TimeStamp) lazily
# imports win32timezone - if it is missing from the bundle, EVERY read fails
# with SystemError('InvokeTypes ... exception set'). The registry/discovery
# smoke tests cannot catch this; only a real read does. Skipped gracefully
# when no COM server is reachable on this build host.
$canary = Invoke-Captured -FilePath $Exe -Arguments @(
'read', '--server', 'Matrikon.OPC.Simulation.1', 'Bucket Brigade.Int1'
)
$canaryFirst = ($canary.Output -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 2) -join ' | '
if ($canary.ExitCode -eq 0) {
Write-Host " read canary (Matrikon sim) -> exit 0: $canaryFirst"
} elseif ($canary.Output -match 'SystemError.*InvokeTypes') {
Fail ("frozen read canary failed with the missing-win32timezone signature: $canaryFirst")
} else {
Write-Host " read canary skipped (no Matrikon sim reachable): exit $($canary.ExitCode)"
}
Write-Host 'build_exe: smoke test OK'
exit 0