From a879c7701a3e8ada7102259a725cf466ad5fa07c Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 11 Aug 2026 12:33:14 -0400 Subject: [PATCH 1/6] Extend platform support to include win-arm64 --- constructor/conda_interface.py | 1 + constructor/construct.py | 1 + constructor/nsis/main.nsi.tmpl | 3 ++- constructor/winexe.py | 27 +++++++++++---------------- tests/test_construct.py | 28 +++++++++++++++++++++++++++- tests/test_winexe.py | 15 +++++++++++++++ 6 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 tests/test_winexe.py diff --git a/constructor/conda_interface.py b/constructor/conda_interface.py index d40813165..ab1c46bf7 100644 --- a/constructor/conda_interface.py +++ b/constructor/conda_interface.py @@ -32,6 +32,7 @@ "linux-ppc64le", "linux-s390x", "win-64", + "win-arm64", "osx-64", "osx-arm64", ] diff --git a/constructor/construct.py b/constructor/construct.py index bca76e357..bfd3fd8e6 100644 --- a/constructor/construct.py +++ b/constructor/construct.py @@ -52,6 +52,7 @@ def ns_platform(platform): win=p.startswith("win-"), win32=bool(p == "win-32"), win64=bool(p == "win-64"), + win_arm64=bool(p == "win-arm64"), ) diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index c9fa01517..5dff000d5 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -657,8 +657,9 @@ Function .onInit # Select the correct registry to look at, depending # on whether it's a 32-bit or 64-bit installer SetRegView {{ BITS }} -{%- if win64 %} +{%- if win64 or win_arm64 %} # If we're a 64-bit installer, make sure it's 64-bit Windows + # Not verified yet on ARM64 Windows. ${IfNot} ${RunningX64} MessageBox MB_OK|MB_ICONEXCLAMATION \ "This installer is for a 64-bit version for ${NAME}$\n\ diff --git a/constructor/winexe.py b/constructor/winexe.py index ce6074971..cff693e72 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -44,6 +44,15 @@ logger = logging.getLogger(__name__) +def parse_arch(platform: str) -> tuple[str, int]: + """Return a tuple (display string, bit width) for a Windows platform string.""" + arch = platform.split("-")[1] + if arch == "arm64": + return "ARM64", 64 + bits = int(arch) + return "%d-bit" % bits, bits + + def read_nsi_tmpl(info) -> str: path = abspath(info.get("nsis_template", join(NSIS_DIR, "main.nsi.tmpl"))) logger.info("Reading: %s", path) @@ -155,7 +164,7 @@ def make_nsi( dists += env_info["_dists"] dists = list({dist: None for dist in dists}) # de-duplicate - arch = int(info["_platform"].split("-")[1]) + display_arch, arch = parse_arch(info["_platform"]) info["pre_install_desc"] = info.get("pre_install_desc", "") info["post_install_desc"] = info.get("post_install_desc", "") @@ -164,7 +173,7 @@ def make_nsi( "installer_version": info["version"], "company": info.get("company", "Unknown, Inc."), "installer_platform": info["_platform"], - "arch": "%d-bit" % arch, + "arch": display_arch, "default_prefix": info.get("default_prefix", join("%USERPROFILE%", name.lower())), "default_prefix_domain_user": info.get( "default_prefix_domain_user", join("%LOCALAPPDATA%", name.lower()) @@ -413,17 +422,3 @@ def create(info, verbose=False): if not info.get("_debug"): shutil.rmtree(tmp_dir) - - -if __name__ == "__main__": - make_nsi( - { - "name": "Maxi", - "version": "1.2", - "_platform": "win-64", - "_outpath": "dummy.exe", - "_download_dir": "dummy", - "_dists": ["python-2.7.9-0.tar.bz2", "vs2008_runtime-1.0-1.tar.bz2"], - }, - ".", - ) diff --git a/tests/test_construct.py b/tests/test_construct.py index 6d3274c16..a51ca04ad 100644 --- a/tests/test_construct.py +++ b/tests/test_construct.py @@ -4,7 +4,8 @@ import pytest -from constructor.conda_interface import cc_platform +from constructor.conda_interface import SUPPORTED_PLATFORMS, cc_platform +from constructor.construct import ns_platform from constructor.construct import parse as construct_parse from constructor.construct import render as construct_render @@ -37,6 +38,10 @@ """ +def test_supported_platforms_includes_win_arm64(): + assert "win-arm64" in SUPPORTED_PLATFORMS + + @pytest.fixture def construct_yaml_file(tmp_path: Path) -> str: file_path = tmp_path / "construct.yaml" @@ -110,3 +115,24 @@ def test_parse_error(tmp_path): construct_parse(construct_yaml_file, cc_platform) assert exc.value.code != 0 assert "Unable to parse" in str(exc.getrepr()) + + +NS_PLATFORM_TRUE_FLAGS = { + "linux-64": {"linux", "linux64", "x86", "x86_64", "unix"}, + "linux-aarch64": {"linux", "aarch64", "unix"}, + "linux-ppc64le": {"linux", "ppc64le", "unix"}, + "linux-s390x": {"linux", "s390x", "unix"}, + "win-64": {"x86", "x86_64", "win", "win64"}, + "win-arm64": {"win", "win_arm64"}, + "osx-64": {"x86", "x86_64", "osx", "unix"}, + "osx-arm64": {"arm64", "osx", "unix"}, +} + + +@pytest.mark.parametrize("platform", SUPPORTED_PLATFORMS) +def test_ns_platform(platform): + result = ns_platform(platform) + assert result + true_flags = NS_PLATFORM_TRUE_FLAGS[platform] + for flag, value in result.items(): + assert value is (flag in true_flags), f"{platform}: expected {flag}={flag in true_flags}" diff --git a/tests/test_winexe.py b/tests/test_winexe.py new file mode 100644 index 000000000..bde50dcf6 --- /dev/null +++ b/tests/test_winexe.py @@ -0,0 +1,15 @@ +import pytest + +from constructor.winexe import parse_arch + + +@pytest.mark.parametrize( + "platform,expected", + [ + ("win-32", ("32-bit", 32)), + ("win-64", ("64-bit", 64)), + ("win-arm64", ("ARM64", 64)), + ], +) +def test_parse_arch(platform, expected): + assert parse_arch(platform) == expected From e8487decf6196b6e9d8e6a5edbba74c8ce9d47d3 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 11 Aug 2026 13:04:32 -0400 Subject: [PATCH 2/6] Update docs --- CONSTRUCT.md | 2 ++ docs/source/construct-yaml.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 935061b8c..2397f181e 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -744,6 +744,7 @@ The output filename will be: - `win` - `win32` - `win64` +- `win_arm64` - `x86` - `x86_64` @@ -755,5 +756,6 @@ If provided, this argument must be formated as `-`, e.g. - `linux-ppc64le` - `linux-s390x` - `win-64` +- `win-arm64` - `osx-64` - `osx-arm64` \ No newline at end of file diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 935061b8c..2397f181e 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -744,6 +744,7 @@ The output filename will be: - `win` - `win32` - `win64` +- `win_arm64` - `x86` - `x86_64` @@ -755,5 +756,6 @@ If provided, this argument must be formated as `-`, e.g. - `linux-ppc64le` - `linux-s390x` - `win-64` +- `win-arm64` - `osx-64` - `osx-arm64` \ No newline at end of file From 98dc2534d9e643e4d61098d575db5cf66383213c Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 11 Aug 2026 16:44:24 -0400 Subject: [PATCH 3/6] Move PIL imports to lazy imports --- constructor/imaging.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/constructor/imaging.py b/constructor/imaging.py index a81dc8e71..795452f7a 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -14,8 +14,6 @@ from pathlib import Path from random import randint -from PIL import Image, ImageDraw, ImageFont - from ._schema import InstallerTypes ttf_path = join(dirname(__file__), "ttf", "Vera.ttf") @@ -37,6 +35,8 @@ def new_background(size, color, bs=20, boxes=50): + from PIL import Image, ImageDraw + im = Image.new("RGB", size, color=color) d = ImageDraw.Draw(im) for unused in range(boxes): @@ -48,6 +48,8 @@ def new_background(size, color, bs=20, boxes=50): def add_text(im, xy, text, min_lines, line_height, font, color): + from PIL import ImageDraw + x, y = xy d = ImageDraw.Draw(im) lines = text.splitlines() @@ -61,6 +63,8 @@ def add_text(im, xy, text, min_lines, line_height, font, color): def mk_welcome_image(info): + from PIL import ImageFont + font = ImageFont.truetype(BytesIO(ttf_bytes), 20) im = new_background(welcome_size, info["_color"]) text = "\n".join([info["welcome_image_text"], info["version"]]) @@ -69,6 +73,8 @@ def mk_welcome_image(info): def mk_welcome_image_osx(info): + from PIL import Image, ImageFont + font = ImageFont.truetype(BytesIO(ttf_bytes), 40) # Transparent background im = Image.new("RGBA", welcome_size_osx, color=(0, 0, 0, 0)) @@ -78,6 +84,8 @@ def mk_welcome_image_osx(info): def mk_header_image(info): + from PIL import Image, ImageFont + font = ImageFont.truetype(BytesIO(ttf_bytes), 20) im = Image.new("RGB", header_size, color=white) text = info["header_image_text"] @@ -87,6 +95,8 @@ def mk_header_image(info): def mk_icon_image(info): + from PIL import ImageDraw, ImageFont + font = ImageFont.truetype(BytesIO(ttf_bytes), 200) im = new_background(icon_size, info["_color"]) d = ImageDraw.Draw(im) @@ -115,6 +125,8 @@ def _resize_for_msi_welcome(image_path): The user's image is resized to 164x312 and placed on the left, with white padding on the right for the dialog text. """ + from PIL import Image + im = Image.open(image_path) # Resize to side panel dimensions (164x312) @@ -128,6 +140,8 @@ def _resize_for_msi_welcome(image_path): def write_images(info: dict, dir_path: Path, installer_type: InstallerTypes = InstallerTypes.EXE): + from PIL import Image + if installer_type == InstallerTypes.EXE: instructions = [ ("welcome", welcome_size, mk_welcome_image, ".bmp"), From b4295a3a0316f2d0bcb87215b3fe4273907e388f Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 13 Aug 2026 10:12:49 -0400 Subject: [PATCH 4/6] Add checks for AMD64/ARM64 --- constructor/nsis/main.nsi.tmpl | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index 5dff000d5..e214fbbd6 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -657,14 +657,24 @@ Function .onInit # Select the correct registry to look at, depending # on whether it's a 32-bit or 64-bit installer SetRegView {{ BITS }} -{%- if win64 or win_arm64 %} - # If we're a 64-bit installer, make sure it's 64-bit Windows - # Not verified yet on ARM64 Windows. - ${IfNot} ${RunningX64} +{%- if win64 %} + # Make sure we're not on 32-bit or native ARM64 Windows (the latter would + # otherwise silently run this x64 installer under x64 emulation). + ${IfNot} ${IsNativeAMD64} MessageBox MB_OK|MB_ICONEXCLAMATION \ - "This installer is for a 64-bit version for ${NAME}$\n\ - but your system is 32-bit. Please use the 32-bit Windows$\n\ - ${NAME} installer." \ + "This installer is for the 64-bit (x86_64) version of ${NAME}$\n\ + but your system is not x86_64. Please use the installer that$\n\ + matches your system's architecture." \ + /SD IDOK + Abort + ${EndIf} +{%- elif win_arm64 %} + # Make sure we're actually on native ARM64 Windows. + ${IfNot} ${IsNativeARM64} + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "This installer is for the ARM64 version of ${NAME}$\n\ + but your system is not ARM64. Please use the installer that$\n\ + matches your system's architecture." \ /SD IDOK Abort ${EndIf} From 608307908a72e5c07f0d942284bc1737a0f7c6c6 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 13 Aug 2026 14:37:09 -0400 Subject: [PATCH 5/6] Add news --- news/1323-extend-windows-support | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 news/1323-extend-windows-support diff --git a/news/1323-extend-windows-support b/news/1323-extend-windows-support new file mode 100644 index 000000000..1cacd6e8f --- /dev/null +++ b/news/1323-extend-windows-support @@ -0,0 +1,19 @@ +### Enhancements + +* Add support for Windows ARM64. (#1323) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* From 5fce45274b8ac152345acd4c89ba35ec584c1da2 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 14 Aug 2026 14:14:21 -0400 Subject: [PATCH 6/6] Review fixes --- constructor/imaging.py | 18 ++---------------- tests/test_construct.py | 4 ---- tests/test_winexe.py | 8 +++++++- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/constructor/imaging.py b/constructor/imaging.py index 795452f7a..a81dc8e71 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -14,6 +14,8 @@ from pathlib import Path from random import randint +from PIL import Image, ImageDraw, ImageFont + from ._schema import InstallerTypes ttf_path = join(dirname(__file__), "ttf", "Vera.ttf") @@ -35,8 +37,6 @@ def new_background(size, color, bs=20, boxes=50): - from PIL import Image, ImageDraw - im = Image.new("RGB", size, color=color) d = ImageDraw.Draw(im) for unused in range(boxes): @@ -48,8 +48,6 @@ def new_background(size, color, bs=20, boxes=50): def add_text(im, xy, text, min_lines, line_height, font, color): - from PIL import ImageDraw - x, y = xy d = ImageDraw.Draw(im) lines = text.splitlines() @@ -63,8 +61,6 @@ def add_text(im, xy, text, min_lines, line_height, font, color): def mk_welcome_image(info): - from PIL import ImageFont - font = ImageFont.truetype(BytesIO(ttf_bytes), 20) im = new_background(welcome_size, info["_color"]) text = "\n".join([info["welcome_image_text"], info["version"]]) @@ -73,8 +69,6 @@ def mk_welcome_image(info): def mk_welcome_image_osx(info): - from PIL import Image, ImageFont - font = ImageFont.truetype(BytesIO(ttf_bytes), 40) # Transparent background im = Image.new("RGBA", welcome_size_osx, color=(0, 0, 0, 0)) @@ -84,8 +78,6 @@ def mk_welcome_image_osx(info): def mk_header_image(info): - from PIL import Image, ImageFont - font = ImageFont.truetype(BytesIO(ttf_bytes), 20) im = Image.new("RGB", header_size, color=white) text = info["header_image_text"] @@ -95,8 +87,6 @@ def mk_header_image(info): def mk_icon_image(info): - from PIL import ImageDraw, ImageFont - font = ImageFont.truetype(BytesIO(ttf_bytes), 200) im = new_background(icon_size, info["_color"]) d = ImageDraw.Draw(im) @@ -125,8 +115,6 @@ def _resize_for_msi_welcome(image_path): The user's image is resized to 164x312 and placed on the left, with white padding on the right for the dialog text. """ - from PIL import Image - im = Image.open(image_path) # Resize to side panel dimensions (164x312) @@ -140,8 +128,6 @@ def _resize_for_msi_welcome(image_path): def write_images(info: dict, dir_path: Path, installer_type: InstallerTypes = InstallerTypes.EXE): - from PIL import Image - if installer_type == InstallerTypes.EXE: instructions = [ ("welcome", welcome_size, mk_welcome_image, ".bmp"), diff --git a/tests/test_construct.py b/tests/test_construct.py index a51ca04ad..af4032c7a 100644 --- a/tests/test_construct.py +++ b/tests/test_construct.py @@ -38,10 +38,6 @@ """ -def test_supported_platforms_includes_win_arm64(): - assert "win-arm64" in SUPPORTED_PLATFORMS - - @pytest.fixture def construct_yaml_file(tmp_path: Path) -> str: file_path = tmp_path / "construct.yaml" diff --git a/tests/test_winexe.py b/tests/test_winexe.py index bde50dcf6..7982d5618 100644 --- a/tests/test_winexe.py +++ b/tests/test_winexe.py @@ -1,6 +1,10 @@ +import sys + import pytest -from constructor.winexe import parse_arch +# winexe.py needs Pillow, which isn't installed on Linux. Skip this file there +# and only import winexe inside each test, so collection doesn't fail. +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="winexe is Windows-only") @pytest.mark.parametrize( @@ -12,4 +16,6 @@ ], ) def test_parse_arch(platform, expected): + from constructor.winexe import parse_arch + assert parse_arch(platform) == expected