From 4255f934177a28bf008d7ad7b0f0d9b6b730b727 Mon Sep 17 00:00:00 2001 From: Lee Hudson Date: Mon, 27 Jul 2026 06:48:12 +0000 Subject: [PATCH 1/2] Build standalone Windows exe in CI Freeze dls-plc-tools into a single Windows .exe with PyInstaller on a windows-latest runner, so IT-locked DLS workstations without uv/pip can run the tool by downloading one file. Because _release.yml attaches every run artifact, the exe lands on the GitHub release on tags; the windows-exe job also runs on push/PR to catch freeze breakage early, and release now needs it. The spec force-collects aphyt/openpyxl submodules (the CLI/GUI lazy-import aphyt and it loads CIP classes dynamically, so the static graph misses them); console=True keeps the read CLI's stdout working. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A1NtKmMT92DTBWro4vVEL5 --- .github/workflows/_windows-exe.yml | 42 +++++++++++++++++++++++ .github/workflows/ci.yml | 10 ++++-- .gitignore | 2 ++ CLAUDE.md | 22 ++++++++++++ packaging/dls-plc-tools.spec | 54 ++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/_windows-exe.yml create mode 100644 packaging/dls-plc-tools.spec diff --git a/.github/workflows/_windows-exe.yml b/.github/workflows/_windows-exe.yml new file mode 100644 index 0000000..ea1201e --- /dev/null +++ b/.github/workflows/_windows-exe.yml @@ -0,0 +1,42 @@ +on: + workflow_call: + +jobs: + build: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + # Need this so setuptools_scm can derive the version from the last tag + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install runtime deps + PyInstaller + # uv sync installs the project (editable), which triggers setuptools_scm + # to write _version.py. PyInstaller goes into the same venv so it can see + # aphyt / PySide6 when it freezes them. + run: | + uv sync + uv pip install pyinstaller + + - name: Build single-file exe + run: uv run pyinstaller --clean --noconfirm packaging/dls-plc-tools.spec + + - name: Smoke test the frozen exe + # --version / --help don't import the heavy deps, so this only proves the + # bootloader + Typer wiring froze. Exercising aphyt / Qt needs a real PLC + # (see CLAUDE.md); do that on a workstation before trusting a release. + run: | + dist/dls-plc-tools.exe --version + dist/dls-plc-tools.exe --help + dist/dls-plc-tools.exe read --help + + - name: Upload exe as artifact + uses: actions/upload-artifact@v4 + with: + name: windows-exe + path: dist/dls-plc-tools.exe diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f3246a..c5f2d70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,13 @@ jobs: dist: uses: ./.github/workflows/_dist.yml - + + windows-exe: + # Freeze a standalone Windows .exe so IT-locked boxes without uv/pip can run + # the tool. Runs on every push/PR to catch freeze breakage early; the built + # exe is attached to the GitHub release on tags (see release job below). + uses: ./.github/workflows/_windows-exe.yml + pypi: needs: [dist, test] if: github.ref_type == 'tag' @@ -47,7 +53,7 @@ jobs: id-token: write release: - needs: [dist, test] + needs: [dist, test, windows-exe] if: github.ref_type == 'tag' uses: ./.github/workflows/_release.yml permissions: diff --git a/.gitignore b/.gitignore index 0f33bf2..2eef322 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ var/ # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec +# ...but our checked-in spec drives the CI Windows-exe build: +!packaging/dls-plc-tools.spec # Installer logs pip-log.txt diff --git a/CLAUDE.md b/CLAUDE.md index 6d4fd34..bf3c263 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,3 +211,25 @@ Pushing a git tag runs the full CI matrix and, on success, publishes to PyPI via trusted publishing (GitHub environment `release`) and creates a GitHub release. Any tag triggers this — don't push scratch tags. Version comes from the tag via setuptools_scm. + +## Windows binary (frozen exe) + +For DLS Windows workstations with IT policies that block uv/pip, CI freezes a +standalone `dls-plc-tools.exe` with PyInstaller (`_windows-exe.yml`, driven by +`packaging/dls-plc-tools.spec`) and — because `_release.yml` attaches every run +artifact (`files: "*"`) — it lands on the GitHub release. Users download the one +file; no Python/uv/pip on the target. The `windows-exe` job runs on every +push/PR (so freeze breakage is caught early) and `release` `needs` it, so a +broken freeze blocks the tagged release. The spec force-collects `aphyt`'s +submodules because the CLI/GUI lazy-import it *inside* functions and aphyt +imports its CIP classes dynamically — PyInstaller's static graph misses them +otherwise; PySide6 rides its bundled Qt hook. Built `console=True` so `read`'s +stdout works (GUI users get a spare console window — don't flip to windowed). +CI's smoke test only runs `--version`/`--help` (no heavy-dep import); exercising +aphyt/Qt still needs a real PLC per the rule above. The GUI's k8s **port-forward +path won't work on Windows** (no kubectl/klogin) — the exe is for the `read` CLI +and direct-IP GUI connections. If AppLocker/WDAC allowlisting is in play, a +self-built exe is blocked too and IT must whitelist the hash/publisher — a +different problem the freeze doesn't solve. Build locally to reproduce: +`uv sync && uv pip install pyinstaller && uv run pyinstaller --clean --noconfirm +packaging/dls-plc-tools.spec`. diff --git a/packaging/dls-plc-tools.spec b/packaging/dls-plc-tools.spec new file mode 100644 index 0000000..acf461c --- /dev/null +++ b/packaging/dls-plc-tools.spec @@ -0,0 +1,54 @@ +# PyInstaller spec: freeze dls-plc-tools into a single Windows .exe. +# +# Built in CI on a windows-latest runner (.github/workflows/_windows-exe.yml) +# and attached to the GitHub release, so IT-locked Windows boxes that can't run +# uv/pip just download and run the one file. Build locally with: +# +# uv sync && uv pip install pyinstaller +# uv run pyinstaller --clean --noconfirm packaging/dls-plc-tools.spec +# +# Notes: +# - collect_submodules(aphyt): the subcommands lazy-import aphyt *inside* +# functions and aphyt imports its CIP classes dynamically, so PyInstaller's +# static graph misses them without forcing the whole package in. Same defence +# for openpyxl. PySide6 is handled by PyInstaller's bundled Qt hooks. +# - console=True: this is a CLI (`read`) with a GUI subcommand (`gui`). A +# console build keeps `read`'s stdout working; GUI users get a harmless +# console window alongside. Don't flip to windowed or the CLI goes mute. +import os + +from PyInstaller.utils.hooks import collect_submodules + +script = os.path.join(SPECPATH, os.pardir, "src", "dls_plc_tools", "__main__.py") + +hiddenimports = collect_submodules("aphyt") + collect_submodules("openpyxl") + +a = Analysis( + [script], + pathex=[], + binaries=[], + datas=[], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="dls-plc-tools", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + runtime_tmpdir=None, + console=True, +) From 9b3b11d34e14fdc5c54c16133a974beaca2eaa2e Mon Sep 17 00:00:00 2001 From: Lee Hudson Date: Mon, 27 Jul 2026 06:57:30 +0000 Subject: [PATCH 2/2] Fix frozen exe crash: freeze an absolute-import entry shim Freezing dls_plc_tools/__main__.py directly made it the top-level script with no parent package, so its `from . import ...` relative imports raised "ImportError: attempted relative import with no known parent package" at startup (the CI smoke test caught it). Freeze packaging/entrypoint.py instead, which imports the package absolutely so those imports resolve. Verified locally by freezing on Linux and running --version / --help / read --help. Also harden the workflow: build with `uv run --with pyinstaller` (a plain `uv run pyinstaller` re-syncs to the lockfile and uninstalls pyinstaller first), and upload the exe with `if: always()` so a failed smoke test still leaves an artifact to inspect while still failing the job. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A1NtKmMT92DTBWro4vVEL5 --- .github/workflows/_windows-exe.yml | 19 +++++++++++-------- packaging/dls-plc-tools.spec | 6 +++++- packaging/entrypoint.py | 13 +++++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 packaging/entrypoint.py diff --git a/.github/workflows/_windows-exe.yml b/.github/workflows/_windows-exe.yml index ea1201e..fd62152 100644 --- a/.github/workflows/_windows-exe.yml +++ b/.github/workflows/_windows-exe.yml @@ -15,16 +15,16 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 - - name: Install runtime deps + PyInstaller - # uv sync installs the project (editable), which triggers setuptools_scm - # to write _version.py. PyInstaller goes into the same venv so it can see - # aphyt / PySide6 when it freezes them. - run: | - uv sync - uv pip install pyinstaller + - name: Install runtime deps + # Installs the project (editable), which triggers setuptools_scm to write + # _version.py and makes dls_plc_tools importable so the spec can freeze it. + run: uv sync - name: Build single-file exe - run: uv run pyinstaller --clean --noconfirm packaging/dls-plc-tools.spec + # --with pyinstaller adds it to the run env; a plain `uv run pyinstaller` + # would first sync the env back to the lockfile (which omits pyinstaller) + # and uninstall it out from under us. + run: uv run --with pyinstaller pyinstaller --clean --noconfirm packaging/dls-plc-tools.spec - name: Smoke test the frozen exe # --version / --help don't import the heavy deps, so this only proves the @@ -36,6 +36,9 @@ jobs: dist/dls-plc-tools.exe read --help - name: Upload exe as artifact + # always() so a failed smoke test still leaves a downloadable exe to + # inspect; the failed step still fails the job (and blocks the release). + if: always() uses: actions/upload-artifact@v4 with: name: windows-exe diff --git a/packaging/dls-plc-tools.spec b/packaging/dls-plc-tools.spec index acf461c..4bc916b 100644 --- a/packaging/dls-plc-tools.spec +++ b/packaging/dls-plc-tools.spec @@ -15,11 +15,15 @@ # - console=True: this is a CLI (`read`) with a GUI subcommand (`gui`). A # console build keeps `read`'s stdout working; GUI users get a harmless # console window alongside. Don't flip to windowed or the CLI goes mute. +# - Freezes packaging/entrypoint.py, NOT dls_plc_tools/__main__.py: the latter +# as a top-level script has no parent package, so its relative imports crash +# (ImportError: attempted relative import with no known parent package). The +# shim imports the package absolutely so those imports resolve. import os from PyInstaller.utils.hooks import collect_submodules -script = os.path.join(SPECPATH, os.pardir, "src", "dls_plc_tools", "__main__.py") +script = os.path.join(SPECPATH, "entrypoint.py") hiddenimports = collect_submodules("aphyt") + collect_submodules("openpyxl") diff --git a/packaging/entrypoint.py b/packaging/entrypoint.py new file mode 100644 index 0000000..44eaadf --- /dev/null +++ b/packaging/entrypoint.py @@ -0,0 +1,13 @@ +"""PyInstaller entry shim. + +Freezing ``dls_plc_tools/__main__.py`` directly makes it the top-level script, +which has no parent package, so its ``from . import ...`` relative imports raise +``ImportError: attempted relative import with no known parent package``. Import +the package *absolutely* here instead: that pulls ``dls_plc_tools`` in as a real +package, so its internal relative imports resolve. The spec freezes this file. +""" + +from dls_plc_tools.__main__ import main + +if __name__ == "__main__": + main()