From 662a01410428e2b9b24cd371e83a8203cea65ee5 Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Fri, 2 Oct 2026 07:19:13 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20Add=20build=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- README.md | 42 ++++++++++++++++ build.ps1 | 127 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + run.py | 11 +++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 build.ps1 create mode 100644 run.py diff --git a/.gitignore b/.gitignore index 2ffcf2f..acb4d23 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ build/ dist/ .pytest_cache/ output/ -*.xlsx \ No newline at end of file +*.xlsx +*.spec \ No newline at end of file diff --git a/README.md b/README.md index ba66a6c..9c20489 100644 --- a/README.md +++ b/README.md @@ -116,10 +116,52 @@ pip install -e .[dev] pytest ``` +## Building a self-executing exe + +The CLI can be packaged as a standalone Windows executable with PyInstaller. + +```bash +pip install -e .[dev] # includes pyinstaller + +# one-folder build (default): fast start-up, distribute the whole folder +.\build.ps1 + +# single-file build: one .exe, slower start-up (self-extracting) +.\build.ps1 -Onefile + +# rebuild from scratch and smoke-test the exe against assets/ +.\build.ps1 -Clean -Test + +# explicit interpreter if several Pythons are installed +.\build.ps1 -Python C:\Path\To\python.exe +``` + +Results land in `dist/`: + +- **one-folder** (default): `dist/mitsubishi-label-converter/mitsubishi-label-converter.exe` + plus supporting DLLs — distribute the whole folder; starts fast. +- **onefile**: `dist/mitsubishi-label-converter.exe` — a single 8.8 MB file to + copy anywhere; a little slower to start. + +Notes: + +- The script prefers the project `.venv`, then `python`/`py` on PATH; the + Microsoft Store `python` alias is detected and skipped automatically. +- `run.py` at the project root is the packaging entry point (PyInstaller cannot + execute `cli.py` directly because of its relative imports). +- Antivirus software may flag unsigned PyInstaller exes; sign the exe or add + an exclusion if this matters in your environment. +- Build artifacts (`build/`, `dist/`, `*.spec`) are git-ignored. + ## TODO / Roadmap Done: +- [x] **Self-executing exe** — `build.ps1` wraps PyInstaller with + one-folder/onefile modes, clean rebuild, built-in smoke test, and robust + Python resolution; verified all three subcommands and the lossless + round-trip through the packaged exe. + - [x] **Expose struct members with addresses in Excel** — dotted member rows inline under each structured variable (189 structured variables -> 1,220 member rows in the sample file). diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..8dad7b8 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,127 @@ +# Build script: packages the CLI as a single self-executing .exe +# +# Usage: +# .\build.ps1 -> dist\mitsubishi-label-converter.exe +# .\build.ps1 -Clean -> remove build artifacts first +# .\build.ps1 -Onefile -> single-file exe (slower start, one file) +# default is one-folder mode (fast start) +# .\build.ps1 -Test -> run the exe against the real assets afterwards +# +# Requirements: pyinstaller installed in the active environment +# pip install pyinstaller + +[CmdletBinding()] +param( + [switch]$Clean, + [switch]$Onefile, + [switch]$Test, + # Explicit interpreter, e.g. -Python C:\Path\To\python.exe + [string]$Python +) + +$ErrorActionPreference = "Stop" +$root = $PSScriptRoot +$exename = "mitsubishi-label-converter" +$dist = Join-Path $root "dist" + +# Resolve a working Python interpreter ------------------------------- +# The Microsoft Store 'python' alias can shadow a real install, so verify +# each candidate by actually running it. +function Test-Python([string]$cmd) { + if (-not $cmd) { return $false } + try { + & $cmd -c "import sys; sys.exit(0)" 2>$null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +if (-not $Python) { + $candidates = @( + (Join-Path $root ".venv\Scripts\python.exe"), # project venv first + "python", + "py" + ) + foreach ($c in $candidates) { + if (Test-Python $c) { $Python = $c; break } + } +} +if (-not $Python -or -not (Test-Python $Python)) { + Write-Error @" +No working Python interpreter found. +Tried: project .venv, 'python' on PATH, 'py' launcher. +Create a venv (python -m venv .venv) or pass one explicitly: + .\build.ps1 -Python C:\Path\To\python.exe +"@ +} +Write-Host "==> Using Python: $Python" + +# Sanity checks ------------------------------------------------------------ +& $Python -m PyInstaller --version 2>$null | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Error "PyInstaller not found in '$Python'. Run: $Python -m pip install pyinstaller" +} + +if ($Clean) { + Write-Host "==> Cleaning build artifacts..." + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue ` + (Join-Path $root "build"), $dist + # remove the egg-info generated by editable installs + Get-ChildItem $root -Filter "*.egg-info" -Directory -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue +} + +# Build -------------------------------------------------------------------- +$modeArgs = @() +if ($Onefile) { $modeArgs += "--onefile" } else { $modeArgs += "--onedir" } + +Write-Host "==> Building $exename ($($modeArgs -join ' '))..." +& $Python -m PyInstaller @modeArgs ` + --name $exename ` + --noconfirm ` + --clean ` + --console ` + --paths "$root\src" ` + "$root\run.py" + +if ($LASTEXITCODE -ne 0) { + Write-Error "PyInstaller failed with exit code $LASTEXITCODE" +} + +# Locate the exe ----------------------------------------------------------- +if ($Onefile) { + $exe = Join-Path $dist "$exename.exe" +} else { + $exe = Join-Path $dist "$exename\$exename.exe" +} +if (-not (Test-Path $exe)) { + Write-Error "Build finished but exe not found at $exe" +} + +$size = "{0:N1} MB" -f ((Get-Item $exe).Length / 1MB) +Write-Host "==> Built: $exe ($size)" + +# Optional smoke test ------------------------------------------------------- +if ($Test) { + Write-Host "==> Smoke testing against assets/Global.xml..." + $testOut = Join-Path $dist "smoketest.xlsx" + & $exe to-xlsx (Join-Path $root "assets\Global.xml") ` + --sdt (Join-Path $root "assets\SDT.xml") ` + -o $testOut --force + if ($LASTEXITCODE -ne 0) { + Write-Error "Smoke test failed" + } + & $exe report (Join-Path $root "assets\Global.xml") --sdt (Join-Path $root "assets\SDT.xml") + if ($LASTEXITCODE -ne 0) { + Write-Error "Smoke test (report) failed" + } + Remove-Item $testOut -Force -ErrorAction SilentlyContinue + Write-Host "==> Smoke test OK" +} + +Write-Host "" +Write-Host "Done. Distribute: $exe" +if (-not $Onefile) { + Write-Host "Note: one-folder mode requires the whole dist\$exename folder." +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9f1c816..74c5a03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0", + "pyinstaller>=6.0", ] [project.scripts] diff --git a/run.py b/run.py new file mode 100644 index 0000000..769b02d --- /dev/null +++ b/run.py @@ -0,0 +1,11 @@ +"""PyInstaller entry point (absolute imports, no relative package context). + +Packaging tools cannot execute ``src/mitsubishi_label_converter/cli.py`` +directly because it uses relative imports; this thin launcher works around +that by importing the package normally. +""" + +from mitsubishi_label_converter.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file