|
| 1 | +# Freeze tooling evaluation (#917) |
| 2 | + |
| 3 | +Status: evaluation only, no tooling migration has started. This document is |
| 4 | +the deliverable requested by #917: assess maintained alternatives to |
| 5 | +`py2app`/`py2exe`/`bdist_bbfreeze`/PyInstaller (`bdist_pyi`) and propose a |
| 6 | +migration plan. It does not change `DisplayCAL/freeze.py`, `DisplayCAL/setup.py`, |
| 7 | +or `native_build.py`. |
| 8 | + |
| 9 | +**Sequencing note (maintainer decision, 2026-07-24):** DisplayCAL 4.x drops |
| 10 | +wx entirely and ships Qt-only. That changes this evaluation materially, wx |
| 11 | +bundling is currently the single biggest source of manual hook work for |
| 12 | +every candidate below, and it's going away regardless of which freeze tool |
| 13 | +is picked. The recommendation in this document (§5) is written for a |
| 14 | +**Qt-only** target and assumes the actual freeze-tooling migration happens |
| 15 | +*after* wx is fully removed from the codebase, not before. Attempting this |
| 16 | +migration while wx and Qt still coexist (the current `DISPLAYCAL_UI=qt` |
| 17 | +additive state) would mean paying the wx-bundling cost twice: once now, |
| 18 | +and again when wx is deleted. See §2 and §6 for how this affects scoping. |
| 19 | + |
| 20 | +## 1. What's actually live today |
| 21 | + |
| 22 | +`DisplayCAL/setup.py` and `native_build.py` implement four freeze paths as |
| 23 | +old-style `distutils` Command classes, but only two of them are exercised by |
| 24 | +CI or the release process: |
| 25 | + |
| 26 | +| Path | Used by CI (`release_builds.yml` / `nightly_builds.yml`)? | Notes | |
| 27 | +|---|---|---| |
| 28 | +| `py2app` (macOS) | Yes | `.app` bundle -> DMG, one build per arch (x86_64 has been dropped from the matrix, arm64 only) | |
| 29 | +| `py2exe` (Windows) | Yes | Windows x64 uses the PyPI wheel; win-arm64 has no upstream wheel and needs a `git clone` + `--no-build-isolation` install from `py2exe/py2exe` HEAD (`nightly_builds.yml:53-66`) | |
| 30 | +| `bdist_bbfreeze` | No | Referenced in `setup.py`/`native_build.py` only. `bbfreeze` has been unmaintained for years and doesn't install on any currently supported Python (3.9-3.14); this code path is dead | |
| 31 | +| `bdist_pyi` (PyInstaller) | No | Partial/started implementation in `native_build.py` (spec template generation, dist-dir layout) but never wired into a CI workflow or documented as a supported build target | |
| 32 | + |
| 33 | +Linux ships as a plain wheel plus DEB/RPM/AppImage/Flatpak, none of which |
| 34 | +need a freeze tool at all, so this evaluation is scoped to **macOS and |
| 35 | +Windows only**. |
| 36 | + |
| 37 | +Practical implication: this isn't a 1:1 swap of four tools, it's replacing |
| 38 | +two live integrations (py2app, py2exe) and deleting two dead ones |
| 39 | +(bdist_bbfreeze, bdist_pyi), which #916 already started doing for the |
| 40 | +surrounding scaffolding. |
| 41 | + |
| 42 | +## 2. Constraints specific to this codebase |
| 43 | + |
| 44 | +- **wx is transitional, Qt is the long-term target.** Today, wx and Qt |
| 45 | + (PySide6) coexist per the 4.0 migration (`DISPLAYCAL_UI=qt`), and 4.x is |
| 46 | + planned to drop wx entirely. That means "does this tool bundle wx well" |
| 47 | + is a question worth answering only if the migration lands *before* wx is |
| 48 | + removed; if it lands after (the recommended order, see the sequencing |
| 49 | + note above), wx bundling quality drops out of the decision entirely and |
| 50 | + the only hook quality that matters is Qt/PySide6, which is where all |
| 51 | + three candidates below are strongest anyway. This is the single biggest |
| 52 | + way the picture in this document differs from a "freeze tooling in |
| 53 | + isolation" evaluation. |
| 54 | +- **Many entry points, not one binary.** `get_scripts()` in `freeze.py`/ |
| 55 | + `setup.py` enumerates ~10 standalone tools (3DLUT maker, curve viewer, |
| 56 | + profile info, scripting client, synthprofile, testchart editor, |
| 57 | + VRML-to-X3D converter, eeColor-to-madVR converter, apply-profiles |
| 58 | + launcher, main app), each with its own icon and, on macOS, its own `.app` |
| 59 | + bundle. The current macOS build freezes once and then hand-builds the |
| 60 | + other `.app` bundles as symlink farms into the same frozen payload |
| 61 | + (`create_app_symlinks()` in `DisplayCAL/setup.py:232-383`) rather than |
| 62 | + re-freezing per tool, almost certainly for build-time reasons. Any |
| 63 | + replacement needs an equivalent (either the same symlink-farm trick, or a |
| 64 | + tool that supports multiple-executables-from-one-freeze cheaply). |
| 65 | +- **Windows ARM64.** No upstream `py2exe` wheel exists for it today (source |
| 66 | + build workaround, see above). This is the constraint most likely to |
| 67 | + determine which alternative is even viable, since not all candidates |
| 68 | + support it yet. |
| 69 | +- **Custom Qt plugin bundling.** `copy_qt_plugins()` in `freeze.py` exists |
| 70 | + specifically because py2exe has no Qt-aware hook and silently drops |
| 71 | + `platforms/qwindows.dll` and friends. This is exactly the kind of thing a |
| 72 | + maintained tool's built-in Qt recipe should remove, so it's a concrete |
| 73 | + signal for evaluating hook quality, not just "does it run." |
| 74 | +- **Legacy VC90 CRT handling** (`vc90crt_copy_files`) predates the Universal |
| 75 | + CRT and is very likely dead weight on any Python 3.10+ toolchain |
| 76 | + regardless of which freezer is used; worth a separate cleanup ticket |
| 77 | + independent of #917. |
| 78 | + |
| 79 | +## 3. Candidates |
| 80 | + |
| 81 | +### PyInstaller |
| 82 | +Actively maintained, by far the largest hook ecosystem and community, has an |
| 83 | +in-tree PySide6 hook (Qt's own docs point to it). Already has a |
| 84 | +**half-started integration in this repo** (`bdist_pyi` in |
| 85 | +`native_build.py`), so picking it would reuse, rather than discard, existing |
| 86 | +work. Pure bundler (no C toolchain needed at build time). Windows ARM64: |
| 87 | +supported as a packaging target for a while now. wx hooks exist in the |
| 88 | +community but are irrelevant if this migration lands post-wx-removal (see |
| 89 | +sequencing note above). |
| 90 | + |
| 91 | +### Nuitka |
| 92 | +Actively maintained, compiles to C rather than just bundling the |
| 93 | +interpreter, meaningfully smaller/faster output, Qt recommends it (or |
| 94 | +PyInstaller) directly in their own deployment docs, for a Qt-only codebase |
| 95 | +this endorsement carries more weight than it did in the dual-toolkit |
| 96 | +framing. Downsides for us specifically: requires a working C/C++ toolchain |
| 97 | +in every build environment (new CI dependency on all three OSes), noticeably |
| 98 | +longer build times, and, critically, **standalone/onefile mode does not yet |
| 99 | +work on native Windows ARM64** (lacks binary dependency analysis there as of |
| 100 | +mid-2026) - the exact target we currently limp along for with a |
| 101 | +source-built py2exe. That alone is close to disqualifying unless we're |
| 102 | +willing to cross-compile win-arm64 from an x64 host, which is extra |
| 103 | +complexity this evaluation shouldn't wave away. This ARM64 gap is |
| 104 | +independent of the wx/Qt question, it doesn't improve just because wx is |
| 105 | +gone. |
| 106 | + |
| 107 | +### cx_Freeze |
| 108 | +Actively maintained (8.6.x shipping regularly through 2026), has in-tree |
| 109 | +hooks for PySide6, and is the closest in spirit to what's here today: it's |
| 110 | +also a `distutils`/`setuptools` Command-based API (`cx_Freeze.setup(..., |
| 111 | +executables=[Executable(...)])`), which is the smallest conceptual jump from |
| 112 | +the current `py2exe`/`py2app` `Target`-list pattern in `freeze.py`. Windows |
| 113 | +ARM64 MSI support landed for Python 3.13+, which happens to line up with |
| 114 | +what `release_builds.yml` already uses for the arm64 runner |
| 115 | +(`python-version: '3.13'`) - a real point in its favor, but this specific |
| 116 | +claim came from web search rather than a build we've run, and needs |
| 117 | +verification with a PoC before being load-bearing for a decision. Its |
| 118 | +narrower third-party hook catalog compared to PyInstaller was previously |
| 119 | +flagged as a wx-bundling risk; with wx out of the picture (post-4.x) that |
| 120 | +concern mostly evaporates since its in-tree PySide6 hook is the only one |
| 121 | +that matters. |
| 122 | + |
| 123 | +### Briefcase (BeeWare) |
| 124 | +Ruled out regardless of the wx/Qt question. Briefcase is a much larger |
| 125 | +commitment than a freeze-tool swap, it dictates project layout and how the |
| 126 | +app is packaged end to end, not just how it's frozen, and DisplayCAL's |
| 127 | +many hand-built standalone-tool bundles (§2) don't map cleanly onto its |
| 128 | +single-app model. Out of scope for #917, which is about freeze tooling, |
| 129 | +not a packaging-workflow rewrite. |
| 130 | + |
| 131 | +### PyOxidizer |
| 132 | +Ruled out. Development has stalled for years; not a "maintained alternative" |
| 133 | +by the issue's own bar. |
| 134 | + |
| 135 | +### bdist_bbfreeze / current PyInstaller stub |
| 136 | +Not viable as-is: `bbfreeze` doesn't install on any currently supported |
| 137 | +Python. The existing `bdist_pyi` code is a reasonable starting skeleton if |
| 138 | +PyInstaller is chosen, not a reason on its own to choose it. |
| 139 | + |
| 140 | +## 4. Comparison summary |
| 141 | + |
| 142 | +Evaluated for the post-wx-removal (Qt-only) target, per the sequencing note |
| 143 | +above: |
| 144 | + |
| 145 | +| | PyInstaller | Nuitka | cx_Freeze | |
| 146 | +|---|---|---|---| |
| 147 | +| Maintenance | Active, largest community | Active | Active | |
| 148 | +| Qt (PySide6) hook | In-tree, mature | Plugin, Qt-recommended | In-tree | |
| 149 | +| Build-time C toolchain | No | Yes (new CI dependency) | No | |
| 150 | +| Windows ARM64 (standalone) | Supported | **Not yet supported** | Supported (Python >= 3.13, needs verification) | |
| 151 | +| API shape vs. current code | Spec-file / CLI | CLI / setup.py plugin | `setup(executables=[...])`, closest match to current `Target` pattern | |
| 152 | +| Existing work to build on | Partial `bdist_pyi` skeleton | None | None | |
| 153 | +| Output size/speed | Baseline | Smaller/faster (compiled) | Baseline | |
| 154 | + |
| 155 | +(wx hook quality dropped from this table, see §2, it only matters if the |
| 156 | +migration is done before wx is removed, which is not the recommended order.) |
| 157 | + |
| 158 | +## 5. Recommendation |
| 159 | + |
| 160 | +Evaluate **cx_Freeze first**, with **PyInstaller as the fallback** if cx_Freeze's |
| 161 | +Qt hook coverage or Windows ARM64 story doesn't hold up in practice. |
| 162 | +Reasoning: |
| 163 | + |
| 164 | +- It's the smallest structural change from `freeze.py`/`setup.py`'s existing |
| 165 | + `Target`-list, Command-class pattern, lowest risk of collateral breakage |
| 166 | + in the parts of this file that aren't about freezing (data file |
| 167 | + enumeration, icon handling, manifest generation). |
| 168 | +- Its Windows ARM64 support timeline matches what the release workflow |
| 169 | + already targets (Python 3.13 on `windows-11-arm`), unlike Nuitka which is |
| 170 | + presently not viable there. |
| 171 | +- No new C/C++ toolchain requirement in CI, unlike Nuitka. |
| 172 | +- With wx gone, its narrower hook catalog (previously a wx-bundling risk |
| 173 | + relative to PyInstaller) stops mattering, its PySide6 hook is in-tree |
| 174 | + either way. |
| 175 | + |
| 176 | +Nuitka remains attractive for output quality (smaller, faster binaries) and |
| 177 | +is worth a second look once/if its Windows ARM64 standalone support matures, |
| 178 | +but it isn't a safe pick today given our ARM64 requirement. |
| 179 | + |
| 180 | +This recommendation should not be treated as final until backed by a PoC |
| 181 | +(see open questions below); it's a starting point for the next issue, |
| 182 | +not authorization to start ripping out `py2app`/`py2exe` yet. It also |
| 183 | +assumes wx has already been removed by the time that PoC work starts, per |
| 184 | +the sequencing note, if that turns out not to be true, re-add the wx hook |
| 185 | +comparison this version dropped. |
| 186 | + |
| 187 | +## 6. Proposed migration plan (one platform at a time, per the issue) |
| 188 | + |
| 189 | +0. **Sequencing gate**: don't start the PoC steps below until wx has been |
| 190 | + fully removed from the codebase (4.x). Doing this cleanup today |
| 191 | + (step 1) doesn't depend on that, it's independent of which GUI toolkit |
| 192 | + is in use. |
| 193 | +1. **Delete the dead paths now** (`bdist_bbfreeze`, `bdist_pyi` skeleton) |
| 194 | + as a small, independent cleanup, same spirit as #916. Low risk, shrinks |
| 195 | + the surface area before touching anything live, and doesn't need to wait |
| 196 | + on the wx removal. |
| 197 | +2. **macOS PoC** (post-wx-removal): build the main app bundle with |
| 198 | + cx_Freeze, verify Qt plugin bundling starts correctly, verify |
| 199 | + codesigning/notarization still works with cx_Freeze's `.app` output |
| 200 | + shape. |
| 201 | +3. **Windows PoC** (post-wx-removal): build x64 first (wheel exists), then |
| 202 | + validate the win-arm64 story that's currently a build-from-source |
| 203 | + workaround for py2exe. |
| 204 | +4. Only after both PoCs pass: port `create_app_symlinks()`/per-tool icon |
| 205 | + handling, replace `py2app`/`py2exe` in `native_build.py` and the |
| 206 | + `release_builds.yml`/`nightly_builds.yml` workflows, drop |
| 207 | + `requirements-dev.txt`'s `py2app`/`py2exe` lines. |
| 208 | +5. Re-evaluate the legacy `vc90crt_copy_files()` step for removal |
| 209 | + separately, it predates the toolchain we build with now regardless of |
| 210 | + which freezer wins. |
| 211 | + |
| 212 | +## 7. Open questions requiring a PoC (not yet answered by this doc) |
| 213 | + |
| 214 | +- Does cx_Freeze's win-arm64 MSI support actually work end-to-end on the |
| 215 | + `windows-11-arm` GitHub runner, or only in theory? (The claim above came |
| 216 | + from web search, not a build we've run.) |
| 217 | +- Can cx_Freeze reproduce the multi-`.app`-bundle-from-one-freeze trick |
| 218 | + `create_app_symlinks()` relies on, or does each tool need its own freeze |
| 219 | + invocation (build-time cost)? |
| 220 | +- What does cx_Freeze do with the Qt plugin directory by default, does it |
| 221 | + still need a `copy_qt_plugins()`-equivalent, or is that handled by its |
| 222 | + in-tree hook? |
| 223 | +- If the wx removal (4.x) slips and this migration ends up needing to |
| 224 | + happen while wx is still present, re-add the wx-bundling comparison this |
| 225 | + revision dropped from §3/§4 before treating §5's recommendation as final. |
| 226 | + |
| 227 | +## Sources |
| 228 | + |
| 229 | +- [Qt for Python & PyInstaller](https://doc.qt.io/qtforpython-6/deployment/deployment-pyinstaller.html) |
| 230 | +- [cx_Freeze release notes](https://cx-freeze.readthedocs.io/en/latest/releasenotes.html) |
| 231 | +- [cx_Freeze documentation](https://cx-freeze.readthedocs.io/) |
| 232 | +- [Nuitka Windows ARM64 / macOS arm64 platform support discussion](https://github.com/Nuitka/Nuitka/issues/2724) |
| 233 | +- [BeeWare February 2026 status update](https://beeware.org/news/buzz/2026/february-2026-status-update/) |
0 commit comments