🚧 WIP
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user