Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/_windows-exe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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
# 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
# --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
# 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
# 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
path: dist/dls-plc-tools.exe
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
58 changes: 58 additions & 0 deletions packaging/dls-plc-tools.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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.
# - 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, "entrypoint.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,
)
13 changes: 13 additions & 0 deletions packaging/entrypoint.py
Original file line number Diff line number Diff line change
@@ -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()
Loading