Skip to content

Commit 3261933

Browse files
authored
Merge pull request #925 from eoyilmaz/917-evaluate-freeze-tooling-alternatives
[#917] Evaluate freeze-tooling alternatives to py2app/py2exe/bbfreeze/pyi
2 parents ffb7733 + a398d94 commit 3261933

2 files changed

Lines changed: 236 additions & 3 deletions

File tree

.github/workflows/pytest.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ jobs:
130130
env:
131131
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
132132
run: |
133-
python -m pytest --verbose -n auto -W ignore --color=yes -m "not network" --durations=50
133+
python -m pytest --verbose -W ignore --color=yes -m "not network" --durations=50
134134
135135
- name: Dump thread stacks (diagnostic, see conftest.py)
136136
if: always()
@@ -215,7 +215,7 @@ jobs:
215215
env:
216216
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
217217
run: |
218-
python -m pytest --verbose -n auto -W ignore --color=yes -m "not network" --durations=50
218+
python -m pytest --verbose -W ignore --color=yes -m "not network" --durations=50
219219
220220
macos:
221221
name: macOS + Python ${{ matrix.python-version }}
@@ -287,7 +287,7 @@ jobs:
287287
env:
288288
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
289289
run: |
290-
python -m pytest --verbose -n auto -W ignore --color=yes -m "not network" --durations=50
290+
python -m pytest --verbose -W ignore --color=yes -m "not network" --durations=50
291291
292292
- name: Dump thread stacks (diagnostic, see conftest.py)
293293
if: always()
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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

Comments
 (0)