From 5429c07342376cacef2ede3bb1e7cdfd0048c3fd Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 7 Oct 2026 15:48:57 +0800 Subject: [PATCH] Fix portable Python: ship python/runtime and relink .venv on install (0.4.6). 0.4.5 venv pointed at the build machine user profile; other PCs failed to start API. Bundle stdlib under python/runtime, repair pyvenv.cfg, and health-check before start. Co-authored-by: Cursor --- packaging/README.md | 2 +- packaging/_common.ps1 | 93 +++++++++++++++++++++++++++++++++-- packaging/build_release.ps1 | 42 ++++++++++++++-- packaging/installer/netx.iss | 2 +- packaging/setup_first_run.ps1 | 22 ++++----- pyproject.toml | 2 +- scripts/start_netx.ps1 | 22 +++++++-- 7 files changed, 158 insertions(+), 27 deletions(-) diff --git a/packaging/README.md b/packaging/README.md index 449b1a1..951db52 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -19,7 +19,7 @@ This directory builds a Windows deliverable with: **Installer:** English + 简体中文 (language dialog). Icons use `packaging/assets/netx.ico`. -**Offline:** Setup ships portable PostgreSQL, `.venv` (when built with `-CreateVenv`), and WinSW. The installer wizard chooses **built-in or external** PostgreSQL and validates external credentials (`psql SELECT 1`) before files are installed — **no GitHub/EDB download on the target PC**. Service install uses bundled WinSW (pass `-AllowDownload` only on a build/dev machine if the binary is missing). Auto-update still needs network later, and is unchecked by default. +**Offline:** Setup ships portable PostgreSQL, **`python/runtime` + `.venv`** (portable; not tied to the build PC’s user profile), and WinSW. The installer wizard chooses **built-in or external** PostgreSQL and validates external credentials (`psql SELECT 1`) before files are installed — **no GitHub/EDB download on the target PC**. Service install uses bundled WinSW (pass `-AllowDownload` only on a build/dev machine if the binary is missing). Auto-update still needs network later, and is unchecked by default. **OS:** Windows 10/11 or Windows Server **2016+** recommended. Packaging scripts are UTF-8 **with BOM** so Chinese UI works on Windows PowerShell 5.x. Bundled Python in current releases is **3.13+**, which does **not** support Windows Server 2012 R2 — use Server 2016+ or a newer desktop OS. diff --git a/packaging/_common.ps1 b/packaging/_common.ps1 index 556bf5a..a73f544 100644 --- a/packaging/_common.ps1 +++ b/packaging/_common.ps1 @@ -276,12 +276,21 @@ function Start-NetxPowerShell { function Get-NetxSystemPython { # Prefer a real interpreter; skip the WindowsApps Store stub. $candidates = @() - foreach ($cmd in @("python", "python3", "py")) { + foreach ($cmd in @("python", "python3")) { $c = Get-Command $cmd -ErrorAction SilentlyContinue if ($c -and $c.Source -and ($c.Source -notmatch '\\WindowsApps\\')) { $candidates += $c.Source } } + $pyLauncher = Get-Command "py" -ErrorAction SilentlyContinue + if ($pyLauncher -and $pyLauncher.Source -and ($pyLauncher.Source -notmatch '\\WindowsApps\\')) { + try { + $resolved = (& $pyLauncher.Source -3 -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim() + if ($resolved -and (Test-Path -LiteralPath $resolved)) { + $candidates += $resolved + } + } catch {} + } foreach ($p in @( "$env:LOCALAPPDATA\Python\pythoncore-3.14-64\python.exe", "$env:LOCALAPPDATA\Programs\Python\Python312\python.exe", @@ -300,6 +309,65 @@ function Get-NetxSystemPython { return $null } +function Get-NetxBundledPythonRoot { + param([Parameter(Mandatory = $true)][string]$ProgramRoot) + return Join-Path $ProgramRoot "python\runtime" +} + +function Repair-NetxShippedVenv { + param([Parameter(Mandatory = $true)][string]$ProgramRoot) + $cfgPath = Join-Path $ProgramRoot ".venv\pyvenv.cfg" + $rtRoot = Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot + $rtPy = Join-Path $rtRoot "python.exe" + if (-not (Test-Path -LiteralPath $rtPy)) { return $false } + if (-not (Test-Path -LiteralPath $cfgPath)) { return $false } + + $rtRootAbs = (Resolve-Path -LiteralPath $rtRoot).Path + $rtPyAbs = (Resolve-Path -LiteralPath $rtPy).Path + $ver = "3.14.0" + try { + $verOut = & $rtPyAbs -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null + if ($verOut) { $ver = ($verOut | Out-String).Trim() } + } catch {} + + $lines = @( + "home = $rtRootAbs", + "include-system-site-packages = false", + "version = $ver", + "executable = $rtPyAbs" + ) + Set-Content -LiteralPath $cfgPath -Value ($lines -join "`r`n") -Encoding ascii + return $true +} + +function Test-NetxVenvRunnable { + param([Parameter(Mandatory = $true)][string]$VenvPython) + if (-not (Test-Path -LiteralPath $VenvPython)) { return $false } + + $outFile = Join-Path $env:TEMP ("netx-venv-out-" + [Guid]::NewGuid().ToString("n") + ".txt") + $errFile = Join-Path $env:TEMP ("netx-venv-err-" + [Guid]::NewGuid().ToString("n") + ".txt") + try { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $VenvPython + $psi.Arguments = '-c "import encodings, sys; sys.exit(0 if sys.prefix else 1)"' + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + $p = [Diagnostics.Process]::Start($psi) + $stderr = $p.StandardError.ReadToEnd() + $null = $p.StandardOutput.ReadToEnd() + $p.WaitForExit() + if ($stderr -match 'did not find executable|No Python at|Fatal Python error') { + return $false + } + if ($p.ExitCode -ne 0) { return $false } + return $true + } finally { + Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue + } +} + function Ensure-NetxVenv { param( [Parameter(Mandatory = $true)][string]$ProgramRoot, @@ -307,8 +375,27 @@ function Ensure-NetxVenv { ) $venvPy = Join-Path $ProgramRoot ".venv\Scripts\python.exe" $req = Join-Path $ProgramRoot "requirements.txt" - if ((Test-Path $venvPy) -and -not $ForcePip) { - return $venvPy + $rtPy = Join-Path (Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot) "python.exe" + + if (Test-Path -LiteralPath $rtPy) { + $null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot + } + + if ((Test-Path -LiteralPath $venvPy) -and -not $ForcePip) { + if (Test-NetxVenvRunnable -VenvPython $venvPy) { + return $venvPy + } + Write-Host "[WARN] Shipped .venv exists but Python cannot start (broken pyvenv.cfg or missing runtime)." -ForegroundColor Yellow + if (Test-Path -LiteralPath $rtPy) { + $null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot + if (Test-NetxVenvRunnable -VenvPython $venvPy) { + Write-Host "==> Repaired .venv to use bundled python/runtime" -ForegroundColor Green + return $venvPy + } + } + if (-not $ForcePip) { + throw "venv_not_runnable: reinstall NetX Setup (ships python/runtime + .venv) or run repair as admin" + } } if (-not (Test-Path $venvPy)) { $py = Get-NetxSystemPython diff --git a/packaging/build_release.ps1 b/packaging/build_release.ps1 index 6c3ab6d..d737114 100644 --- a/packaging/build_release.ps1 +++ b/packaging/build_release.ps1 @@ -168,16 +168,50 @@ foreach ($cmdName in @("NetX-FirstRun.cmd", "NetX-Start.cmd", "NetX-Tray.cmd", " } if ($CreateVenv) { - Write-Host "==> Creating .venv in stage (ships with Setup; target should NOT pip-install)" + Write-Host "==> Creating portable python/runtime + .venv (must not reference build-machine paths)" $pyPath = Get-NetxSystemPython if (-not $pyPath) { throw "python_not_found_for_venv: need Python 3.11+ (not WindowsApps stub)" } - Write-Host " Using: $pyPath" - & $pyPath -m venv (Join-Path $stage ".venv") - $venvPy = Join-Path $stage ".venv\Scripts\python.exe" + Write-Host " Build Python: $pyPath" + + $prefix = (& $pyPath -c "import sys; print(sys.base_prefix)" 2>$null | Out-String).Trim() + if (-not $prefix -or -not (Test-Path -LiteralPath $prefix)) { + throw "python_base_prefix_not_found: $prefix" + } + $rtDir = Join-Path $stage "python\runtime" + if (Test-Path $rtDir) { Remove-Item -Recurse -Force $rtDir } + New-Item -ItemType Directory -Path $rtDir -Force | Out-Null + + Write-Host "==> Copying Python stdlib/runtime to $rtDir (exclude base site-packages)" + $spEx = Join-Path $prefix "Lib\site-packages" + $robocopy = Join-Path $env:SystemRoot "System32\robocopy.exe" + if (-not (Test-Path -LiteralPath $robocopy)) { throw "robocopy_not_found" } + & $robocopy $prefix $rtDir /E /XD $spEx __pycache__ /XF *.pyc /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null + if ($LASTEXITCODE -ge 8) { throw "robocopy_python_runtime_failed: exit $LASTEXITCODE" } + + $rtPy = Join-Path $rtDir "python.exe" + if (-not (Test-Path -LiteralPath $rtPy)) { throw "python_runtime_missing: $rtPy" } + + $venvDir = Join-Path $stage ".venv" + if (Test-Path $venvDir) { Remove-Item -Recurse -Force $venvDir } + Write-Host "==> Creating .venv from shipped runtime (--copies)" + & $rtPy -m venv $venvDir --copies + if ($LASTEXITCODE -ne 0) { throw "venv_create_failed" } + + $venvPy = Join-Path $venvDir "Scripts\python.exe" + $null = Repair-NetxShippedVenv -ProgramRoot $stage + if (-not (Test-NetxVenvRunnable -VenvPython $venvPy)) { + throw "venv_not_runnable_after_build: check python/runtime copy" + } + & $venvPy -m pip install --upgrade pip & $venvPy -m pip install -r (Join-Path $stage "requirements.txt") if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" } + $null = Repair-NetxShippedVenv -ProgramRoot $stage + if (-not (Test-NetxVenvRunnable -VenvPython $venvPy)) { + throw "venv_not_runnable_after_pip" + } + Write-Host "==> Slimming .venv (drop tests / __pycache__ / *.pyc)" $site = Join-Path $stage ".venv\Lib\site-packages" if (Test-Path $site) { diff --git a/packaging/installer/netx.iss b/packaging/installer/netx.iss index 561d369..335ce0c 100644 --- a/packaging/installer/netx.iss +++ b/packaging/installer/netx.iss @@ -5,7 +5,7 @@ #define MyAppName "NetX" #ifndef MyAppVersion - #define MyAppVersion "0.4.5" + #define MyAppVersion "0.4.6" #endif #define MyAppPublisher "NetX" #define MyAppURL "https://github.com/hansjone/netx" diff --git a/packaging/setup_first_run.ps1 b/packaging/setup_first_run.ps1 index 78bd539..c865723 100644 --- a/packaging/setup_first_run.ps1 +++ b/packaging/setup_first_run.ps1 @@ -293,20 +293,18 @@ Write-DotEnvValue -Path $envPath -Values $values Write-Host "" Write-Host "Wrote $envPath" -ForegroundColor Green -# Official Setup ships .venv (build_release -CreateVenv). Only bootstrap if missing -# (e.g. zip without venv). Prefer not to pip-install on the target machine. +# Official Setup ships python/runtime + .venv (build_release -CreateVenv). $venvPy = Join-Path $prog ".venv\Scripts\python.exe" -if (Test-Path $venvPy) { - Write-Host "==> Using bundled Python venv: $venvPy" -ForegroundColor Green -} else { - Write-Host "==> Bundled .venv missing — creating one (Setup should normally ship it)." -ForegroundColor Yellow - try { - $null = Ensure-NetxVenv -ProgramRoot $prog - Write-Host "==> Python venv ready: $venvPy" -ForegroundColor Green - } catch { - Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow - Write-Host " Install a Setup built with: packaging\build_release.ps1 -CreateVenv" -ForegroundColor Yellow +try { + $null = Ensure-NetxVenv -ProgramRoot $prog + Write-Host "==> Bundled Python venv OK: $venvPy" -ForegroundColor Green +} catch { + if (Test-Path $venvPy) { + throw } + Write-Host "==> Bundled .venv missing — creating one (Setup should normally ship it)." -ForegroundColor Yellow + Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow + Write-Host " Install a Setup built with: packaging\build_release.ps1 -CreateVenv" -ForegroundColor Yellow } Write-Host "" diff --git a/pyproject.toml b/pyproject.toml index 8c54492..3e59513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netx-ops" -version = "0.4.5" +version = "0.4.6" description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/start_netx.ps1 b/scripts/start_netx.ps1 index 3f2fabe..d1ba8ea 100644 --- a/scripts/start_netx.ps1 +++ b/scripts/start_netx.ps1 @@ -23,6 +23,14 @@ if ($Backgtound -and -not $Background) { $projectRoot = Split-Path -Parent $PSScriptRoot Set-Location $projectRoot +$packCommon = Join-Path $projectRoot "packaging\_common.ps1" +if (Test-Path -LiteralPath $packCommon) { + . $packCommon + if (Get-Command Repair-NetxShippedVenv -ErrorAction SilentlyContinue) { + $null = Repair-NetxShippedVenv -ProgramRoot $projectRoot + } +} + # Prefer writable data-root runtime dir (Program Files is not writable after Setup install). if ($env:NETX_RUN_DIR) { $runDir = $env:NETX_RUN_DIR @@ -46,13 +54,17 @@ $webPidFile = Join-Path $runDir "web.pid" $webLogFile = Join-Path $runDir "web.out.log" $webErrFile = Join-Path $runDir "web.err.log" -$venvPython = Join-Path $projectRoot ".venv\\Scripts\\python.exe" -if (-not (Test-Path $venvPython)) { +$venvPython = Join-Path $projectRoot ".venv\Scripts\python.exe" +if (Get-Command Test-NetxVenvRunnable -ErrorAction SilentlyContinue) { + if (-not (Test-NetxVenvRunnable -VenvPython $venvPython)) { + throw "venv_not_runnable: reinstall NetX or run setup as administrator" + } +} elseif (-not (Test-Path -LiteralPath $venvPython)) { Write-Host "==> .venv not found, creating virtual environment" python -m venv (Join-Path $projectRoot ".venv") -} -if (-not (Test-Path $venvPython)) { - throw "failed_to_create_venv" + if (-not (Test-Path -LiteralPath $venvPython)) { + throw "failed_to_create_venv" + } } $pythonExe = $venvPython