From 4cb796d78c8b69e782871937465455caf352905c Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 17 Dec 2025 13:57:24 +0000 Subject: [PATCH 01/17] Initial MSI implementation, based on Briefcase (#1084) * Initial prototype as shown in demo * Switch to install_launcher option * Update schema properly * Move MSI file rather than copying it * Add fallbacks for invalid versions and app names * Use absolute paths in install script * Check that briefcase.exe exists * Add briefcase to dependencies, and make it and tomli-w Windows-only * Move Windows-specific dependencies from environment.yml to extra-requirements-windows.txt --------- Co-authored-by: Marco Esters --- .gitignore | 5 + CONSTRUCT.md | 8 +- constructor/_schema.py | 9 +- constructor/briefcase.py | 171 +++++++++++++++++++++ constructor/briefcase/run_installation.bat | 10 ++ constructor/data/construct.schema.json | 5 +- constructor/main.py | 6 +- constructor/osxpkg.py | 3 +- constructor/utils.py | 2 + dev/extra-requirements-windows.txt | 2 + docs/source/construct-yaml.md | 8 +- docs/source/howto.md | 10 +- pyproject.toml | 4 +- recipe/meta.yaml | 2 + tests/test_briefcase.py | 134 ++++++++++++++++ 15 files changed, 364 insertions(+), 15 deletions(-) create mode 100644 constructor/briefcase.py create mode 100644 constructor/briefcase/run_installation.bat create mode 100644 tests/test_briefcase.py diff --git a/.gitignore b/.gitignore index 22609e17e..9733f24b1 100644 --- a/.gitignore +++ b/.gitignore @@ -150,8 +150,13 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# VS Code .vscode/ +# macOS +.DS_Store + # Rever rever/ diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 17a71302f..e767d1da5 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -235,6 +235,7 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -317,8 +318,11 @@ Name of the company/entity responsible for the installer. ### `reverse_domain_identifier` Unique identifier for this package, formatted with reverse domain notation. This is -used internally in the PKG installers to handle future updates and others. If not -provided, it will default to `io.continuum`. (MacOS only) +used internally in the MSI and PKG installers to handle future updates and others. +If not provided, it will default to: + +* In MSI installers: `io.continuum` followed by an ID derived from the `name`. +* In PKG installers: `io.continuum`. ### `uninstall_name` diff --git a/constructor/_schema.py b/constructor/_schema.py index 87945ad03..a8b99f030 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -40,6 +40,7 @@ class WinSignTools(StrEnum): class InstallerTypes(StrEnum): ALL = "all" EXE = "exe" + MSI = "msi" PKG = "pkg" SH = "sh" @@ -403,6 +404,7 @@ class ConstructorConfiguration(BaseModel): - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS + - `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -486,8 +488,11 @@ class ConstructorConfiguration(BaseModel): reverse_domain_identifier: NonEmptyStr | None = None """ Unique identifier for this package, formatted with reverse domain notation. This is - used internally in the PKG installers to handle future updates and others. If not - provided, it will default to `io.continuum`. (MacOS only) + used internally in the MSI and PKG installers to handle future updates and others. + If not provided, it will default to: + + * In MSI installers: `io.continuum` followed by an ID derived from the `name`. + * In PKG installers: `io.continuum`. """ uninstall_name: NonEmptyStr | None = None """ diff --git a/constructor/briefcase.py b/constructor/briefcase.py new file mode 100644 index 000000000..70569c794 --- /dev/null +++ b/constructor/briefcase.py @@ -0,0 +1,171 @@ +""" +Logic to build installers using Briefcase. +""" + +import logging +import re +import shutil +import sysconfig +import tempfile +from pathlib import Path +from subprocess import run + +import tomli_w + +from . import preconda +from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist + +BRIEFCASE_DIR = Path(__file__).parent / "briefcase" +EXTERNAL_PACKAGE_PATH = "external" + +# Default to a low version, so that if a valid version is provided in the future, it'll +# be treated as an upgrade. +DEFAULT_VERSION = "0.0.1" + +logger = logging.getLogger(__name__) + + +def get_name_version(info): + if not (name := info.get("name")): + raise ValueError("Name is empty") + if not (version := info.get("version")): + raise ValueError("Version is empty") + + # Briefcase requires version numbers to be in the canonical Python format, and some + # installer types use the version to distinguish between upgrades, downgrades and + # reinstalls. So try to produce a consistent ordering by extracting the last valid + # version from the Constructor version string. + # + # Hyphens aren't allowed in this format, but for compatibility with Miniconda's + # version format, we treat them as dots. + matches = list( + re.finditer( + r"(\d+!)?\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?", + version.lower().replace("-", "."), + ) + ) + if not matches: + logger.warning( + f"Version {version!r} contains no valid version numbers; " + f"defaulting to {DEFAULT_VERSION}" + ) + return f"{name} {version}", DEFAULT_VERSION + + match = matches[-1] + version = match.group() + + # Treat anything else in the version string as part of the name. + start, end = match.span() + strip_chars = " .-_" + before = info["version"][:start].strip(strip_chars) + after = info["version"][end:].strip(strip_chars) + name = " ".join(s for s in [name, before, after] if s) + + return name, version + + +# Takes an arbitrary string with at least one alphanumeric character, and makes it into +# a valid Python package name. +def make_app_name(name, source): + app_name = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + if not app_name: + raise ValueError(f"{source} contains no alphanumeric characters") + return app_name + + +# Some installer types use the reverse domain ID to detect when the product is already +# installed, so it should be both unique between different products, and stable between +# different versions of a product. +def get_bundle_app_name(info, name): + # If reverse_domain_identifier is provided, use it as-is, + if (rdi := info.get("reverse_domain_identifier")) is not None: + if "." not in rdi: + raise ValueError(f"reverse_domain_identifier {rdi!r} contains no dots") + bundle, app_name = rdi.rsplit(".", 1) + + # Ensure that the last component is a valid Python package name, as Briefcase + # requires. + if not re.fullmatch( + r"[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]", app_name, flags=re.IGNORECASE + ): + app_name = make_app_name( + app_name, f"Last component of reverse_domain_identifier {rdi!r}" + ) + + # If reverse_domain_identifier isn't provided, generate it from the name. + else: + bundle = DEFAULT_REVERSE_DOMAIN_ID + app_name = make_app_name(name, f"Name {name!r}") + + return bundle, app_name + + +# Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja +# template allows us to avoid escaping strings everywhere. +def write_pyproject_toml(tmp_dir, info): + name, version = get_name_version(info) + bundle, app_name = get_bundle_app_name(info, name) + + config = { + "project_name": name, + "bundle": bundle, + "version": version, + "license": ({"file": info["license_file"]} if "license_file" in info else {"text": ""}), + "app": { + app_name: { + "formal_name": f"{info['name']} {info['version']}", + "description": "", # Required, but not used in the installer. + "external_package_path": EXTERNAL_PACKAGE_PATH, + "use_full_install_path": False, + "install_launcher": False, + "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), + } + }, + } + + if "company" in info: + config["author"] = info["company"] + + (tmp_dir / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}})) + + +def create(info, verbose=False): + tmp_dir = Path(tempfile.mkdtemp()) + write_pyproject_toml(tmp_dir, info) + + external_dir = tmp_dir / EXTERNAL_PACKAGE_PATH + external_dir.mkdir() + preconda.write_files(info, external_dir) + preconda.copy_extra_files(info.get("extra_files", []), external_dir) + + download_dir = Path(info["_download_dir"]) + pkgs_dir = external_dir / "pkgs" + for dist in info["_dists"]: + shutil.copy(download_dir / filename_dist(dist), pkgs_dir) + + copy_conda_exe(external_dir, "_conda.exe", info["_conda_exe"]) + + briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe" + if not briefcase.exists(): + raise FileNotFoundError( + f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}" + ) + + logger.info("Building installer") + run( + [briefcase, "package"] + (["-v"] if verbose else []), + cwd=tmp_dir, + check=True, + ) + + dist_dir = tmp_dir / "dist" + msi_paths = list(dist_dir.glob("*.msi")) + if len(msi_paths) != 1: + raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}") + + outpath = Path(info["_outpath"]) + outpath.unlink(missing_ok=True) + shutil.move(msi_paths[0], outpath) + + if not info.get("_debug"): + shutil.rmtree(tmp_dir) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat new file mode 100644 index 000000000..190a6d9f7 --- /dev/null +++ b/constructor/briefcase/run_installation.bat @@ -0,0 +1,10 @@ +set PREFIX=%cd% +_conda constructor --prefix %PREFIX% --extract-conda-pkgs + +set CONDA_PROTECT_FROZEN_ENVS=0 +set CONDA_ROOT_PREFIX=%PREFIX% +set CONDA_SAFETY_CHECKS=disabled +set CONDA_EXTRA_SAFETY_CHECKS=no +set CONDA_PKGS_DIRS=%PREFIX%\pkgs + +_conda install --offline --file %PREFIX%\conda-meta\initial-state.explicit.txt -yp %PREFIX% diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index f0178d738..a01aee1cf 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -244,6 +244,7 @@ "enum": [ "all", "exe", + "msi", "pkg", "sh" ], @@ -864,7 +865,7 @@ } ], "default": null, - "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", + "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\n- `msi`: Windows GUI installer built with Briefcase and WiX\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", "title": "Installer Type" }, "keep_pkgs": { @@ -1144,7 +1145,7 @@ } ], "default": null, - "description": "Unique identifier for this package, formatted with reverse domain notation. This is used internally in the PKG installers to handle future updates and others. If not provided, it will default to `io.continuum`. (MacOS only)", + "description": "Unique identifier for this package, formatted with reverse domain notation. This is used internally in the MSI and PKG installers to handle future updates and others. If not provided, it will default to:\n* In MSI installers: `io.continuum` followed by an ID derived from the `name`. * In PKG installers: `io.continuum`.", "title": "Reverse Domain Identifier" }, "script_env_variables": { diff --git a/constructor/main.py b/constructor/main.py index 5a8ce9af7..e1168fbad 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -40,7 +40,7 @@ def get_installer_type(info: dict): osname, unused_arch = info["_platform"].split("-") - os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe",)} + os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe", "msi")} all_allowed = set(sum(os_allowed.values(), ("all",))) itype = info.get("installer_type") @@ -399,6 +399,10 @@ def main_build( from .winexe import create as winexe_create create = winexe_create + elif itype == "msi": + from .briefcase import create as briefcase_create + + create = briefcase_create info["installer_type"] = itype info["_outpath"] = abspath(join(output_dir, get_output_filename(info))) create(info, verbose=verbose) diff --git a/constructor/osxpkg.py b/constructor/osxpkg.py index 2bccfbf85..f43dc6aa7 100644 --- a/constructor/osxpkg.py +++ b/constructor/osxpkg.py @@ -21,6 +21,7 @@ from .jinja import render_template from .signing import CodeSign from .utils import ( + DEFAULT_REVERSE_DOMAIN_ID, add_condarc, approx_size_kb, copy_conda_exe, @@ -433,7 +434,7 @@ def fresh_dir(dir_path): def pkgbuild(name, identifier=None, version=None, install_location=None): "see `man pkgbuild` for the meaning of optional arguments" if identifier is None: - identifier = "io.continuum" + identifier = DEFAULT_REVERSE_DOMAIN_ID args = [ "pkgbuild", "--root", diff --git a/constructor/utils.py b/constructor/utils.py index c63329c16..7766264bb 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -26,6 +26,8 @@ from conda.models.version import VersionOrder from ruamel.yaml import YAML +DEFAULT_REVERSE_DOMAIN_ID = "io.continuum" + logger = logging.getLogger(__name__) yaml = YAML(typ="rt") yaml.default_flow_style = False diff --git a/dev/extra-requirements-windows.txt b/dev/extra-requirements-windows.txt index 1f405685d..d382e69cb 100644 --- a/dev/extra-requirements-windows.txt +++ b/dev/extra-requirements-windows.txt @@ -1 +1,3 @@ +conda-forge::briefcase>=0.3.26 conda-forge::nsis>=3.08=*_log_* +conda-forge::tomli-w>=1.2.0 diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 17a71302f..e767d1da5 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -235,6 +235,7 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -317,8 +318,11 @@ Name of the company/entity responsible for the installer. ### `reverse_domain_identifier` Unique identifier for this package, formatted with reverse domain notation. This is -used internally in the PKG installers to handle future updates and others. If not -provided, it will default to `io.continuum`. (MacOS only) +used internally in the MSI and PKG installers to handle future updates and others. +If not provided, it will default to: + +* In MSI installers: `io.continuum` followed by an ID derived from the `name`. +* In PKG installers: `io.continuum`. ### `uninstall_name` diff --git a/docs/source/howto.md b/docs/source/howto.md index 8087a2803..c255b6e84 100644 --- a/docs/source/howto.md +++ b/docs/source/howto.md @@ -7,10 +7,12 @@ which it is running. In other words, if you run constructor on a Windows computer, you can only generate Windows installers. This is largely because OS-native tools are needed to generate the Windows `.exe` files and macOS `.pkg` files. There is a key in `construct.yaml`, `installer_type`, which dictates -the type of installer that gets generated. This is primarily only useful for -macOS, where you can generate either `.pkg` or `.sh` installers. When not set in -`construct.yaml`, this value defaults to `.sh` on Unix platforms, and `.exe` on -Windows. Using this key is generally done with selectors. For example, to +the type of installer that gets generated. This is useful for macOS, where you can +generate either `.pkg` or `.sh` installers, and Windows, where you can generate +either `.exe` or `.msi` installers. + +When not set in`construct.yaml`, this value defaults to `.sh` on Unix platforms, and +`.exe` on Windows. Using this key is generally done with selectors. For example, to build a `.pkg` installer on MacOS, but fall back to default behavior on other platforms: diff --git a/pyproject.toml b/pyproject.toml index f54eaac55..2c457356d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,9 @@ dependencies = [ "ruamel.yaml >=0.11.14,<0.19", "pillow >=3.1 ; platform_system=='Windows' or platform_system=='Darwin'", "jinja2", - "jsonschema >=4" + "jsonschema >=4", + "briefcase >=0.3.26 ; platform_system=='Windows'", + "tomli-w >=1.2.0 ; platform_system=='Windows'", ] [project.optional-dependencies] diff --git a/recipe/meta.yaml b/recipe/meta.yaml index d68af36a1..595ece483 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -30,6 +30,8 @@ requirements: - jsonschema >=4 - pillow >=3.1 # [win or osx] - nsis >=3.08 # [win] + - briefcase >=0.3.26 # [win] + - tomli-w >=1.2.0 # [win] run_constrained: # [unix] - nsis >=3.08 # [unix] - conda-libmamba-solver !=24.11.0 diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py new file mode 100644 index 000000000..a858b6ae4 --- /dev/null +++ b/tests/test_briefcase.py @@ -0,0 +1,134 @@ +import pytest + +from constructor.briefcase import get_bundle_app_name, get_name_version + + +@pytest.mark.parametrize( + "name_in, version_in, name_expected, version_expected", + [ + # Valid versions + ("Miniconda", "1", "Miniconda", "1"), + ("Miniconda", "1.2", "Miniconda", "1.2"), + ("Miniconda", "1.2.3", "Miniconda", "1.2.3"), + ("Miniconda", "1.2a1", "Miniconda", "1.2a1"), + ("Miniconda", "1.2b2", "Miniconda", "1.2b2"), + ("Miniconda", "1.2rc3", "Miniconda", "1.2rc3"), + ("Miniconda", "1.2.post4", "Miniconda", "1.2.post4"), + ("Miniconda", "1.2.dev5", "Miniconda", "1.2.dev5"), + ("Miniconda", "1.2rc3.post4.dev5", "Miniconda", "1.2rc3.post4.dev5"), + # Hyphens are treated as dots + ("Miniconda", "1.2-3", "Miniconda", "1.2.3"), + ("Miniconda", "1.2-3.4-5.6", "Miniconda", "1.2.3.4.5.6"), + # Additional text before and after the last valid version should be treated as + # part of the name. + ("Miniconda", "1.2 3.4 5.6", "Miniconda 1.2 3.4", "5.6"), + ("Miniconda", "1.2_3.4_5.6", "Miniconda 1.2_3.4", "5.6"), + ("Miniconda", "1.2c3", "Miniconda 1.2c", "3"), + ("Miniconda", "1.2rc3.dev5.post4", "Miniconda 1.2rc3.dev5.post", "4"), + ("Miniconda", "py313", "Miniconda py", "313"), + ("Miniconda", "py.313", "Miniconda py", "313"), + ("Miniconda", "py3.13", "Miniconda py", "3.13"), + ("Miniconda", "py313_1.2", "Miniconda py313", "1.2"), + ("Miniconda", "1.2 and more", "Miniconda and more", "1.2"), + ("Miniconda", "1.2! and more", "Miniconda ! and more", "1.2"), + ("Miniconda", "py313 1.2 and more", "Miniconda py313 and more", "1.2"), + # Numbers in the name are not added to the version. + ("Miniconda3", "1", "Miniconda3", "1"), + ], +) +def test_name_version(name_in, version_in, name_expected, version_expected): + name_actual, version_actual = get_name_version( + {"name": name_in, "version": version_in}, + ) + assert (name_actual, version_actual) == (name_expected, version_expected) + + +@pytest.mark.parametrize( + "info", + [ + {}, + {"name": ""}, + ], +) +def test_name_empty(info): + with pytest.raises(ValueError, match="Name is empty"): + get_name_version(info) + + +@pytest.mark.parametrize( + "info", + [ + {"name": "Miniconda"}, + {"name": "Miniconda", "version": ""}, + ], +) +def test_version_empty(info): + with pytest.raises(ValueError, match="Version is empty"): + get_name_version(info) + + +@pytest.mark.parametrize("version_in", ["x", ".", " ", "hello"]) +def test_version_invalid(version_in, caplog): + name_actual, version_actual = get_name_version( + {"name": "Miniconda3", "version": version_in}, + ) + assert name_actual == f"Miniconda3 {version_in}" + assert version_actual == "0.0.1" + assert caplog.messages == [ + f"Version {version_in!r} contains no valid version numbers; defaulting to 0.0.1" + ] + + +@pytest.mark.parametrize( + "rdi, name, bundle_expected, app_name_expected", + [ + # Valid rdi + ("org.conda", "ignored", "org", "conda"), + ("org.Conda", "ignored", "org", "Conda"), + ("org.conda-miniconda", "ignored", "org", "conda-miniconda"), + ("org.conda_miniconda", "ignored", "org", "conda_miniconda"), + ("org-conda.miniconda", "ignored", "org-conda", "miniconda"), + ("org.conda.miniconda", "ignored", "org.conda", "miniconda"), + ("org.conda.1", "ignored", "org.conda", "1"), + # Invalid rdi + ("org.hello-", "Miniconda", "org", "hello"), + ("org.-hello", "Miniconda", "org", "hello"), + ("org.hello world", "Miniconda", "org", "hello-world"), + ("org.hello!world", "Miniconda", "org", "hello-world"), + # Missing rdi + (None, "x", "io.continuum", "x"), + (None, "X", "io.continuum", "x"), + (None, "1", "io.continuum", "1"), + (None, "Miniconda", "io.continuum", "miniconda"), + (None, "Miniconda3", "io.continuum", "miniconda3"), + (None, "Miniconda3 py313", "io.continuum", "miniconda3-py313"), + (None, "Hello, world!", "io.continuum", "hello-world"), + ], +) +def test_bundle_app_name(rdi, name, bundle_expected, app_name_expected): + bundle_actual, app_name_actual = get_bundle_app_name({"reverse_domain_identifier": rdi}, name) + assert (bundle_actual, app_name_actual) == (bundle_expected, app_name_expected) + + +@pytest.mark.parametrize("rdi", ["", "org"]) +def test_rdi_no_dots(rdi): + with pytest.raises(ValueError, match=f"reverse_domain_identifier '{rdi}' contains no dots"): + get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") + + +@pytest.mark.parametrize("rdi", ["org.", "org.hello.", "org.hello.-"]) +def test_rdi_invalid_package(rdi): + with pytest.raises( + ValueError, + match=( + f"Last component of reverse_domain_identifier '{rdi}' " + f"contains no alphanumeric characters" + ), + ): + get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") + + +@pytest.mark.parametrize("name", ["", " ", "!", "-", "---"]) +def test_name_no_alphanumeric(name): + with pytest.raises(ValueError, match=f"Name '{name}' contains no alphanumeric characters"): + get_bundle_app_name({}, name) From 57260585bdba2fbeb3fa61d614d4b9f574b5fcbf Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:53:10 -0500 Subject: [PATCH 02/17] MSI: Integration tests (#1133) * Add tests * Some more fixes * Test commit to see if this resolves test failure * Set version back to X also for the other failing test * Review fixes * pre-commit * Add str conversion * Remove request usage for MSI --- .github/workflows/main.yml | 2 + constructor/briefcase.py | 23 ++- constructor/briefcase/run_installation.bat | 6 +- examples/azure_signtool/construct.yaml | 2 +- examples/custom_nsis_template/construct.yaml | 2 +- examples/customize_controls/construct.yaml | 2 +- .../construct.yaml | 2 +- examples/exe_extra_pages/construct.yaml | 2 +- examples/extra_envs/construct.yaml | 4 +- examples/extra_files/construct.yaml | 2 +- examples/from_env_txt/construct.yaml | 2 +- examples/from_env_yaml/construct.yaml | 4 +- examples/from_existing_env/construct.yaml | 2 +- examples/from_explicit/construct.yaml | 2 +- examples/initialization/construct.yaml | 2 +- examples/miniforge-mamba2/construct.yaml | 2 +- examples/miniforge/construct.yaml | 2 +- examples/mirrored_channels/construct.yaml | 2 +- examples/noconda/constructor_input.yaml | 2 +- examples/outputs/construct.yaml | 2 +- examples/protected_base/construct.yaml | 4 +- examples/register_envs/construct.yaml | 4 +- examples/scripts/construct.yaml | 4 +- examples/scripts/post_install.bat | 2 +- examples/scripts/post_install.sh | 2 +- examples/scripts/pre_install.bat | 2 +- examples/scripts/pre_install.sh | 2 +- examples/shortcuts/construct.yaml | 2 +- examples/signing/construct.yaml | 2 +- examples/virtual_specs_failed/construct.yaml | 2 +- examples/virtual_specs_ok/construct.yaml | 2 +- tests/test_examples.py | 169 ++++++++++++++++-- tests/test_main.py | 2 +- 33 files changed, 215 insertions(+), 53 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0f87e02c7..7bc9def77 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -137,6 +137,7 @@ jobs: run: conda list - name: conda config run: conda config --show-sources + - name: Run unit tests run: | pytest -ra -vvv --cov=constructor --cov-branch tests/ -m "not examples" @@ -155,6 +156,7 @@ jobs: AZURE_SIGNTOOL_KEY_VAULT_URL: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_URL }} CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS: "${{ runner.temp }}/examples_artifacts" CONSTRUCTOR_SIGNTOOL_PATH: "C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x86/signtool.exe" + CONSTRUCTOR_VERBOSE: 1 run: | rm -rf coverage.json pytest -ra -vvv --cov=constructor --cov-branch tests/test_examples.py diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 70569c794..1d8880485 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -5,12 +5,17 @@ import logging import re import shutil +import sys import sysconfig import tempfile from pathlib import Path from subprocess import run -import tomli_w +IS_WINDOWS = sys.platform == "win32" +if IS_WINDOWS: + import tomli_w +else: + tomli_w = None # This file is only intended for Windows use from . import preconda from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist @@ -100,6 +105,16 @@ def get_bundle_app_name(info, name): return bundle, app_name +def get_license(info): + """Retrieve the specified license as a dict or return a placeholder if not set.""" + + if "license_file" in info: + return {"file": info["license_file"]} + + placeholder_license = Path(__file__).parent / "nsis" / "placeholder_license.txt" + return {"file": str(placeholder_license)} # convert to str for TOML serialization + + # Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja # template allows us to avoid escaping strings everywhere. def write_pyproject_toml(tmp_dir, info): @@ -110,7 +125,7 @@ def write_pyproject_toml(tmp_dir, info): "project_name": name, "bundle": bundle, "version": version, - "license": ({"file": info["license_file"]} if "license_file" in info else {"text": ""}), + "license": get_license(info), "app": { app_name: { "formal_name": f"{info['name']} {info['version']}", @@ -130,6 +145,9 @@ def write_pyproject_toml(tmp_dir, info): def create(info, verbose=False): + if not IS_WINDOWS: + raise Exception(f"Invalid platform '{sys.platform}'. Only Windows is supported.") + tmp_dir = Path(tempfile.mkdtemp()) write_pyproject_toml(tmp_dir, info) @@ -150,7 +168,6 @@ def create(info, verbose=False): raise FileNotFoundError( f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}" ) - logger.info("Building installer") run( [briefcase, "package"] + (["-v"] if verbose else []), diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 190a6d9f7..267907bec 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,5 +1,5 @@ -set PREFIX=%cd% -_conda constructor --prefix %PREFIX% --extract-conda-pkgs +set "PREFIX=%cd%" +_conda constructor --prefix "%PREFIX%" --extract-conda-pkgs set CONDA_PROTECT_FROZEN_ENVS=0 set CONDA_ROOT_PREFIX=%PREFIX% @@ -7,4 +7,4 @@ set CONDA_SAFETY_CHECKS=disabled set CONDA_EXTRA_SAFETY_CHECKS=no set CONDA_PKGS_DIRS=%PREFIX%\pkgs -_conda install --offline --file %PREFIX%\conda-meta\initial-state.explicit.txt -yp %PREFIX% +_conda install --offline --file "%PREFIX%\conda-meta\initial-state.explicit.txt" -yp "%PREFIX%" diff --git a/examples/azure_signtool/construct.yaml b/examples/azure_signtool/construct.yaml index 86a6fb983..96498a702 100644 --- a/examples/azure_signtool/construct.yaml +++ b/examples/azure_signtool/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Signed_AzureSignTool -version: X +version: 1.0.0 installer_type: exe channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/custom_nsis_template/construct.yaml b/examples/custom_nsis_template/construct.yaml index 4b8eab0b4..4a59f423a 100644 --- a/examples/custom_nsis_template/construct.yaml +++ b/examples/custom_nsis_template/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: custom -version: X +version: 1.0.0 ignore_duplicate_files: True installer_filename: {{ name }}-installer.exe installer_type: exe diff --git a/examples/customize_controls/construct.yaml b/examples/customize_controls/construct.yaml index 907ba11c9..161ac88e9 100644 --- a/examples/customize_controls/construct.yaml +++ b/examples/customize_controls/construct.yaml @@ -3,7 +3,7 @@ name: NoCondaOptions version: X -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/customized_welcome_conclusion/construct.yaml b/examples/customized_welcome_conclusion/construct.yaml index 751e1305c..f02e55265 100644 --- a/examples/customized_welcome_conclusion/construct.yaml +++ b/examples/customized_welcome_conclusion/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: CustomizedWelcomeConclusion -version: X +version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/exe_extra_pages/construct.yaml b/examples/exe_extra_pages/construct.yaml index 5452ce126..957e926c3 100644 --- a/examples/exe_extra_pages/construct.yaml +++ b/examples/exe_extra_pages/construct.yaml @@ -7,7 +7,7 @@ {% set name = "extraPageSingle" %} {% endif %} name: {{ name }} -version: X +version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/extra_envs/construct.yaml b/examples/extra_envs/construct.yaml index b74124eeb..74746410e 100644 --- a/examples/extra_envs/construct.yaml +++ b/examples/extra_envs/construct.yaml @@ -2,8 +2,8 @@ "$schema": "../../constructor/data/construct.schema.json" name: ExtraEnvs -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - https://conda.anaconda.org/conda-forge specs: diff --git a/examples/extra_files/construct.yaml b/examples/extra_files/construct.yaml index 7b8c9a600..fa82dbfad 100644 --- a/examples/extra_files/construct.yaml +++ b/examples/extra_files/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: ExtraFiles -version: X +version: 1.0.0 installer_type: all license_file: TEST_LICENSE.txt check_path_spaces: False diff --git a/examples/from_env_txt/construct.yaml b/examples/from_env_txt/construct.yaml index ee8412dc7..5cb7ae774 100644 --- a/examples/from_env_txt/construct.yaml +++ b/examples/from_env_txt/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: EnvironmentTXT -version: X +version: 1.0.0 installer_type: all environment_file: env.txt initialize_by_default: false diff --git a/examples/from_env_yaml/construct.yaml b/examples/from_env_yaml/construct.yaml index d86bdeafb..6711be0c9 100644 --- a/examples/from_env_yaml/construct.yaml +++ b/examples/from_env_yaml/construct.yaml @@ -2,8 +2,8 @@ "$schema": "../../constructor/data/construct.schema.json" name: EnvironmentYAML -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} environment_file: env.yaml initialize_by_default: false register_python: False diff --git a/examples/from_existing_env/construct.yaml b/examples/from_existing_env/construct.yaml index 89df7b411..b45540a0b 100644 --- a/examples/from_existing_env/construct.yaml +++ b/examples/from_existing_env/construct.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=../../constructor/data/construct.schema.json "$schema": "../../constructor/data/construct.schema.json" name: Existing -version: X +version: 1.0.0 installer_type: all environment: {{ os.environ.get("CONSTRUCTOR_TEST_EXISTING_ENV", os.environ["CONDA_PREFIX"]) }} channels: diff --git a/examples/from_explicit/construct.yaml b/examples/from_explicit/construct.yaml index 9137fa8f7..6e07790cd 100644 --- a/examples/from_explicit/construct.yaml +++ b/examples/from_explicit/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Explicit -version: X +version: 1.0.0 installer_type: all environment_file: explicit_linux-64.txt initialize_by_default: false diff --git a/examples/initialization/construct.yaml b/examples/initialization/construct.yaml index 1e980532f..8f38e6631 100644 --- a/examples/initialization/construct.yaml +++ b/examples/initialization/construct.yaml @@ -18,4 +18,4 @@ initialize_by_default: true register_python: false check_path_spaces: true check_path_length: false -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} diff --git a/examples/miniforge-mamba2/construct.yaml b/examples/miniforge-mamba2/construct.yaml index 98feefec3..becb523f4 100644 --- a/examples/miniforge-mamba2/construct.yaml +++ b/examples/miniforge-mamba2/construct.yaml @@ -21,7 +21,7 @@ specs: - miniforge_console_shortcut 1.* # [win] # Added for extra testing -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} post_install: test_install.sh # [unix] post_install: test_install.bat # [win] initialize_by_default: false diff --git a/examples/miniforge/construct.yaml b/examples/miniforge/construct.yaml index eb894cc91..52b961da9 100644 --- a/examples/miniforge/construct.yaml +++ b/examples/miniforge/construct.yaml @@ -21,7 +21,7 @@ specs: - miniforge_console_shortcut 1.* # [win] # Added for extra testing -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} post_install: test_install.sh # [unix] post_install: test_install.bat # [win] initialize_by_default: false diff --git a/examples/mirrored_channels/construct.yaml b/examples/mirrored_channels/construct.yaml index f105c6d0c..6e7ab9d81 100644 --- a/examples/mirrored_channels/construct.yaml +++ b/examples/mirrored_channels/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Mirrors -version: X +version: 1.0.0 channels: - conda-forge diff --git a/examples/noconda/constructor_input.yaml b/examples/noconda/constructor_input.yaml index 0a17e6cb2..b5641de01 100644 --- a/examples/noconda/constructor_input.yaml +++ b/examples/noconda/constructor_input.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: NoConda -version: X +version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/outputs/construct.yaml b/examples/outputs/construct.yaml index 01aa24a0a..9080dc36d 100644 --- a/examples/outputs/construct.yaml +++ b/examples/outputs/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Outputs -version: X +version: 1.0.0 installer_type: sh # [unix] installer_type: exe # [win] channels: diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml index 7e2e09a49..e8f18a630 100644 --- a/examples/protected_base/construct.yaml +++ b/examples/protected_base/construct.yaml @@ -2,8 +2,8 @@ "$schema": "../../constructor/data/construct.schema.json" name: ProtectedBaseEnv -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - defaults diff --git a/examples/register_envs/construct.yaml b/examples/register_envs/construct.yaml index 86d621561..31d72c9f6 100644 --- a/examples/register_envs/construct.yaml +++ b/examples/register_envs/construct.yaml @@ -2,8 +2,8 @@ "$schema": "../../constructor/data/construct.schema.json" name: RegisterEnvs -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - https://repo.anaconda.com/pkgs/main/ specs: diff --git a/examples/scripts/construct.yaml b/examples/scripts/construct.yaml index 33dcfee91..d55057bbd 100644 --- a/examples/scripts/construct.yaml +++ b/examples/scripts/construct.yaml @@ -2,8 +2,8 @@ "$schema": "../../constructor/data/construct.schema.json" name: Scripts -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - https://repo.anaconda.com/pkgs/main/ specs: diff --git a/examples/scripts/post_install.bat b/examples/scripts/post_install.bat index bc9103425..2c2c57af5 100644 --- a/examples/scripts/post_install.bat +++ b/examples/scripts/post_install.bat @@ -1,6 +1,6 @@ echo Added by post-install script > "%PREFIX%\post_install_sentinel.txt" if not "%INSTALLER_NAME%" == "Scripts" exit 1 -if not "%INSTALLER_VER%" == "X" exit 1 +if not "%INSTALLER_VER%" == "1.0.0" exit 1 if not "%INSTALLER_PLAT%" == "win-64" exit 1 if not "%INSTALLER_TYPE%" == "EXE" exit 1 if not "%INSTALLER_UNATTENDED%" == "1" exit 1 diff --git a/examples/scripts/post_install.sh b/examples/scripts/post_install.sh index 2158bf640..00a0095e6 100644 --- a/examples/scripts/post_install.sh +++ b/examples/scripts/post_install.sh @@ -15,7 +15,7 @@ echo "CUSTOM_VARIABLE_2=${CUSTOM_VARIABLE_2}" echo "PREFIX=${PREFIX}" test "${INSTALLER_NAME}" = "Scripts" -test "${INSTALLER_VER}" = "X" +test "${INSTALLER_VER}" = "1.0.0" # shellcheck disable=SC2016 # String interpolation disabling is deliberate test "${CUSTOM_VARIABLE_1}" = 'FIR$T-CUSTOM_'\''STRING'\'' WITH SPACES AND @*! "CHARACTERS"' # shellcheck disable=SC2016 # String interpolation disabling is deliberate diff --git a/examples/scripts/pre_install.bat b/examples/scripts/pre_install.bat index ec4fce07c..22a529daa 100644 --- a/examples/scripts/pre_install.bat +++ b/examples/scripts/pre_install.bat @@ -1,5 +1,5 @@ if not "%INSTALLER_NAME%" == "Scripts" exit 1 -if not "%INSTALLER_VER%" == "X" exit 1 +if not "%INSTALLER_VER%" == "1.0.0" exit 1 if not "%INSTALLER_PLAT%" == "win-64" exit 1 if not "%INSTALLER_TYPE%" == "EXE" exit 1 if not "%INSTALLER_UNATTENDED%" == "1" exit 1 diff --git a/examples/scripts/pre_install.sh b/examples/scripts/pre_install.sh index 753db8121..88b111630 100644 --- a/examples/scripts/pre_install.sh +++ b/examples/scripts/pre_install.sh @@ -12,7 +12,7 @@ echo "CUSTOM_VARIABLE_2=${CUSTOM_VARIABLE_2}" echo "PREFIX=${PREFIX}" test "${INSTALLER_NAME}" = "Scripts" -test "${INSTALLER_VER}" = "X" +test "${INSTALLER_VER}" = "1.0.0" # shellcheck disable=SC2016 # String interpolation disabling is deliberate test "${CUSTOM_VARIABLE_1}" = 'FIR$T-CUSTOM_'\''STRING'\'' WITH SPACES AND @*! "CHARACTERS"' # shellcheck disable=SC2016 # String interpolation disabling is deliberate diff --git a/examples/shortcuts/construct.yaml b/examples/shortcuts/construct.yaml index a17be497c..e7c8877f4 100644 --- a/examples/shortcuts/construct.yaml +++ b/examples/shortcuts/construct.yaml @@ -3,7 +3,7 @@ name: MinicondaWithShortcuts version: X -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - conda-test/label/menuinst-tests diff --git a/examples/signing/construct.yaml b/examples/signing/construct.yaml index 07bf9685f..0889a3387 100644 --- a/examples/signing/construct.yaml +++ b/examples/signing/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Signed -version: X +version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/virtual_specs_failed/construct.yaml b/examples/virtual_specs_failed/construct.yaml index f3b554872..12d886b9f 100644 --- a/examples/virtual_specs_failed/construct.yaml +++ b/examples/virtual_specs_failed/construct.yaml @@ -22,4 +22,4 @@ initialize_by_default: false register_python: false check_path_spaces: false check_path_length: false -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} diff --git a/examples/virtual_specs_ok/construct.yaml b/examples/virtual_specs_ok/construct.yaml index 41635eefc..15655811b 100644 --- a/examples/virtual_specs_ok/construct.yaml +++ b/examples/virtual_specs_ok/construct.yaml @@ -22,4 +22,4 @@ initialize_by_default: false register_python: false check_path_spaces: false check_path_length: false -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} diff --git a/tests/test_examples.py b/tests/test_examples.py index 4ec5c9a64..9b653d595 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ctypes import getpass import json import os @@ -52,6 +53,7 @@ REPO_DIR = Path(__file__).parent.parent ON_CI = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" CONSTRUCTOR_CONDA_EXE = os.environ.get("CONSTRUCTOR_CONDA_EXE") +CONSTRUCTOR_VERBOSE = os.environ.get("CONSTRUCTOR_VERBOSE") CONDA_EXE, CONDA_EXE_VERSION = identify_conda_exe(CONSTRUCTOR_CONDA_EXE) if CONDA_EXE_VERSION is not None: CONDA_EXE_VERSION = Version(CONDA_EXE_VERSION) @@ -337,6 +339,98 @@ def _sentinel_file_checks(example_path, install_dir): ) +def is_admin() -> bool: + try: + return ctypes.windll.shell32.IsUserAnAdmin() + except Exception: + return False + + +def calculate_msi_install_path(installer: Path) -> Path: + """This is a temporary solution for now since we cannot choose the install location ourselves. + Installers are named --Windows-x86_64.msi. + """ + dir_name = installer.name.replace("-Windows-x86_64.msi", "").replace("-", " ") + if is_admin(): + root_dir = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) + else: + local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) + root_dir = Path(local_dir) / "Programs" + + assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors + return Path(root_dir) / dir_name + + +def _run_installer_msi( + installer: Path, + install_dir: Path, + installer_input=None, + timeout=420, + check=True, + options: list | None = None, +): + """Runs specified MSI Installer via command line in silent mode. This is work in progress.""" + if not sys.platform.startswith("win"): + raise ValueError("Can only run .msi installers on Windows") + + # Currently we only have 1 test that specifies options, so this is a temporary "fix" + if options: + allusers = "/InstallationType=AllUsers" in options + else: + allusers = False + + cmd = [ + "msiexec.exe", + "/i", + str(installer), + "ALLUSERS=1" + if allusers + else "MSIINSTALLPERUSER=1", # For some reason tests fail on the CI system if "ALLUSERS=1" + "/qn", + ] + + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + ".log") + cmd.extend(["/L*V", str(log_path)]) + try: + process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) + except subprocess.CalledProcessError as e: + if log_path.exists(): + # When running on the CI system, it tries to decode a UTF-16 log file as UTF-8, + # therefore we need to specify encoding before printing. + print(f"\n=== MSI LOG {log_path} START ===") + print( + log_path.read_text(encoding="utf-16", errors="replace")[-15000:] + ) # last 15k chars + print(f"\n=== MSI LOG {log_path} END ===") + raise e + if check: + print("A check for MSI Installers not yet implemented") + return process + + +def _run_uninstaller_msi( + installer: Path, + install_dir: Path, + timeout: int = 420, + check: bool = True, +) -> subprocess.CompletedProcess | None: + cmd = [ + "msiexec.exe", + "/x", + str(installer), + "/qn", + ] + process = _execute(cmd, timeout=timeout, check=check) + if check: + # TODO: + # Check log and if there are remaining files, similar to the exe installers + pass + # This is temporary until uninstallation works fine + shutil.rmtree(str(install_dir), ignore_errors=True) + + return process + + def _run_installer( example_path: Path, installer: Path, @@ -382,12 +476,27 @@ def _run_installer( timeout=timeout, check=check_subprocess, ) + elif installer.suffix == ".msi": + process = _run_installer_msi( + installer, + install_dir, + installer_input=installer_input, + timeout=timeout, + check=check_subprocess, + options=options, + ) else: raise ValueError(f"Unknown installer type: {installer.suffix}") - if check_sentinels and not (installer.suffix == ".pkg" and ON_CI): + + if installer.suffix == ".msi": + print("sentinel_file_checks for MSI installers not yet implemented") + elif check_sentinels and not (installer.suffix == ".pkg" and ON_CI): _sentinel_file_checks(example_path, install_dir) - if uninstall and installer.suffix == ".exe": - _run_uninstaller_exe(install_dir, timeout=timeout, check=check_subprocess) + if uninstall: + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir, timeout=timeout, check=check_subprocess) + elif installer.suffix == ".exe": + _run_uninstaller_exe(install_dir, timeout=timeout, check=check_subprocess) return process @@ -407,16 +516,19 @@ def create_installer( output_dir = workspace / "installer" output_dir.mkdir(parents=True, exist_ok=True) - cmd = [ - *COV_CMD, - "constructor", - "-v", + cmd = [*COV_CMD, "constructor"] + # This flag will (if enabled) create a lot of output upon test failures for .exe-installers. + # If debugging generated NSIS templates, it can be worth to enable. + if CONSTRUCTOR_VERBOSE: + cmd.append("-v") + cmd += [ str(input_dir), "--output-dir", str(output_dir), "--config-filename", config_filename, ] + if conda_exe: cmd.extend(["--conda-exe", conda_exe]) if debug: @@ -430,18 +542,21 @@ def create_installer( def _sort_by_extension(path): "Return shell installers first so they are run before the GUI ones" - return {"sh": 1, "pkg": 2, "exe": 3}[path.suffix[1:]], path + return {"sh": 1, "pkg": 2, "exe": 3, "msi": 4}[path.suffix[1:]], path - installers = (p for p in output_dir.iterdir() if p.suffix in (".exe", ".sh", ".pkg")) + installers = (p for p in output_dir.iterdir() if p.suffix in (".exe", ".msi", ".sh", ".pkg")) for installer in sorted(installers, key=_sort_by_extension): if installer.suffix == ".pkg" and ON_CI: install_dir = Path("~").expanduser() / calculate_install_dir( input_dir / config_filename ) + elif installer.suffix == ".msi": + install_dir = calculate_msi_install_path(installer) else: install_dir = ( workspace / f"{install_dir_prefix}-{installer.stem}-{installer.suffix[1:]}" ) + yield installer, install_dir if KEEP_ARTIFACTS_PATH: try: @@ -529,13 +644,23 @@ def test_example_extra_envs(tmp_path, request): assert "@EXPLICIT" in envtxt.read_text() if sys.platform.startswith("win"): - _run_uninstaller_exe(install_dir=install_dir) + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir) + else: + _run_uninstaller_exe(install_dir=install_dir) def test_example_extra_files(tmp_path, request): input_path = _example_path("extra_files") for installer, install_dir in create_installer(input_path, tmp_path, with_spaces=True): - _run_installer(input_path, installer, install_dir, request=request) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_sentinels=CONSTRUCTOR_VERBOSE, + check_subprocess=CONSTRUCTOR_VERBOSE, + ) def test_example_mirrored_channels(tmp_path, request): @@ -612,6 +737,10 @@ def test_example_miniforge(tmp_path, request, example): raise AssertionError("Could not find Start Menu folder for miniforge") _run_uninstaller_exe(install_dir) assert not list(start_menu_dir.glob("Miniforge*.lnk")) + elif installer.suffix == ".msi": + # TODO: Start menus + _run_uninstaller_msi(installer, install_dir) + raise NotImplementedError("Test needs to be implemented") def test_example_noconda(tmp_path, request): @@ -779,7 +908,10 @@ def test_example_shortcuts(tmp_path, request): break else: raise AssertionError("No shortcuts found!") - _run_uninstaller_exe(install_dir) + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir) + else: + _run_uninstaller_exe(install_dir) assert not (package_1 / "A.lnk").is_file() assert not (package_1 / "B.lnk").is_file() elif sys.platform == "darwin": @@ -921,8 +1053,11 @@ def test_example_from_explicit(tmp_path, request): def test_register_envs(tmp_path, request): + """Verify that 'register_envs: False' results in the environment not being registered.""" input_path = _example_path("register_envs") for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + raise NotImplementedError("Test for 'register_envs' not yet implemented for MSI") _run_installer(input_path, installer, install_dir, request=request) environments_txt = Path("~/.conda/environments.txt").expanduser().read_text() assert str(install_dir) not in environments_txt @@ -983,6 +1118,7 @@ def test_cross_osx_building(tmp_path): ) +@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix only") def test_cross_build_example(tmp_path, platform_conda_exe): platform, conda_exe = platform_conda_exe input_path = _example_path("virtual_specs_ok") @@ -998,6 +1134,7 @@ def test_cross_build_example(tmp_path, platform_conda_exe): def test_virtual_specs_failed(tmp_path, request): + """Verify that virtual packages listed via 'virtual_specs' are satisfied.""" input_path = _example_path("virtual_specs_failed") for installer, install_dir in create_installer(input_path, tmp_path): process = _run_installer( @@ -1013,6 +1150,8 @@ def test_virtual_specs_failed(tmp_path, request): with pytest.raises(AssertionError, match="Failed to check virtual specs"): _check_installer_log(install_dir) continue + elif installer.suffix == ".msi": + raise NotImplementedError("Test for 'virtual_specs' not yet implemented for MSI") elif installer.suffix == ".pkg": if not ON_CI: continue @@ -1075,6 +1214,8 @@ def test_initialization(tmp_path, request, monkeypatch, method): # GHA runs on an admin user account, but AllUsers (admin) installs # do not add to PATH due to CVE-2022-26526, so force single user install options = ["/AddToPath=1", "/InstallationType=JustMe"] + elif installer.suffix == ".msi": + raise NotImplementedError("Test needs to be implemented") else: options = [] _run_installer( @@ -1108,6 +1249,8 @@ def test_initialization(tmp_path, request, monkeypatch, method): finally: _run_uninstaller_exe(install_dir, check=True) + elif installer.suffix == ".msi": + raise NotImplementedError("Test needs to be implemented") else: # GHA's Ubuntu needs interactive, but macOS wants login :shrug: login_flag = "-i" if sys.platform.startswith("linux") else "-l" @@ -1473,7 +1616,7 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): """Verify the app is in the Installed Apps Menu (or not), based on the CLI arg '/NoRegistry'. If NoRegistry=0, we expect to find the installer in the Menu, otherwise not. """ - input_path = _example_path("extra_files") # The specific example we use here is not important + input_path = _example_path("register_envs") # The specific example we use here is not important options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): _run_installer( diff --git a/tests/test_main.py b/tests/test_main.py index 896c7d8a6..e17f69f54 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,7 +7,7 @@ def test_dry_run(tmp_path): inputfile = dedent( """ name: test_schema_validation - version: X + version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ From 95cdd5e5a66e2a24d90c346c0458ba68828d4ab3 Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:29:02 -0500 Subject: [PATCH 03/17] Briefcase installer options (#1144) * Add Install Options Page - WIP * Review fixes * Fix description * Remove comment and fix format * Review fixes * Remove 'self' * Review fixes --- constructor/briefcase.py | 104 +++++++++++++++++++++++++++++++++++++++ constructor/winexe.py | 2 +- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 1d8880485..cb848b8a6 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -115,6 +115,109 @@ def get_license(info): return {"file": str(placeholder_license)} # convert to str for TOML serialization +def is_bat_file(file_path: Path) -> bool: + return file_path.is_file() and file_path.suffix.lower() == ".bat" + + +def create_install_options_list(info: dict) -> list[dict]: + """Returns a list of dicts with data formatted for the installation options page.""" + options = [] + + # Register Python (if Python is bundled) + has_python = False + for item in info.get("_dists", []): + if item.startswith("python-"): + components = item.split("-") # python-x.y.z-.suffix + python_version = ".".join(components[1].split(".")[:-1]) # create the string "x.y" + has_python = True + break + + if has_python and info.get("register_python", True): + options.append( + { + "name": "register_python", + "title": f"Register {info['name']} as my default Python {python_version}.", + "description": "Allows other programs, such as VSCode, PyCharm, etc. to automatically " + f"detect {info['name']} as the primary Python {python_version} on the system.", + "default": info.get("register_python_default", False), + } + ) + + # Initialize conda + initialize_conda = info.get("initialize_conda", "classic") + if initialize_conda: + if initialize_conda == "condabin": + description = ( + "Adds condabin, which only contains the 'conda' executables, to PATH. " + "Does not require special shortcuts but activation needs " + "to be performed manually." + ) + else: + description = ( + "NOT recommended. This can lead to conflicts with other applications. " + "Instead, use the Command Prompt and Powershell menus added to the Windows Start Menu." + ) + options.append( + { + "name": "initialize_conda", + "title": "Add installation to my PATH environment variable", + "description": description, + "default": info.get("initialize_by_default", False), + } + ) + + # Keep package option (presented to the user as a negation (clear package cache)) + clear_package_cache = not info.get("keep_pkgs", False) + options.append( + { + "name": "clear_package_cache", + "title": "Clear the package cache upon completion", + "description": "Recommended. Recovers some disk space without harming functionality.", + "default": clear_package_cache, + } + ) + + # Enable shortcuts + if info.get("_enable_shortcuts", False) is True: + options.append( + { + "name": "enable_shortcuts", + "title": "Create shortcuts", + "description": "Create shortcuts (supported packages only).", + "default": False, + } + ) + + # Pre/Post install script + for script_type in ["pre", "post"]: + script_description = info.get(f"{script_type}_install_desc", "") + script = info.get(f"{script_type}_install", "") + if script_description and not script: + raise ValueError( + f"{script_type}_install_desc was set, but {script_type}_install was not!" + ) + + if script: + script_path = Path(script) + if not is_bat_file(script_path): + raise ValueError( + f"Specified {script_type}-install script '{script}' must be an existing '.bat' file." + ) + + # The UI option is only displayed if a description is set + if script_description: + options.append( + { + "name": f"{script_type}_install_script", + "title": f"{script_type.capitalize()}-install script", + "description": script_description, + "default": False, + } + ) + + return options + + # Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja # template allows us to avoid escaping strings everywhere. def write_pyproject_toml(tmp_dir, info): @@ -134,6 +237,7 @@ def write_pyproject_toml(tmp_dir, info): "use_full_install_path": False, "install_launcher": False, "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), + "install_option": create_install_options_list(info), } }, } diff --git a/constructor/winexe.py b/constructor/winexe.py index caf49f066..6a279bf03 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -252,7 +252,7 @@ def make_nsi( variables["initialize_by_default"] = info.get("initialize_by_default", None) variables["check_path_length"] = info.get("check_path_length", False) variables["check_path_spaces"] = info.get("check_path_spaces", True) - variables["keep_pkgs"] = info.get("keep_pkgs") or False + variables["keep_pkgs"] = info.get("keep_pkgs", False) variables["pre_install_exists"] = bool(info.get("pre_install")) variables["post_install_exists"] = bool(info.get("post_install")) variables["with_conclusion_text"] = bool(conclusion_text) From e19e65b2420db93b578b8199c1d12cc82b62c3a2 Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:28:17 -0500 Subject: [PATCH 04/17] MSI: Change installer layout (#1160) * Work in progress * Work in progress * Remove unnecessary packages * Remove call to 'pause' that was used for debugging * Fix grammar in error message Co-authored-by: Marco Esters --------- Co-authored-by: Marco Esters --- constructor/briefcase.py | 15 +++++++++++---- constructor/briefcase/run_installation.bat | 14 +++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index cb848b8a6..782881d09 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -250,18 +250,25 @@ def write_pyproject_toml(tmp_dir, info): def create(info, verbose=False): if not IS_WINDOWS: - raise Exception(f"Invalid platform '{sys.platform}'. Only Windows is supported.") + raise Exception(f"Invalid platform '{sys.platform}'. MSI installers require Windows.") tmp_dir = Path(tempfile.mkdtemp()) write_pyproject_toml(tmp_dir, info) external_dir = tmp_dir / EXTERNAL_PACKAGE_PATH external_dir.mkdir() - preconda.write_files(info, external_dir) + + # Create the sub-directory "base", + # note that the directory name "base" is also explicitly + # defined in `run_installation.bat` + base_dir = external_dir / "base" + base_dir.mkdir() + + preconda.write_files(info, base_dir) preconda.copy_extra_files(info.get("extra_files", []), external_dir) download_dir = Path(info["_download_dir"]) - pkgs_dir = external_dir / "pkgs" + pkgs_dir = base_dir / "pkgs" for dist in info["_dists"]: shutil.copy(download_dir / filename_dist(dist), pkgs_dir) @@ -282,7 +289,7 @@ def create(info, verbose=False): dist_dir = tmp_dir / "dist" msi_paths = list(dist_dir.glob("*.msi")) if len(msi_paths) != 1: - raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}") + raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}, expected 1.") outpath = Path(info["_outpath"]) outpath.unlink(missing_ok=True) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 267907bec..ec8cc35b9 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,10 +1,14 @@ -set "PREFIX=%cd%" -_conda constructor --prefix "%PREFIX%" --extract-conda-pkgs +set "INSTDIR=%cd%" +set "BASE_PATH=%INSTDIR%\base" +set "PREFIX=%BASE_PATH%" +set "CONDA_EXE=%INSTDIR%\_conda.exe" + +"%INSTDIR%\_conda.exe" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs set CONDA_PROTECT_FROZEN_ENVS=0 -set CONDA_ROOT_PREFIX=%PREFIX% +set "CONDA_ROOT_PREFIX=%BASE_PATH%" set CONDA_SAFETY_CHECKS=disabled set CONDA_EXTRA_SAFETY_CHECKS=no -set CONDA_PKGS_DIRS=%PREFIX%\pkgs +set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" -_conda install --offline --file "%PREFIX%\conda-meta\initial-state.explicit.txt" -yp "%PREFIX%" +"%INSTDIR%\_conda.exe" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" From 9e31debb8afad32ef8e13713c99a8924dbffe8ee Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:48:25 -0500 Subject: [PATCH 05/17] MSI: Reorganize payload preparation (#1164) * Reorganize payload into a class and add tests * Ensure new tests are Windows only * Add jinja templating, payload as tar, tests * Update docstring * Update briefcase.py * Inline test utility function * Remove payload tar, update pre_uninstall.bat * Use conda-standalone for extracting tar * Merge archive functions, update root as cached property * Remove template_file.py improve handling of templates * Add missing .dst * Remove compresslevel arg * Review fixes * Fix typo in file name causing build errors * Dynamically set archive type from file name * Rename class function and update docstring * Update uninstallation scripts * Update pre_uninstall.bat * Add logging * Improve log handling for msi tests * Add register_envs * Fix syntax error with remove * Ensure .exe test not running for MSI * Properly disable test for MSI * Add more tests and another check errorlevel * Make logging more neat * Removed all use of 'sanity' * Updated test for MSI (remove pytest.skip) * Update to use CLI for newer conda-standalone * Fix missing quote and properly use --log-file * Docstring formatting * pre-commit fix * Hopefully fix issue with conda-standalone canary * Fix typo in workflow * Automatically create 'dst' * Remove TemplateFile * Fix docstring * FIx pre-commit * Always log to file * Generalize install/uninstall logs and move into INSTDIR * pre-commit fix * Remove PayloadLayout * Fix remaining syntax error * Review fixes --- .github/workflows/main.yml | 2 +- constructor/briefcase.py | 219 ++++++++++++++++----- constructor/briefcase/pre_uninstall.bat | 53 +++++ constructor/briefcase/run_installation.bat | 68 ++++++- examples/register_envs/construct.yaml | 2 +- tests/test_briefcase.py | 145 +++++++++++++- tests/test_examples.py | 183 ++++++++++++----- 7 files changed, 569 insertions(+), 103 deletions(-) create mode 100644 constructor/briefcase/pre_uninstall.bat diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7bc9def77..39610000a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -156,7 +156,7 @@ jobs: AZURE_SIGNTOOL_KEY_VAULT_URL: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_URL }} CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS: "${{ runner.temp }}/examples_artifacts" CONSTRUCTOR_SIGNTOOL_PATH: "C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x86/signtool.exe" - CONSTRUCTOR_VERBOSE: 1 + CONSTRUCTOR_VERBOSE: 0 run: | rm -rf coverage.json pytest -ra -vvv --cov=constructor --cov-branch tests/test_examples.py diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 782881d09..769ee707c 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -2,12 +2,15 @@ Logic to build installers using Briefcase. """ +import functools import logging import re import shutil import sys import sysconfig +import tarfile import tempfile +from dataclasses import dataclass from pathlib import Path from subprocess import run @@ -18,6 +21,7 @@ tomli_w = None # This file is only intended for Windows use from . import preconda +from .jinja import render_template from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist BRIEFCASE_DIR = Path(__file__).parent / "briefcase" @@ -218,75 +222,192 @@ def create_install_options_list(info: dict) -> list[dict]: return options -# Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja -# template allows us to avoid escaping strings everywhere. -def write_pyproject_toml(tmp_dir, info): - name, version = get_name_version(info) - bundle, app_name = get_bundle_app_name(info, name) - - config = { - "project_name": name, - "bundle": bundle, - "version": version, - "license": get_license(info), - "app": { - app_name: { - "formal_name": f"{info['name']} {info['version']}", - "description": "", # Required, but not used in the installer. - "external_package_path": EXTERNAL_PACKAGE_PATH, - "use_full_install_path": False, - "install_launcher": False, - "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), - "install_option": create_install_options_list(info), - } - }, - } +@dataclass +class Payload: + """ + This class manages and prepares a payload with a temporary directory. + """ + + info: dict + archive_name: str = "payload.tar.gz" + conda_exe_name: str = "_conda.exe" + + # Enable additional log output during pre/post uninstall/install. + add_debug_logging: bool = False + + @functools.cached_property + def root(self) -> Path: + """Create root upon first access and cache it.""" + return Path(tempfile.mkdtemp(prefix="payload-")) + + def remove(self, *, ignore_errors: bool = True) -> None: + """Remove the root of the payload. + + This function requires some extra care due to the root directory being a cached property. + """ + root = getattr(self, "root", None) + if root is None: + return + shutil.rmtree(root, ignore_errors=ignore_errors) + # Now we drop the cached value so next access will recreate if desired + try: + delattr(self, "root") + except Exception: + # delattr on a cached_property may raise on some versions / edge cases + pass + + def prepare(self) -> tuple: + """Prepares the payload. + + Directory structure created during preparation: + + / (temporary directory, see :attr:`root`) + └── / (external_dir: contains the payload archive and conda exe) + └── base/ (base_dir: represents the base conda environment) + └── pkgs/ (pkgs_dir: staging area for conda package distributions) + """ + root = self.root + external_dir = root / EXTERNAL_PACKAGE_PATH + external_dir.mkdir(parents=True, exist_ok=True) + + # Note that the directory name "base" is also explicitly defined in `run_installation.bat` + base_dir = external_dir / "base" + base_dir.mkdir() + + pkgs_dir = base_dir / "pkgs" + pkgs_dir.mkdir() + # Render the template files and add them to the necessary config field + self.render_templates() + self.write_pyproject_toml(root, external_dir) + + preconda.write_files(self.info, base_dir) + preconda.copy_extra_files(self.info.get("extra_files", []), external_dir) + self._stage_dists(pkgs_dir) + self._stage_conda(external_dir) + + archive_path = self.make_archive(base_dir, external_dir) + if not archive_path.exists(): + raise RuntimeError(f"Unexpected error, failed to create archive: {archive_path}") + return (root, external_dir, base_dir, pkgs_dir) + + def make_archive(self, src: Path, dst: Path) -> Path: + """Create an archive of the directory 'src'. + The input 'src' must be an existing directory. + If 'dst' does not exist, this function will create it. + The directory specified via 'src' is removed after successful creation. + Returns the path to the archive. + + Example: + payload = Payload(...) + foo = Path('foo') + bar = Path('bar') + targz = payload.make_archive(foo, bar) + This will create the file bar\\ containing 'foo' and all its contents. + + """ + if not src.is_dir(): + raise NotADirectoryError(src) + dst.mkdir(parents=True, exist_ok=True) + + archive_path = dst / self.archive_name + + archive_type = archive_path.suffix[1:] # since suffix starts with '.' + with tarfile.open(archive_path, mode=f"w:{archive_type}", compresslevel=1) as tar: + tar.add(src, arcname=src.name) + + shutil.rmtree(src) + return archive_path + + def render_templates(self) -> list[Path]: + """Render the configured templates under the payload root, + returns a list of Paths to the rendered templates. + """ + templates = { + Path(BRIEFCASE_DIR / "run_installation.bat"): Path(self.root / "run_installation.bat"), + Path(BRIEFCASE_DIR / "pre_uninstall.bat"): Path(self.root / "pre_uninstall.bat"), + } - if "company" in info: - config["author"] = info["company"] + context: dict[str, str] = { + "archive_name": self.archive_name, + "conda_exe_name": self.conda_exe_name, + "add_debug": self.add_debug_logging, + "register_envs": str(self.info.get("register_envs", True)).lower(), + } - (tmp_dir / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}})) + # Render the templates now using jinja and the defined context + for src, dst in templates.items(): + if not src.exists(): + raise FileNotFoundError(src) + rendered = render_template(src.read_text(encoding="utf-8"), **context) + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(rendered, encoding="utf-8", newline="\r\n") + + return list(templates.values()) + + def write_pyproject_toml(self, root: Path, external: Path) -> None: + name, version = get_name_version(self.info) + bundle, app_name = get_bundle_app_name(self.info, name) + + config = { + "project_name": name, + "bundle": bundle, + "version": version, + "license": get_license(self.info), + "app": { + app_name: { + "formal_name": f"{self.info['name']} {self.info['version']}", + "description": "", # Required, but not used in the installer. + "external_package_path": str(external), + "use_full_install_path": False, + "install_launcher": False, + "install_option": create_install_options_list(self.info), + "post_install_script": str(root / "run_installation.bat"), + "pre_uninstall_script": str(root / "pre_uninstall.bat"), + } + }, + } + # Add optional content + if "company" in self.info: + config["author"] = self.info["company"] -def create(info, verbose=False): - if not IS_WINDOWS: - raise Exception(f"Invalid platform '{sys.platform}'. MSI installers require Windows.") + # Finalize + (root / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}})) + logger.debug(f"Created TOML file at: {root}") - tmp_dir = Path(tempfile.mkdtemp()) - write_pyproject_toml(tmp_dir, info) + def _stage_dists(self, pkgs_dir: Path) -> None: + download_dir = Path(self.info["_download_dir"]) + for dist in self.info["_dists"]: + shutil.copy(download_dir / filename_dist(dist), pkgs_dir) - external_dir = tmp_dir / EXTERNAL_PACKAGE_PATH - external_dir.mkdir() + def _stage_conda(self, external_dir: Path) -> None: + copy_conda_exe(external_dir, self.conda_exe_name, self.info["_conda_exe"]) - # Create the sub-directory "base", - # note that the directory name "base" is also explicitly - # defined in `run_installation.bat` - base_dir = external_dir / "base" - base_dir.mkdir() - preconda.write_files(info, base_dir) - preconda.copy_extra_files(info.get("extra_files", []), external_dir) +def create(info, verbose=False): + if not IS_WINDOWS: + raise Exception(f"Invalid platform '{sys.platform}'. MSI installers require Windows.") - download_dir = Path(info["_download_dir"]) - pkgs_dir = base_dir / "pkgs" - for dist in info["_dists"]: - shutil.copy(download_dir / filename_dist(dist), pkgs_dir) + if not info.get("_conda_exe_supports_logging"): + raise Exception("MSI installers require conda-standalone with logging support.") - copy_conda_exe(external_dir, "_conda.exe", info["_conda_exe"]) + payload = Payload(info) + payload.prepare() briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe" if not briefcase.exists(): raise FileNotFoundError( f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}" ) - logger.info("Building installer") + + logger.info("Building MSI installer") run( [briefcase, "package"] + (["-v"] if verbose else []), - cwd=tmp_dir, + cwd=payload.root, check=True, ) - dist_dir = tmp_dir / "dist" + dist_dir = payload.root / "dist" msi_paths = list(dist_dir.glob("*.msi")) if len(msi_paths) != 1: raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}, expected 1.") @@ -296,4 +417,4 @@ def create(info, verbose=False): shutil.move(msi_paths[0], outpath) if not info.get("_debug"): - shutil.rmtree(tmp_dir) + payload.remove() diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat new file mode 100644 index 000000000..bb5ab024f --- /dev/null +++ b/constructor/briefcase/pre_uninstall.bat @@ -0,0 +1,53 @@ +@echo {{ 'on' if add_debug else 'off' }} +setlocal + +{% macro error_block(message, code) %} +echo [ERROR] {{ message }} +>> "%LOG%" echo [ERROR] {{ message }} +exit /b {{ code }} +{% endmacro %} + +rem Assign INSTDIR and normalize the path +set "INSTDIR=%~dp0.." +for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" + +set "BASE_PATH=%INSTDIR%\base" +set "PREFIX=%BASE_PATH%" +set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" +set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" + +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +set "LOG=%INSTDIR%\uninstall.log" + +{%- if add_debug %} +echo ==== pre_uninstall start ==== >> "%LOG%" +echo SCRIPT=%~f0 >> "%LOG%" +echo CWD=%CD% >> "%LOG%" +echo INSTDIR=%INSTDIR% >> "%LOG%" +echo BASE_PATH=%BASE_PATH% >> "%LOG%" +echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" +echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" +"%CONDA_EXE%" --version >> "%LOG%" 2>&1 +{%- endif %} + +rem Consistency checks +if not exist "%CONDA_EXE%" ( + {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} +) + +rem Recreate an empty payload tar. This file was deleted during installation but the +rem MSI installer expects it to exist. +type nul > "%PAYLOAD_TAR%" +if errorlevel 1 ( + {{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }} +) + +"%CONDA_EXE%" --log-file "%LOG%" constructor uninstall --prefix "%BASE_PATH%" +if errorlevel 1 ( exit /b %errorlevel% ) + +rem If we reached this far without any errors, remove any log files. +if exist "%INSTDIR%\install.log" del "%INSTDIR%\install.log" +if exist "%INSTDIR%\uninstall.log" del "%INSTDIR%\uninstall.log" + +exit /b 0 diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index ec8cc35b9..11710108d 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,14 +1,68 @@ -set "INSTDIR=%cd%" +@echo {{ 'on' if add_debug else 'off' }} +setlocal + +{% macro error_block(message, code) %} +echo [ERROR] {{ message }} +>> "%LOG%" echo [ERROR] {{ message }} +exit /b {{ code }} +{% endmacro %} + +rem Assign INSTDIR and normalize the path +set "INSTDIR=%~dp0.." +for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" + set "BASE_PATH=%INSTDIR%\base" set "PREFIX=%BASE_PATH%" -set "CONDA_EXE=%INSTDIR%\_conda.exe" - -"%INSTDIR%\_conda.exe" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs +set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" +set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" +set CONDA_EXTRA_SAFETY_CHECKS=no set CONDA_PROTECT_FROZEN_ENVS=0 -set "CONDA_ROOT_PREFIX=%BASE_PATH%" +set CONDA_REGISTER_ENVS={{ register_envs }} set CONDA_SAFETY_CHECKS=disabled -set CONDA_EXTRA_SAFETY_CHECKS=no +set "CONDA_ROOT_PREFIX=%BASE_PATH%" set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" -"%INSTDIR%\_conda.exe" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +set "LOG=%INSTDIR%\install.log" + +{%- if add_debug %} +echo ==== run_installation start ==== >> "%LOG%" +echo SCRIPT=%~f0 >> "%LOG%" +echo CWD=%CD% >> "%LOG%" +echo INSTDIR=%INSTDIR% >> "%LOG%" +echo BASE_PATH=%BASE_PATH% >> "%LOG%" +echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" +echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" +{%- endif %} + +rem Consistency checks +if not exist "%CONDA_EXE%" ( + {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} +) +if not exist "%PAYLOAD_TAR%" ( + {{ error_block('PAYLOAD_TAR not found: "%PAYLOAD_TAR%"', 11) }} +) + +echo Unpacking payload... +"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" +if errorlevel 1 ( exit /b %errorlevel% ) + +"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs +if errorlevel 1 ( exit /b %errorlevel% ) + +if not exist "%BASE_PATH%" ( + {{ error_block('"%BASE_PATH%" not found!', 12) }} +) + +"%CONDA_EXE%" --log-file "%LOG%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" +if errorlevel 1 ( exit /b %errorlevel% ) + +rem Delete the payload to save disk space. +rem A truncated placeholder of 0 bytes is recreated during uninstall +rem because MSI expects the file to be there to clean the registry. +del "%PAYLOAD_TAR%" +if errorlevel 1 ( exit /b %errorlevel% ) + +exit /b 0 diff --git a/examples/register_envs/construct.yaml b/examples/register_envs/construct.yaml index 31d72c9f6..760757a05 100644 --- a/examples/register_envs/construct.yaml +++ b/examples/register_envs/construct.yaml @@ -3,7 +3,7 @@ name: RegisterEnvs version: 1.0.0 -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ specs: diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index a858b6ae4..2f9950c99 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -1,6 +1,25 @@ +import sys +import tarfile +from pathlib import Path + import pytest -from constructor.briefcase import get_bundle_app_name, get_name_version +from constructor.briefcase import Payload, get_bundle_app_name, get_name_version +from constructor.conda_interface import cc_platform + +""" + Here 'mock_info' is simply a 'mock' of the regular 'info' object that is used to create installers. + It contains bare minimum in order to allow simple unit testing. +""" +mock_info = { + "name": "MockInfo", + "version": "1.0.0", + "_conda_exe": str(Path(sys.prefix) / "standalone_conda" / "conda.exe"), + "_download_dir": "", + "_dists": [], + "_platform": cc_platform, + "_urls": [], +} @pytest.mark.parametrize( @@ -132,3 +151,127 @@ def test_rdi_invalid_package(rdi): def test_name_no_alphanumeric(name): with pytest.raises(ValueError, match=f"Name '{name}' contains no alphanumeric characters"): get_bundle_app_name({}, name) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_prepare_payload(): + """Test preparing the payload.""" + info = mock_info.copy() + payload = Payload(info) + payload.prepare() + assert payload.root.is_dir() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_layout(): + """Test the layout of the payload and verify that archiving + parts of the payload works as expected. + """ + info = mock_info.copy() + payload = Payload(info) + prepared_payload = payload.prepare() + + root = prepared_payload[0] + external_dir = root / "external" + # The second item in prepared_payload is the 'external' directory + assert external_dir.is_dir() and external_dir == prepared_payload[1] + + base_dir = root / "external" / "base" + pkgs_dir = root / "external" / "base" / "pkgs" + archive_path = external_dir / payload.archive_name + # Since archiving removes the directory 'base_dir' and its contents + assert not base_dir.exists() + assert not pkgs_dir.exists() + assert archive_path.exists() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_archive(tmp_path: Path): + """Test that the payload archive function works as expected.""" + info = mock_info.copy() + payload = Payload(info) + + foo_dir = tmp_path / "foo" + foo_dir.mkdir() + + expected_text = "some test text" + hello_file = foo_dir / "hello.txt" + hello_file.write_text(expected_text, encoding="utf-8") + + archive_path = payload.make_archive(foo_dir, tmp_path) + + with tarfile.open(archive_path, mode="r:gz") as tar: + member = tar.getmember("foo/hello.txt") + f = tar.extractfile(member) + assert f is not None + assert f.read().decode("utf-8") == expected_text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_remove(): + """Test removing the payload.""" + info = mock_info.copy() + payload = Payload(info) + prepared_payload = payload.prepare() + + assert prepared_payload[0].is_dir() + payload.remove() + assert not prepared_payload[0].is_dir() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_pyproject_toml(): + """Test that the pyproject.toml file is created when the payload is prepared.""" + info = mock_info.copy() + payload = Payload(info) + prepared_payload = payload.prepare() + pyproject_toml = prepared_payload[0] / "pyproject.toml" + assert pyproject_toml.is_file() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_conda_exe(): + """Test that conda-standalone is prepared.""" + info = mock_info.copy() + payload = Payload(info) + prepared_payload = payload.prepare() + conda_exe = prepared_payload[1] / "_conda.exe" # The second item is the 'external' directory + assert conda_exe.is_file() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_payload_templates_are_rendered(debug_logging): + """Test that templates are rendered when the payload is prepared.""" + info = mock_info.copy() + payload = Payload(info) + payload.add_debug_logging = debug_logging + rendered_templates = payload.render_templates() + assert len(rendered_templates) == 2 # There should be at least two files + for f in rendered_templates: + assert f.is_file() + text = f.read_text(encoding="utf-8") + assert "{{" not in text and "}}" not in text + assert "{%" not in text and "%}" not in text + assert "{#" not in text and "#}" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_templates_debug_mode(debug_logging): + """Test that debug logging affects template generation.""" + info = mock_info.copy() + payload = Payload(info) + payload.add_debug_logging = debug_logging + rendered_templates = payload.render_templates() + assert len(rendered_templates) == 2 # There should be at least two files + + for f in rendered_templates: + assert f.is_file() + + with open(f) as open_file: + lines = open_file.readlines() + + # Check the first line. + expected = "@echo on\n" if debug_logging else "@echo off\n" + assert lines[0] == expected diff --git a/tests/test_examples.py b/tests/test_examples.py index 9b653d595..2da2b22f1 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,6 +1,5 @@ from __future__ import annotations -import ctypes import getpass import json import os @@ -11,6 +10,7 @@ import warnings import xml.etree.ElementTree as ET from contextlib import nullcontext +from dataclasses import dataclass from datetime import timedelta from functools import cache from pathlib import Path @@ -339,28 +339,99 @@ def _sentinel_file_checks(example_path, install_dir): ) -def is_admin() -> bool: - try: - return ctypes.windll.shell32.IsUserAnAdmin() - except Exception: - return False - - def calculate_msi_install_path(installer: Path) -> Path: """This is a temporary solution for now since we cannot choose the install location ourselves. Installers are named --Windows-x86_64.msi. """ dir_name = installer.name.replace("-Windows-x86_64.msi", "").replace("-", " ") - if is_admin(): - root_dir = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) - else: - local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) - root_dir = Path(local_dir) / "Programs" + local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) + root_dir = Path(local_dir) / "Programs" + root_dir.mkdir(parents=True, exist_ok=True) - assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors + assert root_dir.is_dir() # Consistency check to avoid strange unexpected errors return Path(root_dir) / dir_name +def handle_exception_and_error_out( + failure: InstallationFailure | UninstallationFailure, original_exception: BaseException +) -> None: + """Print failure context (including logs) and re-raise with exception chaining.""" + print(failure.read_text()) + raise failure from original_exception + + +def _read_briefcase_log_tail(path: Path, last_digits: int) -> str: + """Helper function to read logs from installers created with briefcase. + The encoding can vary between the different logs. + """ + if not path or not path.exists(): + return f"(log not found: {path})" + + # Try UTF-16 first (MSI logs), fallback to UTF-8 + try: + text = path.read_text(encoding="utf-16", errors="replace") + except UnicodeError: + text = path.read_text(encoding="utf-8", errors="replace") + + return text[last_digits:] + + +@dataclass +class InstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + post_install_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.post_install_log: + parts.append( + f"\n=== MSI LOG POST INSTALL: {self.post_install_log} ===\n" + + _read_briefcase_log_tail(self.post_install_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + +@dataclass +class UninstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + pre_uninstall_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.pre_uninstall_log: + parts.append( + f"\n=== MSI LOG PRE UNINSTALL: {self.pre_uninstall_log} ===\n" + + _read_briefcase_log_tail(self.pre_uninstall_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + def _run_installer_msi( installer: Path, install_dir: Path, @@ -389,22 +460,28 @@ def _run_installer_msi( "/qn", ] - log_path = Path(os.environ.get("TEMP")) / (install_dir.name + ".log") + # Prepare logging + post_install_log = install_dir / "install.log" + # Logging from MSI engine is handled separately + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") + if log_path.exists(): + os.remove(log_path) cmd.extend(["/L*V", str(log_path)]) + + # Run installer and handle errors/logs if necessary try: process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) except subprocess.CalledProcessError as e: - if log_path.exists(): - # When running on the CI system, it tries to decode a UTF-16 log file as UTF-8, - # therefore we need to specify encoding before printing. - print(f"\n=== MSI LOG {log_path} START ===") - print( - log_path.read_text(encoding="utf-16", errors="replace")[-15000:] - ) # last 15k chars - print(f"\n=== MSI LOG {log_path} END ===") - raise e - if check: - print("A check for MSI Installers not yet implemented") + handle_exception_and_error_out( + InstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + post_install_log=post_install_log, + ), + original_exception=e, + ) + return process @@ -420,13 +497,32 @@ def _run_uninstaller_msi( str(installer), "/qn", ] - process = _execute(cmd, timeout=timeout, check=check) + + # Prepare logging + pre_uninstall_log = install_dir / "uninstall.log" + # Logging from MSI engine is handled separately + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") + if log_path.exists(): + os.remove(log_path) + cmd.extend(["/L*V", str(log_path)]) + + try: + process = _execute(cmd, installer_input=None, timeout=timeout, check=check) + except subprocess.CalledProcessError as e: + handle_exception_and_error_out( + UninstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + post_install_log=pre_uninstall_log, + ), + original_exception=e, + ) + if check: # TODO: # Check log and if there are remaining files, similar to the exe installers pass - # This is temporary until uninstallation works fine - shutil.rmtree(str(install_dir), ignore_errors=True) return process @@ -1056,8 +1152,6 @@ def test_register_envs(tmp_path, request): """Verify that 'register_envs: False' results in the environment not being registered.""" input_path = _example_path("register_envs") for installer, install_dir in create_installer(input_path, tmp_path): - if installer.suffix == ".msi": - raise NotImplementedError("Test for 'register_envs' not yet implemented for MSI") _run_installer(input_path, installer, install_dir, request=request) environments_txt = Path("~/.conda/environments.txt").expanduser().read_text() assert str(install_dir) not in environments_txt @@ -1619,6 +1713,8 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): input_path = _example_path("register_envs") # The specific example we use here is not important options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + continue _run_installer( input_path, installer, @@ -1629,21 +1725,20 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): options=options, ) - # Use the installer file name for the registry search - installer_file_name_parts = Path(installer).name.split("-") - name = installer_file_name_parts[0] - version = installer_file_name_parts[1] - partial_name = f"{name} {version}" + # Use the installer file name for the registry search + installer_file_name_parts = Path(installer).name.split("-") + name = installer_file_name_parts[0] + version = installer_file_name_parts[1] + partial_name = f"{name} {version}" - is_in_installed_apps_menu = _is_program_installed(partial_name) - _run_uninstaller_exe(install_dir) - - # If no_registry=0 we expect is_in_installed_apps_menu=True - # If no_registry=1 we expect is_in_installed_apps_menu=False - assert is_in_installed_apps_menu == (no_registry == 0), ( - f"Unable to find program '{partial_name}' in the 'Installed apps' menu" - ) + is_in_installed_apps_menu = _is_program_installed(partial_name) + _run_uninstaller_exe(install_dir) + # If no_registry=0 we expect is_in_installed_apps_menu=True + # If no_registry=1 we expect is_in_installed_apps_menu=False + assert is_in_installed_apps_menu == (no_registry == 0), ( + f"Unable to find program '{partial_name}' in the 'Installed apps' menu" + ) @pytest.mark.xfail( condition=( From 62ec758ebc6579a273ebdaecc5011e99b550b47d Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:36:20 -0400 Subject: [PATCH 06/17] MSI: Implement install options (#1179) * Add implementation for installer and uninstall options * Disable protected base test for MSI * Ensure yaml always is generated * Ensure we write to 64-bit registry * Make test more precise * pre-commit fix * Misc updates * Fix review comments * Remove manual delete of .nonadmin * Removed one more redundant test * Update comments and printed output * add removal of configuration files * Change to .nonadmin check instead of REG_HIVE * Update test * add another consistency check * Update constructor/briefcase/pre_uninstall.bat Co-authored-by: Marco Esters --------- Co-authored-by: Marco Esters --- constructor/briefcase.py | 64 +++- constructor/briefcase/pre_uninstall.bat | 127 +++++++- constructor/briefcase/run_installation.bat | 100 +++++- examples/protected_base/construct.yaml | 2 +- tests/test_briefcase.py | 354 ++++++++++++++++++++- tests/test_examples.py | 11 +- 6 files changed, 627 insertions(+), 31 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 769ee707c..186ca549e 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -22,7 +22,7 @@ from . import preconda from .jinja import render_template -from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist +from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist, shortcuts_flags BRIEFCASE_DIR = Path(__file__).parent / "briefcase" EXTERNAL_PACKAGE_PATH = "external" @@ -123,6 +123,33 @@ def is_bat_file(file_path: Path) -> bool: return file_path.is_file() and file_path.suffix.lower() == ".bat" +def create_uninstall_options_list(info: dict) -> list[dict]: + """Returns a list of dicts with data formatted for the uninstallation options page. + Options are currently only shown when uninstall_with_conda_exe is True.""" + if not bool(info.get("uninstall_with_conda_exe")): + return [] + return [ + { + "name": "remove_user_data", + "title": "Remove user data", + "description": "Remove user data associated with this installation.", + "default": False, + }, + { + "name": "remove_caches", + "title": "Remove caches", + "description": "Clear the package cache upon completion.", + "default": False, + }, + { + "name": "remove_config_files", + "title": "Remove configuration files", + "description": "Remove .condarc and other configuration files.", + "default": False, + }, + ] + + def create_install_options_list(info: dict) -> list[dict]: """Returns a list of dicts with data formatted for the installation options page.""" options = [] @@ -222,6 +249,15 @@ def create_install_options_list(info: dict) -> list[dict]: return options +def _get_python_info(info: dict) -> tuple[bool, list[str]]: + """Return (has_python, pyver_components) by inspecting _dists.""" + for dist in info.get("_dists", []): + name, version, _ = filename_dist(dist).rsplit("-", 2) + if name == "python": + return True, version.split(".") + return False, [] + + @dataclass class Payload: """ @@ -327,11 +363,34 @@ def render_templates(self) -> list[Path]: Path(BRIEFCASE_DIR / "pre_uninstall.bat"): Path(self.root / "pre_uninstall.bat"), } - context: dict[str, str] = { + has_python, pyver_components = _get_python_info(self.info) + + context: dict = { "archive_name": self.archive_name, "conda_exe_name": self.conda_exe_name, "add_debug": self.add_debug_logging, "register_envs": str(self.info.get("register_envs", True)).lower(), + # --- has_python / pyver_components --- + "has_python": has_python, + "pyver_components": pyver_components, + # --- OPTION_INITIALIZE_CONDA --- + "initialize_conda": self.info.get("initialize_conda", "classic"), + # --- OPTION_CLEAR_PACKAGE_CACHE / OPTION_ENABLE_SHORTCUTS --- + "no_rcs_arg": self.info.get("_ignore_condarcs_arg", ""), + # --- OPTION_ENABLE_SHORTCUTS --- + # shortcuts_flags returns the appropriate --shortcuts-only=... flags, + # an empty string (all shortcuts), or --no-shortcuts (none). + # In the .bat template this is used in the "shortcuts enabled" branch, + # so passing an empty string here is correct when all shortcuts are wanted. + "shortcuts": shortcuts_flags(self.info), + # --- uninstall_with_conda_exe --- + "uninstall_with_conda_exe": bool(self.info.get("uninstall_with_conda_exe")), + # --- has_conda --- + "has_conda": self.info.get("_has_conda", False), + # --- setup_envs --- + # Placeholder for extra_envs support. Currently only contains base env. + # Will be expanded when extra_envs is implemented for MSI installers. + "setup_envs": [{"name": "base", "prefix": "%BASE_PATH%"}], } # Render the templates now using jinja and the defined context @@ -361,6 +420,7 @@ def write_pyproject_toml(self, root: Path, external: Path) -> None: "use_full_install_path": False, "install_launcher": False, "install_option": create_install_options_list(self.info), + "uninstall_option": create_uninstall_options_list(self.info), "post_install_script": str(root / "run_installation.bat"), "pre_uninstall_script": str(root / "pre_uninstall.bat"), } diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index bb5ab024f..9aa68e420 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -1,5 +1,9 @@ @echo {{ 'on' if add_debug else 'off' }} -setlocal +rem enabledelayedexpansion is required for !VAR! expansion inside for /f loops +rem and for building UNINST_ARGS dynamically. Note that this is NOT inherited +rem from run_pre_uninstall.bat even though it sets enabledelayedexpansion there, +rem because setlocal in this script creates a new scope. +setlocal enabledelayedexpansion {% macro error_block(message, code) %} echo [ERROR] {{ message }} @@ -7,6 +11,11 @@ echo [ERROR] {{ message }} exit /b {{ code }} {% endmacro %} +{%- macro tee(message) -%} +echo {{ message }} +>> "%LOG%" echo {{ message }} +{%- endmacro %} + rem Assign INSTDIR and normalize the path set "INSTDIR=%~dp0.." for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" @@ -15,19 +24,33 @@ set "BASE_PATH=%INSTDIR%\base" set "PREFIX=%BASE_PATH%" set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" +set "CONDA_ROOT_PREFIX=%BASE_PATH%" rem Get the name of the install directory for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" set "LOG=%INSTDIR%\uninstall.log" +rem Determine install mode from .nonadmin marker file written at install time +if exist "%BASE_PATH%\.nonadmin" ( + set "REG_HIVE=HKCU" +) else ( + set "REG_HIVE=HKLM" +) + {%- if add_debug %} -echo ==== pre_uninstall start ==== >> "%LOG%" -echo SCRIPT=%~f0 >> "%LOG%" -echo CWD=%CD% >> "%LOG%" -echo INSTDIR=%INSTDIR% >> "%LOG%" -echo BASE_PATH=%BASE_PATH% >> "%LOG%" -echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" -echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" +>> "%LOG%" echo ==== pre_uninstall start ==== +>> "%LOG%" echo SCRIPT=%~f0 +>> "%LOG%" echo CWD=%CD% +>> "%LOG%" echo INSTDIR=%INSTDIR% +>> "%LOG%" echo BASE_PATH=%BASE_PATH% +>> "%LOG%" echo CONDA_EXE=%CONDA_EXE% +>> "%LOG%" echo PAYLOAD_TAR=%PAYLOAD_TAR% +>> "%LOG%" echo CONDA_ROOT_PREFIX=%CONDA_ROOT_PREFIX% +>> "%LOG%" echo REG_HIVE=%REG_HIVE% +>> "%LOG%" echo ALLUSERS=%ALLUSERS% +>> "%LOG%" echo OPTION_REMOVE_USER_DATA=%OPTION_REMOVE_USER_DATA% +>> "%LOG%" echo OPTION_REMOVE_CACHES=%OPTION_REMOVE_CACHES% +>> "%LOG%" echo OPTION_REMOVE_CONFIG_FILES=%OPTION_REMOVE_CONFIG_FILES% "%CONDA_EXE%" --version >> "%LOG%" 2>&1 {%- endif %} @@ -35,6 +58,11 @@ rem Consistency checks if not exist "%CONDA_EXE%" ( {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} ) +if "%ALLUSERS%"=="0" ( + if not exist "%BASE_PATH%\.nonadmin" ( + {{ error_block('Insufficient permissions. Please re-run the uninstallation as administrator.', 11) }} + ) +) rem Recreate an empty payload tar. This file was deleted during installation but the rem MSI installer expects it to exist. @@ -43,8 +71,89 @@ if errorlevel 1 ( {{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }} ) -"%CONDA_EXE%" --log-file "%LOG%" constructor uninstall --prefix "%BASE_PATH%" +rem Remove PATH entries only for user-scoped installs (mirrors NSIS .nonadmin check) +{%- set pathflag = "--condabin" if initialize_conda == "condabin" else "--classic" %} +if exist "%BASE_PATH%\.nonadmin" ( + {{ tee("Removing from PATH...") }} + "%CONDA_EXE%" constructor windows path --remove=user --prefix "%INSTDIR%" {{ pathflag }} --log-file "%LOG%" + if errorlevel 1 ( exit /b %errorlevel% ) +) + +{%- if has_python %} +rem Remove Python registry entries only if InstallPath matches BASE_PATH. +{{ tee("Checking Python registry entries...") }} +call :remove_python_registry "%REG_HIVE%" "%BASE_PATH%" +goto :after_remove_python_registry + +:remove_python_registry +set "REG_HIVE_ARG=%~1" +set "BASE_PATH_ARG=%~2" +rem REG64 forces the 64-bit registry view since the MSI engine runs as a 32-bit process. +set "REG64=/reg:64" +rem Enumerate all subkeys under PythonCore (e.g. 3.11, 3.12, ...) +for /f "tokens=*" %%K in ('reg query "%REG_HIVE_ARG%\Software\Python\PythonCore" %REG64% 2^>nul') do ( + rem Read the InstallPath default value for each subkey + for /f "tokens=2*" %%A in ('reg query "%%K\InstallPath" /ve %REG64% 2^>nul') do ( + rem Only delete if InstallPath matches our installation directory + if /i "%%B"=="%BASE_PATH_ARG%" ( + echo Removing Python registry key: %%K + >> "%LOG%" echo Removing Python registry key: %%K + reg delete "%%K" /f %REG64% >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + ) + ) +) +exit /b 0 + +:after_remove_python_registry +{%- endif %} + +{%- if uninstall_with_conda_exe %} +rem Run constructor uninstall, conditionally passing optional flags +set "UNINST_ARGS=" +if "%OPTION_REMOVE_USER_DATA%"=="1" ( + set "UNINST_ARGS=!UNINST_ARGS! --remove-user-data" +) +if "%OPTION_REMOVE_CACHES%"=="1" ( + set "UNINST_ARGS=!UNINST_ARGS! --remove-caches" +) +if "%OPTION_REMOVE_CONFIG_FILES%"=="1" ( + rem User installs (.nonadmin marker exists) only remove user config files. + rem Admin installs remove both user and system config files. + if exist "%BASE_PATH%\.nonadmin" ( + set "UNINST_ARGS=!UNINST_ARGS! --remove-config-files=user" + ) else ( + set "UNINST_ARGS=!UNINST_ARGS! --remove-config-files=all" + ) +) +{{ tee("Running constructor uninstall...") }} +"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"!UNINST_ARGS! --log-file "%LOG%" if errorlevel 1 ( exit /b %errorlevel% ) +{%- else %} +rem Remove menus for each environment. +{%- for env in setup_envs %} +{{ tee("Removing menus for " + env.name + "...") }} +"%CONDA_EXE%" constructor --prefix "{{ env.prefix }}" --rm-menus --log-file "%LOG%" +if errorlevel 1 ( exit /b %errorlevel% ) +{%- endfor %} + +{%- if has_conda %} +rem Reverse conda shell initialization +if "%REG_HIVE%"=="HKCU" ( + set "CONDA_INIT_SCOPE=user" +) else ( + set "CONDA_INIT_SCOPE=system" +) +{{ tee("Reversing conda shell initialization...") }} +"%BASE_PATH%\condabin\conda.bat" init cmd.exe --reverse --!CONDA_INIT_SCOPE! --log-file "%LOG%" +if errorlevel 1 ( exit /b %errorlevel% ) +{%- endif %} + +rem Remove conda environments. INSTDIR itself is cleaned up by the MSI engine. +{{ tee("Removing environments...") }} +rmdir /s /q "%BASE_PATH%" +if errorlevel 1 ( exit /b %errorlevel% ) +{%- endif %} rem If we reached this far without any errors, remove any log files. if exist "%INSTDIR%\install.log" del "%INSTDIR%\install.log" diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 11710108d..201582e05 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,5 +1,8 @@ @echo {{ 'on' if add_debug else 'off' }} -setlocal +rem enabledelayedexpansion is required for !REG_HIVE! expansion when +rem registering Python, and because setlocal creates a new scope that +rem does not inherit enabledelayedexpansion from the calling script. +setlocal enabledelayedexpansion {% macro error_block(message, code) %} echo [ERROR] {{ message }} @@ -7,6 +10,11 @@ echo [ERROR] {{ message }} exit /b {{ code }} {% endmacro %} +{%- macro tee(message) -%} +echo {{ message }} +>> "%LOG%" echo {{ message }} +{%- endmacro %} + rem Assign INSTDIR and normalize the path set "INSTDIR=%~dp0.." for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" @@ -28,13 +36,20 @@ for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" set "LOG=%INSTDIR%\install.log" {%- if add_debug %} -echo ==== run_installation start ==== >> "%LOG%" -echo SCRIPT=%~f0 >> "%LOG%" -echo CWD=%CD% >> "%LOG%" -echo INSTDIR=%INSTDIR% >> "%LOG%" -echo BASE_PATH=%BASE_PATH% >> "%LOG%" -echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" -echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" +>> "%LOG%" echo ==== run_installation start ==== +>> "%LOG%" echo SCRIPT=%~f0 +>> "%LOG%" echo CWD=%CD% +>> "%LOG%" echo INSTDIR=%INSTDIR% +>> "%LOG%" echo BASE_PATH=%BASE_PATH% +>> "%LOG%" echo CONDA_EXE=%CONDA_EXE% +>> "%LOG%" echo PAYLOAD_TAR=%PAYLOAD_TAR% +>> "%LOG%" echo ALLUSERS=%ALLUSERS% +>> "%LOG%" echo OPTION_ENABLE_SHORTCUTS=%OPTION_ENABLE_SHORTCUTS% +>> "%LOG%" echo OPTION_INITIALIZE_CONDA=%OPTION_INITIALIZE_CONDA% +{%- if has_python %} +>> "%LOG%" echo OPTION_REGISTER_PYTHON=%OPTION_REGISTER_PYTHON% +{%- endif %} +>> "%LOG%" echo OPTION_CLEAR_PACKAGE_CACHE=%OPTION_CLEAR_PACKAGE_CACHE% {%- endif %} rem Consistency checks @@ -45,18 +60,34 @@ if not exist "%PAYLOAD_TAR%" ( {{ error_block('PAYLOAD_TAR not found: "%PAYLOAD_TAR%"', 11) }} ) -echo Unpacking payload... -"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" +{{ tee("Unpacking payload...") }} +"%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin --log-file "%LOG%" < "%PAYLOAD_TAR%" if errorlevel 1 ( exit /b %errorlevel% ) -"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs +"%CONDA_EXE%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs --log-file "%LOG%" if errorlevel 1 ( exit /b %errorlevel% ) if not exist "%BASE_PATH%" ( {{ error_block('"%BASE_PATH%" not found!', 12) }} ) -"%CONDA_EXE%" --log-file "%LOG%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" +rem TODO: loop over extra_envs when extra_envs support is implemented for MSI. + +rem Create .nonadmin marker file for user-scoped installs inside BASE_PATH. +rem This is used by the uninstaller (and menuinst) to determine the install mode. +if "%ALLUSERS%"=="0" ( + echo. > "%BASE_PATH%\.nonadmin" + if errorlevel 1 ( exit /b %errorlevel% ) +) + +rem Install packages, conditionally creating shortcuts +if "%OPTION_ENABLE_SHORTCUTS%"=="1" ( + {{ tee("Installing packages with shortcuts...") }} + "%CONDA_EXE%" install --offline -yp "%BASE_PATH%" --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" {{ shortcuts }} {{ no_rcs_arg }} --log-file "%LOG%" +) else ( + {{ tee("Installing packages...") }} + "%CONDA_EXE%" install --offline -yp "%BASE_PATH%" --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" --no-shortcuts {{ no_rcs_arg }} --log-file "%LOG%" +) if errorlevel 1 ( exit /b %errorlevel% ) rem Delete the payload to save disk space. @@ -65,4 +96,49 @@ rem because MSI expects the file to be there to clean the registry. del "%PAYLOAD_TAR%" if errorlevel 1 ( exit /b %errorlevel% ) +rem Add to PATH / run conda init if the option was selected +{%- set pathflag = "--condabin" if initialize_conda == "condabin" else "--classic" %} +if "%OPTION_INITIALIZE_CONDA%"=="1" ( + {{ tee("Adding to PATH...") }} + "%CONDA_EXE%" constructor windows path --prepend=user --prefix "%INSTDIR%" {{ pathflag }} --log-file "%LOG%" + if errorlevel 1 ( exit /b %errorlevel% ) +) + +{%- if has_python %} +rem Register as system Python if the option was selected +if "%OPTION_REGISTER_PYTHON%"=="1" ( + {{ tee("Registering as system Python...") }} + if "%ALLUSERS%"=="1" ( + set "REG_HIVE=HKLM" + ) else ( + set "REG_HIVE=HKCU" + ) + rem PY_REG is the base registry path for this Python version. + rem /v sets a named value, /ve sets the default (unnamed) value, /d sets the data, + rem /f forces overwrite without prompting. + rem REG64 forces the 64-bit registry view since the MSI engine runs as a 32-bit process. + set "REG64=/reg:64" + set "PY_REG=!REG_HIVE!\Software\Python\PythonCore\{{ pyver_components[:2] | join(".") }}" + reg add "!PY_REG!\Help\Main Python Documentation" /v "Main Python Documentation" /d "%BASE_PATH%\Doc\python{{ pyver_components | join("") }}.chm" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\InstallPath" /ve /d "%BASE_PATH%" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\InstallPath" /v "ExecutablePath" /d "%BASE_PATH%\python.exe" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\InstallPath" /v "InstallGroup" /d "Python {{ pyver_components[:2] | join(".") }}" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\Modules" /ve /d "" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\PythonPath" /ve /d "%BASE_PATH%\Lib;%BASE_PATH%\DLLs" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) +) +{%- endif %} + +rem Clear the package cache if the option was selected +if "%OPTION_CLEAR_PACKAGE_CACHE%"=="1" ( + {{ tee("Clearing package cache...") }} + "%CONDA_EXE%" clean --all --force-pkgs-dirs --yes {{ no_rcs_arg }} --log-file "%LOG%" + if errorlevel 1 ( exit /b %errorlevel% ) +) + exit /b 0 diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml index e8f18a630..b0c2c84b4 100644 --- a/examples/protected_base/construct.yaml +++ b/examples/protected_base/construct.yaml @@ -3,7 +3,7 @@ name: ProtectedBaseEnv version: 1.0.0 -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all channels: - defaults diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 2f9950c99..9685cb5e9 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -4,7 +4,13 @@ import pytest -from constructor.briefcase import Payload, get_bundle_app_name, get_name_version +from constructor.briefcase import ( + Payload, + _get_python_info, + create_uninstall_options_list, + get_bundle_app_name, + get_name_version, +) from constructor.conda_interface import cc_platform """ @@ -19,6 +25,7 @@ "_dists": [], "_platform": cc_platform, "_urls": [], + "uninstall_with_conda_exe": False, } @@ -153,6 +160,27 @@ def test_name_no_alphanumeric(name): get_bundle_app_name({}, name) +@pytest.mark.parametrize( + "dists, has_python_expected, pyver_expected", + [ + # Python present + (["python-3.11.5-0.tar.bz2"], True, ["3", "11", "5"]), + (["python-3.9.7-0.tar.bz2"], True, ["3", "9", "7"]), + # Python present alongside other dists + (["numpy-1.24.0-py311_0.tar.bz2", "python-3.11.5-0.tar.bz2"], True, ["3", "11", "5"]), + # No python dist + (["numpy-1.24.0-py311_0.tar.bz2"], False, []), + # Empty dists + ([], False, []), + ], +) +def test_get_python_info(dists, has_python_expected, pyver_expected): + info = {"_dists": dists} + has_python, pyver_components = _get_python_info(info) + assert has_python == has_python_expected + assert pyver_components == pyver_expected + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_prepare_payload(): """Test preparing the payload.""" @@ -264,14 +292,330 @@ def test_templates_debug_mode(debug_logging): payload = Payload(info) payload.add_debug_logging = debug_logging rendered_templates = payload.render_templates() - assert len(rendered_templates) == 2 # There should be at least two files + assert len(rendered_templates) == 2 for f in rendered_templates: assert f.is_file() - with open(f) as open_file: lines = open_file.readlines() - - # Check the first line. expected = "@echo on\n" if debug_logging else "@echo off\n" assert lines[0] == expected + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_no_python(): + """Test when no Python dist is present, has_python is False and the + OPTION_REGISTER_PYTHON block should not appear in the rendered output.""" + info = mock_info.copy() + info["_dists"] = [] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + assert "OPTION_REGISTER_PYTHON" not in text + assert "PythonCore" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_with_python(): + """Test when a Python dist is present, has_python is True and the + OPTION_REGISTER_PYTHON block should appear in the rendered output.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + assert "OPTION_REGISTER_PYTHON" in text + assert "PythonCore" in text + assert "3.11" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize( + "initialize_conda, expected_flag", + [ + ("condabin", "--condabin"), + ("classic", "--classic"), + ], +) +def test_render_templates_add_to_path_flags(initialize_conda, expected_flag): + """Verify that the correct path flag is rendered based on initialize_conda mode.""" + info = mock_info.copy() + info["initialize_conda"] = initialize_conda + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + assert expected_flag in text + assert "constructor windows path" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("no_rcs_arg", ["--no-rc", ""]) +def test_render_templates_no_rcs_arg(no_rcs_arg): + """Verify that no_rcs_arg is rendered into the template correctly.""" + info = mock_info.copy() + info["_ignore_condarcs_arg"] = no_rcs_arg + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + if no_rcs_arg: + assert no_rcs_arg in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_registry_uses_base_path(): + """Test that Python registry entries use BASE_PATH (INSTDIR\\base) and not + INSTDIR directly, since in the MSI layout is different from EXE.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert "%BASE_PATH%\\python.exe" in text + assert "%BASE_PATH%\\Lib;%BASE_PATH%\\DLLs" in text + assert "%BASE_PATH%\\Doc\\" in text + + assert "%INSTDIR%\\python.exe" not in text + assert "%INSTDIR%\\Lib;%INSTDIR%\\DLLs" not in text + assert "%INSTDIR%\\Doc\\" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_nonadmin_created_for_user_install(): + """Verify that run_installation.bat creates a .nonadmin marker file + when ALLUSERS is 0. This file is used by pre_uninstall.bat to determine + the install mode via REG_HIVE.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert ".nonadmin" in text + assert 'ALLUSERS%"=="0"' in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_option_variable_names(): + """Verify that the option variable names in the rendered template match + exactly what run_post_installation.bat sets via positional arguments.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert "OPTION_REGISTER_PYTHON" in text + assert "OPTION_INITIALIZE_CONDA" in text + assert "OPTION_CLEAR_PACKAGE_CACHE" in text + assert "OPTION_ENABLE_SHORTCUTS" in text + + assert "OPTION_REGISTER_SYSTEM_PYTHON" not in text + assert "OPTION_ADD_TO_PATH" not in text + assert "OPTION_CLEAR_PKG_CACHE" not in text + assert "OPTION_CREATE_SHORTCUTS" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_uninstall_option_variable_names(): + """Verify that the uninstall option variable names in the rendered template match + exactly what run_pre_uninstall.bat sets via positional arguments.""" + info = mock_info.copy() + info["uninstall_with_conda_exe"] = True + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "OPTION_REMOVE_USER_DATA" in text + assert "OPTION_REMOVE_CACHES" in text + assert "OPTION_REMOVE_CONFIG_FILES" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_pre_uninstall_delayed_expansion(): + """Verify that pre_uninstall.bat enables delayed expansion explicitly. + This is required because: + 1. setlocal in pre_uninstall.bat creates a new scope, so enabledelayedexpansion + from run_pre_uninstall.bat is NOT inherited. + 2. !VAR! syntax is needed inside for /f loops and for building UNINST_ARGS + dynamically. + """ + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "setlocal enabledelayedexpansion" in text.lower() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_no_python_no_registry(): + """Verify that when no Python dist is present, no registry operations + appear in pre_uninstall.bat.""" + info = mock_info.copy() + info["_dists"] = [] + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "PythonCore" not in text + assert "reg delete" not in text + assert "reg query" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize( + "initialize_conda, expected_flag", + [ + ("condabin", "--condabin"), + ("classic", "--classic"), + ], +) +def test_render_templates_path_removal_flags(initialize_conda, expected_flag): + """Verify the correct path flag is rendered in pre_uninstall.bat + based on initialize_conda mode.""" + info = mock_info.copy() + info["initialize_conda"] = initialize_conda + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert expected_flag in text + assert "constructor windows path" in text + assert "--remove=user" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_path_removal_gated_on_nonadmin(): + """Verify that PATH removal in pre_uninstall.bat is gated on + the .nonadmin marker file check, mirroring the NSIS behaviour. PATH was + only ever added for user-scoped installs so should only be removed for + those.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + path_removal_pos = text.find("--remove=user") + assert path_removal_pos != -1, '"--remove=user" not found in pre_uninstall.bat' + + # Search backwards from --remove=user to find the nearest preceding .nonadmin + # guard, confirming it is the direct condition for PATH removal and not some + # other .nonadmin check elsewhere in the file. + preceding_text = text[:path_removal_pos] + nonadmin_check_pos = preceding_text.rfind('if exist "%BASE_PATH%\\.nonadmin"') + assert nonadmin_check_pos != -1, ( + 'No .nonadmin guard found immediately before "--remove=user" in pre_uninstall.bat' + ) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_registry_uses_reg64(): + """Verify that Python registry writes in run_installation.bat use /reg:64 + to force the 64-bit registry view, since the MSI engine runs as a 32-bit + process and would otherwise redirect writes to WOW6432Node.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + # REG64 variable must be defined and used + assert "REG64" in text + assert "/reg:64" in text + # Must not use the literal flag directly in reg add calls (should use variable) + assert "reg add" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_pre_uninstall_registry_uses_reg64(): + """Verify that Python registry queries and deletes in pre_uninstall.bat use + /reg:64 to force the 64-bit registry view, since the MSI engine runs as a + 32-bit process and registry entries were written to the 64-bit view at + install time.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + # REG64 variable must be defined and used in the subroutine + assert "REG64" in text + assert "/reg:64" in text + + # reg query and reg delete must both appear after the subroutine label + subroutine_pos = text.find(":remove_python_registry") + reg_query_pos = text.find("reg query", subroutine_pos) + reg_delete_pos = text.find("reg delete", subroutine_pos) + assert subroutine_pos != -1 + assert reg_query_pos != -1 + assert reg_delete_pos != -1 + + # /reg:64 must appear in both the reg query and reg delete lines + reg_query_line = next( + line for line in text.splitlines() if "reg query" in line and "PythonCore" in line + ) + reg_delete_line = next(line for line in text.splitlines() if "reg delete" in line) + assert "/reg:64" in reg_query_line or "%REG64%" in reg_query_line + assert "/reg:64" in reg_delete_line or "%REG64%" in reg_delete_line + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_pre_uninstall_conda_root_prefix(): + """Verify that CONDA_ROOT_PREFIX is set in pre_uninstall.bat. + This is required for the --rm-menus command to work correctly.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "CONDA_ROOT_PREFIX=%BASE_PATH%" in text + + +def test_create_uninstall_options_list_with_conda_exe(): + """Test that create_uninstall_options_list returns all expected options + when uninstall_with_conda_exe is True.""" + info = {"uninstall_with_conda_exe": True} + options = create_uninstall_options_list(info) + + option_names = [opt["name"] for opt in options] + assert "remove_user_data" in option_names + assert "remove_caches" in option_names + assert "remove_config_files" in option_names + + +def test_create_uninstall_options_list_without_conda_exe(): + """Test that create_uninstall_options_list returns empty list + when uninstall_with_conda_exe is False.""" + info = {"uninstall_with_conda_exe": False} + options = create_uninstall_options_list(info) + assert options == [] diff --git a/tests/test_examples.py b/tests/test_examples.py index 2da2b22f1..dd8d0e8da 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1740,6 +1740,7 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): f"Unable to find program '{partial_name}' in the 'Installed apps' menu" ) + @pytest.mark.xfail( condition=( CONDA_EXE == StandaloneExe.CONDA @@ -1767,10 +1768,16 @@ def test_frozen_environment(tmp_path, request, has_conflict): with open(input_path / "construct.yaml") as f: config = yaml.load(f) + # Since the above yaml.load does not rely on jinja rendering, + # set installer_type based on platform instead of using Jinja in the YAML. + # This is needed until MSI installers support protected base environments. + config["installer_type"] = "exe" if os.name == "nt" else "all" + if has_conflict: config.setdefault("extra_files", []).append({"frozen.json": "conda-meta/frozen"}) - with open(input_path / "construct.yaml", "w") as f: - yaml.dump(config, f) + + with open(input_path / "construct.yaml", "w") as f: + yaml.dump(config, f) with context as c: for installer, install_dir in create_installer(input_path, tmp_path): From c2d5872e13d0a2d1e7218c83edb2e01eb8559244 Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:17:15 -0400 Subject: [PATCH 07/17] MSI: Add support for virtual_specs, write_condarc and condarc (#1182) * Add virtual specs * fix: pre-commit * fix: update tests * Add condarc support for MSI installers * add tests also t test_utils.py * enable more examples for MSI * update calculation of MSI path * try use existing parse function * add temporary skip for MSI * pre-commit fix * MSI: Add .condarc during preconda * update .condarc handling for all installers --- constructor/briefcase.py | 12 ++++- constructor/briefcase/run_installation.bat | 13 +++++ constructor/header.sh | 4 -- constructor/nsis/main.nsi.tmpl | 7 +-- constructor/osx/run_installation.sh | 4 -- constructor/osxpkg.py | 2 - constructor/preconda.py | 17 +++++++ constructor/shar.py | 5 +- constructor/utils.py | 24 ++++----- constructor/winexe.py | 3 +- examples/miniforge-mamba2/construct.yaml | 2 +- examples/miniforge/construct.yaml | 2 +- tests/test_briefcase.py | 32 ++++++++++++ tests/test_examples.py | 17 ++++--- tests/test_preconda.py | 58 ++++++++++++++++++++++ tests/test_utils.py | 44 +++++++++++++++- 16 files changed, 205 insertions(+), 41 deletions(-) create mode 100644 tests/test_preconda.py diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 186ca549e..95665e110 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -22,7 +22,12 @@ from . import preconda from .jinja import render_template -from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist, shortcuts_flags +from .utils import ( + DEFAULT_REVERSE_DOMAIN_ID, + copy_conda_exe, + filename_dist, + shortcuts_flags, +) BRIEFCASE_DIR = Path(__file__).parent / "briefcase" EXTERNAL_PACKAGE_PATH = "external" @@ -391,6 +396,11 @@ def render_templates(self) -> list[Path]: # Placeholder for extra_envs support. Currently only contains base env. # Will be expanded when extra_envs is implemented for MSI installers. "setup_envs": [{"name": "base", "prefix": "%BASE_PATH%"}], + # --- virtual_specs --- + # virtual_specs: quoted for command-line use + # virtual_specs_debug: unquoted for display + "virtual_specs": " ".join([f'"{spec}"' for spec in self.info.get("virtual_specs", ())]), + "virtual_specs_debug": " ".join(self.info.get("virtual_specs", ())), } # Render the templates now using jinja and the defined context diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 201582e05..80295dc50 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -71,6 +71,19 @@ if not exist "%BASE_PATH%" ( {{ error_block('"%BASE_PATH%" not found!', 12) }} ) +{%- if virtual_specs %} +rem Check virtual specs compatibility before proceeding with installation. +rem We need to specify CONDA_SOLVER=classic to work around this bug: +rem https://github.com/conda/conda-libmamba-solver/issues/480 +set "CONDA_SOLVER=classic" +{{ tee("Checking virtual specs compatibility: " ~ virtual_specs_debug) }} +"%CONDA_EXE%" create --dry-run --prefix "%BASE_PATH%\envs\_virtual_specs_checks" --offline {{ virtual_specs }} {{ no_rcs_arg }} --log-file "%LOG%" +if errorlevel 1 ( + {{ error_block("Failed to check virtual specs: " ~ virtual_specs_debug, 13) }} +) +set "CONDA_SOLVER=" +{%- endif %} + rem TODO: loop over extra_envs when extra_envs support is implemented for MSI. rem Create .nonadmin marker file for user-scoped installs inside BASE_PATH. diff --git a/constructor/header.sh b/constructor/header.sh index 82778608a..c50cacb67 100644 --- a/constructor/header.sh +++ b/constructor/header.sh @@ -671,10 +671,6 @@ for env_pkgs in "${PREFIX}"/pkgs/envs/*/; do done {%- endif %} -{%- for condarc in write_condarc %} -{{ condarc }} -{%- endfor %} - POSTCONDA="$PREFIX/postconda.tar.bz2" CONDA_QUIET="$BATCH" \ "$CONDA_EXEC" constructor --prefix "$PREFIX" --extract-tarball < "$POSTCONDA" || exit 1 diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index 10bf6c9f4..c00aead2d 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -1648,9 +1648,10 @@ Section "Install" {%- endif %} {%- endfor %} -{%- for condarc in WRITE_CONDARC %} - {{ condarc }} -{%- endfor %} +{%- if condarc_file %} + SetOutPath "$INSTDIR" + File {{ condarc_file }} +{%- endif %} AddSize {{ SIZE }} diff --git a/constructor/osx/run_installation.sh b/constructor/osx/run_installation.sh index a1fdb0b1c..bf066f7c8 100644 --- a/constructor/osx/run_installation.sh +++ b/constructor/osx/run_installation.sh @@ -115,10 +115,6 @@ done # Cleanup! find "$PREFIX/pkgs" -type d -empty -exec rmdir {} \; 2>/dev/null || : -{%- for condarc in write_condarc %} -{{ condarc }} -{%- endfor %} - # This is not needed for the default install to ~, but if the user changes the # install location, the permissions will default to root unless this is done. chown -R "${USER}" "$PREFIX" diff --git a/constructor/osxpkg.py b/constructor/osxpkg.py index f43dc6aa7..e2ed9bdef 100644 --- a/constructor/osxpkg.py +++ b/constructor/osxpkg.py @@ -22,7 +22,6 @@ from .signing import CodeSign from .utils import ( DEFAULT_REVERSE_DOMAIN_ID, - add_condarc, approx_size_kb, copy_conda_exe, explained_check_call, @@ -395,7 +394,6 @@ def move_script(src, dst, info, ensure_shebang=False, user_script_type=None): variables["installer_version"] = info["version"] variables["installer_platform"] = info["_platform"] variables["final_channels"] = get_final_channels(info) - variables["write_condarc"] = list(add_condarc(info)) variables["path_exists_error_text"] = path_exists_error_text variables["progress_notifications"] = info.get("progress_notifications", False) variables["pre_or_post"] = user_script_type or "__PRE_OR_POST__" diff --git a/constructor/preconda.py b/constructor/preconda.py index f928213e8..2edd4c2ce 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -35,6 +35,7 @@ from .utils import ( ensure_transmuted_ext, filename_dist, + get_condarc_content, get_final_channels, get_final_url, shortcuts_flags, @@ -203,6 +204,9 @@ def write_files(info: dict, workspace: str): # base environment frozen marker files write_frozen(info.get("freeze_base"), join(workspace, "conda-meta")) + # base environment .condarc + write_condarc(info, workspace) + for fn in files: os.chmod(join(workspace, fn), 0o664) @@ -332,6 +336,19 @@ def write_shortcuts_txt(info: dict, dst_dir: str, env_config: dict): f.write(contents) +def write_condarc(info: dict, dst_dir: str): + """Write .condarc file to the workspace if configured. + + The file will be included in the payload tarball and extracted + to the correct location during installation. + """ + condarc = get_condarc_content(info) + if not condarc: + return + with open(join(dst_dir, ".condarc"), "w") as f: + f.write(condarc) + + def copy_extra_files( extra_files: list[os.PathLike | Mapping], workdir: os.PathLike ) -> list[os.PathLike]: diff --git a/constructor/shar.py b/constructor/shar.py index a0b631267..e857aac7f 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -26,7 +26,6 @@ from .preconda import files as preconda_files from .preconda import write_files as preconda_write_files from .utils import ( - add_condarc, approx_size_kb, copy_conda_exe, filename_dist, @@ -96,7 +95,6 @@ def get_header(conda_exec, tarball, info): variables["second_payload_size"] = getsize(tarball) variables["conda_exe_payloads"] = info.get("_conda_exe_payloads", {}) variables["conda_exe_payloads_size"] = info.get("_conda_exe_payloads_size", 0) - variables["write_condarc"] = list(add_condarc(info)) variables["final_channels"] = get_final_channels(info) variables["conclusion_text"] = info.get("conclusion_text", "installation finished.") variables["pycache"] = "__pycache__" @@ -176,6 +174,9 @@ def create(info, verbose=False): if os.path.exists(join(tmp_dir, "conda-meta", "frozen")): post_t.add(join(tmp_dir, "conda-meta", "frozen"), "conda-meta/frozen") + if os.path.exists(join(tmp_dir, ".condarc")): + post_t.add(join(tmp_dir, ".condarc"), ".condarc") + for env_name in info.get("_extra_envs_info", {}): pre_t.addfile(tarinfo=tarfile.TarInfo(f"envs/{env_name}/conda-meta/history")) post_t.add( diff --git a/constructor/utils.py b/constructor/utils.py index 7766264bb..a64b6e9d7 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -132,7 +132,14 @@ def if_repl(match): return if_pat.sub(if_repl, data) -def add_condarc(info): +def get_condarc_content(info) -> str | None: + """ + Get the condarc content string from the info dict. + + Returns the condarc content as a YAML string, or None if no condarc should be written. + Handles both the new 'condarc' key (direct content) and the legacy 'write_condarc' + approach (building from channel settings). + """ from .conda_interface import MatchSpec # prevent circular import condarc = info.get("condarc") @@ -146,7 +153,7 @@ def add_condarc(info): if not ( write_condarc and (default_channels or channels or channel_alias or mirrored_channels) ): - return + return None condarc = {} if default_channels: condarc["default_channels"] = default_channels @@ -164,18 +171,7 @@ def add_condarc(info): ) if isinstance(condarc, dict): condarc = yaml_to_string(condarc) - yield "# ----- add condarc" - if info["_platform"].startswith("win"): - yield "Var /Global CONDARC" - yield 'FileOpen $CONDARC "$INSTDIR\\.condarc" w' - for line in condarc.splitlines(): - yield 'FileWrite $CONDARC "%s$\\r$\\n"' % line - yield "FileClose $CONDARC" - else: - yield 'cat <"$PREFIX/.condarc"' - for line in condarc.splitlines(): - yield line - yield "EOF" + return condarc def ensure_transmuted_ext(info, url): diff --git a/constructor/winexe.py b/constructor/winexe.py index 6a279bf03..2efa7b0ec 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -25,7 +25,6 @@ from .preconda import write_files as preconda_write_files from .signing import AzureSignTool, WindowsSignTool from .utils import ( - add_condarc, approx_size_kb, copy_conda_exe, filename_dist, @@ -189,6 +188,7 @@ def make_nsi( "pre_uninstall": "@pre_uninstall.bat", "index_cache": "@" + join("pkgs", "cache"), "repodata_record": "@" + join("pkgs", "repodata_record.json"), + "condarc_file": "@.condarc" if os.path.exists(join(dir_path, ".condarc")) else "", } conclusion_text = info.get("conclusion_text", "") @@ -276,7 +276,6 @@ def make_nsi( variables["DISTS"] = [win_str_esc(join(download_dir, dist)) for dist in dists] variables["SIGNTOOL_COMMAND"] = signing_tool.get_signing_command() if signing_tool else "" variables["SETUP_ENVS"] = setup_envs_commands(info, dir_path) - variables["WRITE_CONDARC"] = list(add_condarc(info)) variables["SIZE"] = approx_pkgs_size_kb variables["UNINSTALL_NAME"] = info.get("uninstall_name", default_uninstall_name) variables["EXTRA_FILES"] = get_extra_files(extra_files, dir_path) diff --git a/examples/miniforge-mamba2/construct.yaml b/examples/miniforge-mamba2/construct.yaml index becb523f4..98feefec3 100644 --- a/examples/miniforge-mamba2/construct.yaml +++ b/examples/miniforge-mamba2/construct.yaml @@ -21,7 +21,7 @@ specs: - miniforge_console_shortcut 1.* # [win] # Added for extra testing -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all post_install: test_install.sh # [unix] post_install: test_install.bat # [win] initialize_by_default: false diff --git a/examples/miniforge/construct.yaml b/examples/miniforge/construct.yaml index 52b961da9..eb894cc91 100644 --- a/examples/miniforge/construct.yaml +++ b/examples/miniforge/construct.yaml @@ -21,7 +21,7 @@ specs: - miniforge_console_shortcut 1.* # [win] # Added for extra testing -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all post_install: test_install.sh # [unix] post_install: test_install.bat # [win] initialize_by_default: false diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 9685cb5e9..ac001d408 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -619,3 +619,35 @@ def test_create_uninstall_options_list_without_conda_exe(): info = {"uninstall_with_conda_exe": False} options = create_uninstall_options_list(info) assert options == [] + + +def test_render_templates_with_virtual_specs(): + """Test that virtual_specs check block is rendered when specs are provided.""" + info = mock_info.copy() + info["virtual_specs"] = ["__win>=10", "__cuda>=11"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert "Checking virtual specs compatibility" in text + assert "__win>=10 __cuda>=11" in text + assert '"__win>=10" "__cuda>=11"' in text + assert "CONDA_SOLVER=classic" in text + assert "--dry-run" in text + assert "_virtual_specs_checks" in text + + +def test_render_templates_without_virtual_specs(): + """Test that virtual_specs check block is not rendered when specs are empty.""" + info = mock_info.copy() + info["virtual_specs"] = [] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert "Checking virtual specs compatibility" not in text + assert "_virtual_specs_checks" not in text diff --git a/tests/test_examples.py b/tests/test_examples.py index dd8d0e8da..3f5afb97e 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -23,6 +23,7 @@ from conda.models.version import VersionOrder as Version from ruamel.yaml import YAML +from constructor.construct import parse as parse_construct from constructor.utils import ( StandaloneExe, check_version, @@ -339,11 +340,14 @@ def _sentinel_file_checks(example_path, install_dir): ) -def calculate_msi_install_path(installer: Path) -> Path: - """This is a temporary solution for now since we cannot choose the install location ourselves. - Installers are named --Windows-x86_64.msi. +def calculate_msi_install_path(config_path: Path) -> Path: + """Calculate the MSI install path from the construct.yaml config. + + MSI installers use ' ' as the install directory name, + matching the formal_name set in briefcase.py. """ - dir_name = installer.name.replace("-Windows-x86_64.msi", "").replace("-", " ") + config = parse_construct(str(config_path), platform="win-64") + dir_name = f"{config['name']} {config['version']}" local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) root_dir = Path(local_dir) / "Programs" root_dir.mkdir(parents=True, exist_ok=True) @@ -647,7 +651,7 @@ def _sort_by_extension(path): input_dir / config_filename ) elif installer.suffix == ".msi": - install_dir = calculate_msi_install_path(installer) + install_dir = calculate_msi_install_path(input_dir / config_filename) else: install_dir = ( workspace / f"{install_dir_prefix}-{installer.stem}-{installer.suffix[1:]}" @@ -836,7 +840,6 @@ def test_example_miniforge(tmp_path, request, example): elif installer.suffix == ".msi": # TODO: Start menus _run_uninstaller_msi(installer, install_dir) - raise NotImplementedError("Test needs to be implemented") def test_example_noconda(tmp_path, request): @@ -1441,6 +1444,8 @@ def _get_dacl_information(filepath: Path) -> dict: input_path = _example_path("miniforge") for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + continue # TODO: Test currently not applicable for MSI installers _run_installer( input_path, installer, diff --git a/tests/test_preconda.py b/tests/test_preconda.py new file mode 100644 index 000000000..8d41a3305 --- /dev/null +++ b/tests/test_preconda.py @@ -0,0 +1,58 @@ +from constructor.preconda import write_condarc + + +def test_write_condarc_with_condarc_dict(tmp_path): + """Test that write_condarc creates .condarc file when condarc is a dict.""" + info = {"condarc": {"channels": ["conda-forge"], "ssl_verify": False}} + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert condarc_file.exists() + content = condarc_file.read_text() + assert "channels:" in content + assert "conda-forge" in content + assert "ssl_verify:" in content + + +def test_write_condarc_with_condarc_string(tmp_path): + """Test that write_condarc creates .condarc file when condarc is a string.""" + info = {"condarc": "channels:\n - my-channel\nssl_verify: false\n"} + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert condarc_file.exists() + content = condarc_file.read_text() + assert "channels:" in content + assert "my-channel" in content + assert "ssl_verify:" in content + + +def test_write_condarc_with_write_condarc_flag(tmp_path): + """Test legacy write_condarc=True approach.""" + info = { + "write_condarc": True, + "channels": ["defaults"], + } + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert condarc_file.exists() + assert "defaults" in condarc_file.read_text() + + +def test_write_condarc_no_content_no_file(tmp_path): + """Test that write_condarc does nothing when no condarc is configured.""" + info = {} + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert not condarc_file.exists() + + +def test_write_condarc_write_condarc_without_channels(tmp_path): + """Test that write_condarc does nothing when write_condarc=True but no channels.""" + info = {"write_condarc": True} + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert not condarc_file.exists() diff --git a/tests/test_utils.py b/tests/test_utils.py index 56f88e4c7..2965a580e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,10 @@ from os import sep -from constructor.utils import make_VIProductVersion, normalize_path +from constructor.utils import ( + get_condarc_content, + make_VIProductVersion, + normalize_path, +) def test_make_VIProductVersion(): @@ -20,3 +24,41 @@ def test_normalize_path(): path = "test///test/test".replace("/", sep) assert normalize_path(path) == "test/test/test".replace("/", sep) + + +def test_get_condarc_content_with_write_condarc(): + """Test that get_condarc_content returns YAML content when write_condarc is True.""" + info = { + "write_condarc": True, + "channels": ["conda-forge", "defaults"], + } + content = get_condarc_content(info) + assert content is not None + assert "channels:" in content + assert "conda-forge" in content + assert "defaults" in content + + +def test_get_condarc_content_with_condarc_dict(): + """Test that get_condarc_content returns YAML content when condarc is a dict.""" + info = { + "condarc": { + "channels": ["my-channel"], + "ssl_verify": False, + }, + } + content = get_condarc_content(info) + assert content is not None + assert "channels:" in content + assert "my-channel" in content + assert "ssl_verify:" in content + + +def test_get_condarc_content_returns_none(): + """Test that get_condarc_content returns None when no condarc settings are provided.""" + info = {} + assert get_condarc_content(info) is None + + # write_condarc without channels should also return None + info = {"write_condarc": True} + assert get_condarc_content(info) is None From 298ad6bc75a6d94f409e354373789361d5fd8717 Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:47:23 -0400 Subject: [PATCH 08/17] MSI: Add support for script_env_variables (#1184) * Add support for script_env_variables * add to uninstall * update docs * Update construct.schema.json due to earlier changes * review fixes * Fix issue with virtual specs --- CONSTRUCT.md | 3 +- constructor/_schema.py | 3 +- constructor/briefcase.py | 28 +++++++ constructor/briefcase/pre_uninstall.bat | 7 ++ constructor/briefcase/run_installation.bat | 28 +++++-- constructor/data/construct.schema.json | 2 +- constructor/utils.py | 30 ++++++++ docs/source/construct-yaml.md | 3 +- examples/virtual_specs_failed/construct.yaml | 2 +- examples/virtual_specs_ok/construct.yaml | 2 +- tests/test_briefcase.py | 81 +++++++++++++++++++- tests/test_examples.py | 10 ++- tests/test_utils.py | 45 +++++++++++ 13 files changed, 229 insertions(+), 15 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index e767d1da5..0cb245960 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -345,7 +345,8 @@ pre_install/post_install script(s). If you need to include single quotes in your value, you can escape them by replacing each single quote with `'''`. -On Windows, single quotes and double quotes are not supported. +For Windows EXE installers, single quotes and double quotes are not supported. +For Windows MSI installers, single quotes are supported but double quotes are not. Note that the # (hash) character cannot be used as it denotes yaml comments for all platforms. diff --git a/constructor/_schema.py b/constructor/_schema.py index a8b99f030..543deb108 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -515,7 +515,8 @@ class ConstructorConfiguration(BaseModel): in your value, you can escape them by replacing each single quote with `'''`. - On Windows, single quotes and double quotes are not supported. + For Windows EXE installers, single quotes and double quotes are not supported. + For Windows MSI installers, single quotes are supported but double quotes are not. Note that the # (hash) character cannot be used as it denotes yaml comments for all platforms. diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 95665e110..8ac085960 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -24,6 +24,8 @@ from .jinja import render_template from .utils import ( DEFAULT_REVERSE_DOMAIN_ID, + bat_echo_esc, + bat_env_var_esc, copy_conda_exe, filename_dist, shortcuts_flags, @@ -128,6 +130,27 @@ def is_bat_file(file_path: Path) -> bool: return file_path.is_file() and file_path.suffix.lower() == ".bat" +def _get_script_env_variables(info: dict) -> dict[str, str]: + """Validate and escape script_env_variables for batch file use. + + Raises ValueError if any key or value contains double quotes. + Returns escaped key-value pairs. + """ + raw_vars = info.get("script_env_variables", {}) + escaped_vars = {} + + for key, val in raw_vars.items(): + if '"' in key or '"' in val: + raise ValueError( + f"script_env_variables entry '{key}' contains double quotes, " + "which are not supported in MSI installers. " + "Use single quotes instead." + ) + escaped_vars[key] = bat_env_var_esc(val) + + return escaped_vars + + def create_uninstall_options_list(info: dict) -> list[dict]: """Returns a list of dicts with data formatted for the uninstallation options page. Options are currently only shown when uninstall_with_conda_exe is True.""" @@ -399,8 +422,13 @@ def render_templates(self) -> list[Path]: # --- virtual_specs --- # virtual_specs: quoted for command-line use # virtual_specs_debug: unquoted for display + # virtual_specs_debug_bat: escaped for batch echo commands "virtual_specs": " ".join([f'"{spec}"' for spec in self.info.get("virtual_specs", ())]), "virtual_specs_debug": " ".join(self.info.get("virtual_specs", ())), + "virtual_specs_debug_bat": bat_echo_esc(" ".join(self.info.get("virtual_specs", ()))), + # --- script_env_variables --- + # User-defined environment variables for pre/post install scripts + "script_env_variables": _get_script_env_variables(self.info), } # Render the templates now using jinja and the defined context diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 9aa68e420..0624be5fa 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -30,6 +30,13 @@ rem Get the name of the install directory for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" set "LOG=%INSTDIR%\uninstall.log" +{%- if script_env_variables %} +rem User-defined environment variables for pre/post install scripts +{%- for key, val in script_env_variables.items() %} +set "{{ key }}={{ val }}" +{%- endfor %} +{%- endif %} + rem Determine install mode from .nonadmin marker file written at install time if exist "%BASE_PATH%\.nonadmin" ( set "REG_HIVE=HKCU" diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 80295dc50..d6c2eb31c 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -29,12 +29,18 @@ set CONDA_PROTECT_FROZEN_ENVS=0 set CONDA_REGISTER_ENVS={{ register_envs }} set CONDA_SAFETY_CHECKS=disabled set "CONDA_ROOT_PREFIX=%BASE_PATH%" -set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" rem Get the name of the install directory for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" set "LOG=%INSTDIR%\install.log" +{%- if script_env_variables %} +rem User-defined environment variables for pre/post install scripts +{%- for key, val in script_env_variables.items() %} +set "{{ key }}={{ val }}" +{%- endfor %} +{%- endif %} + {%- if add_debug %} >> "%LOG%" echo ==== run_installation start ==== >> "%LOG%" echo SCRIPT=%~f0 @@ -64,26 +70,34 @@ if not exist "%PAYLOAD_TAR%" ( "%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin --log-file "%LOG%" < "%PAYLOAD_TAR%" if errorlevel 1 ( exit /b %errorlevel% ) -"%CONDA_EXE%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs --log-file "%LOG%" -if errorlevel 1 ( exit /b %errorlevel% ) - if not exist "%BASE_PATH%" ( {{ error_block('"%BASE_PATH%" not found!', 12) }} ) {%- if virtual_specs %} -rem Check virtual specs compatibility before proceeding with installation. +rem Check virtual specs compatibility before extracting conda packages. +rem This matches the order used by NSIS installers. rem We need to specify CONDA_SOLVER=classic to work around this bug: rem https://github.com/conda/conda-libmamba-solver/issues/480 +rem Use a temp pkgs dir to avoid incomplete cache files in base\pkgs (like shell installer does). set "CONDA_SOLVER=classic" -{{ tee("Checking virtual specs compatibility: " ~ virtual_specs_debug) }} +set "CONDA_PKGS_DIRS=%TEMP%\constructor_vspecs" +echo Checking virtual specs compatibility: {{ virtual_specs_debug_bat }} +>> "%LOG%" echo Checking virtual specs compatibility: {{ virtual_specs_debug_bat }} "%CONDA_EXE%" create --dry-run --prefix "%BASE_PATH%\envs\_virtual_specs_checks" --offline {{ virtual_specs }} {{ no_rcs_arg }} --log-file "%LOG%" if errorlevel 1 ( - {{ error_block("Failed to check virtual specs: " ~ virtual_specs_debug, 13) }} + echo [ERROR] Failed to check virtual specs: {{ virtual_specs_debug_bat }} + >> "%LOG%" echo [ERROR] Failed to check virtual specs: {{ virtual_specs_debug_bat }} + exit /b 13 ) set "CONDA_SOLVER=" {%- endif %} +set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" + +"%CONDA_EXE%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs --log-file "%LOG%" +if errorlevel 1 ( exit /b %errorlevel% ) + rem TODO: loop over extra_envs when extra_envs support is implemented for MSI. rem Create .nonadmin marker file for user-scoped installs inside BASE_PATH. diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index a01aee1cf..38770df84 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -1153,7 +1153,7 @@ "type": "string" }, "default": {}, - "description": "Dictionary of additional environment variables to be made available to the pre_install and post_install scripts, in the form of VAR:VALUE pairs. These environment variables are in addition to those in the `post_install` section above and take precedence in the case of name collisions.\nOn Unix the variable values are automatically single quoted, allowing you to supply strings with spaces, without needing to worry about escaping. As a consequence, string interpolation is disabled: if you need string interpolation, you can apply it in the pre_install/post_install script(s). If you need to include single quotes in your value, you can escape them by replacing each single quote with `'''`.\nOn Windows, single quotes and double quotes are not supported.\nNote that the # (hash) character cannot be used as it denotes yaml comments for all platforms.", + "description": "Dictionary of additional environment variables to be made available to the pre_install and post_install scripts, in the form of VAR:VALUE pairs. These environment variables are in addition to those in the `post_install` section above and take precedence in the case of name collisions.\nOn Unix the variable values are automatically single quoted, allowing you to supply strings with spaces, without needing to worry about escaping. As a consequence, string interpolation is disabled: if you need string interpolation, you can apply it in the pre_install/post_install script(s). If you need to include single quotes in your value, you can escape them by replacing each single quote with `'''`.\nFor Windows EXE installers, single quotes and double quotes are not supported. For Windows MSI installers, single quotes are supported but double quotes are not.\nNote that the # (hash) character cannot be used as it denotes yaml comments for all platforms.", "propertyNames": { "minLength": 1 }, diff --git a/constructor/utils.py b/constructor/utils.py index a64b6e9d7..0122c463c 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -396,6 +396,36 @@ def win_str_esc(s, newlines=True): return '"%s"' % s +def bat_env_var_esc(s: str) -> str: + """Escape a string for use in a Windows batch file SET command. + + For use with: set "VAR=" + + Single quotes are allowed (they're literal in batch). + Double quotes are NOT supported - validate before calling this function. + """ + # ^ and % need special handling: ^ must be first (it's the escape char), + # and % uses %% not ^% + s = s.replace("^", "^^") + s = s.replace("%", "%%") + for c in ("!", "&", "<", ">", "|"): + s = s.replace(c, f"^{c}") + return s + + +def bat_echo_esc(s: str) -> str: + """Escape a string for use in a Windows batch file ECHO command. + + Escapes special shell characters that would otherwise be interpreted + as redirections or command separators. + """ + # ^ must be escaped first since it's the escape character + s = s.replace("^", "^^") + for c in ("&", "<", ">", "|"): + s = s.replace(c, f"^{c}") + return s + + def check_required_env_vars(env_vars): missing_vars = {var for var in env_vars if var not in environ} if missing_vars: diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index e767d1da5..0cb245960 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -345,7 +345,8 @@ pre_install/post_install script(s). If you need to include single quotes in your value, you can escape them by replacing each single quote with `'''`. -On Windows, single quotes and double quotes are not supported. +For Windows EXE installers, single quotes and double quotes are not supported. +For Windows MSI installers, single quotes are supported but double quotes are not. Note that the # (hash) character cannot be used as it denotes yaml comments for all platforms. diff --git a/examples/virtual_specs_failed/construct.yaml b/examples/virtual_specs_failed/construct.yaml index 12d886b9f..f3b554872 100644 --- a/examples/virtual_specs_failed/construct.yaml +++ b/examples/virtual_specs_failed/construct.yaml @@ -22,4 +22,4 @@ initialize_by_default: false register_python: false check_path_spaces: false check_path_length: false -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all diff --git a/examples/virtual_specs_ok/construct.yaml b/examples/virtual_specs_ok/construct.yaml index 15655811b..41635eefc 100644 --- a/examples/virtual_specs_ok/construct.yaml +++ b/examples/virtual_specs_ok/construct.yaml @@ -22,4 +22,4 @@ initialize_by_default: false register_python: false check_path_spaces: false check_path_length: false -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index ac001d408..bdb8afd61 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -7,6 +7,7 @@ from constructor.briefcase import ( Payload, _get_python_info, + _get_script_env_variables, create_uninstall_options_list, get_bundle_app_name, get_name_version, @@ -632,7 +633,6 @@ def test_render_templates_with_virtual_specs(): text = run_installation.read_text(encoding="utf-8") assert "Checking virtual specs compatibility" in text - assert "__win>=10 __cuda>=11" in text assert '"__win>=10" "__cuda>=11"' in text assert "CONDA_SOLVER=classic" in text assert "--dry-run" in text @@ -651,3 +651,82 @@ def test_render_templates_without_virtual_specs(): assert "Checking virtual specs compatibility" not in text assert "_virtual_specs_checks" not in text + + +@pytest.mark.parametrize( + "info", + [ + {}, + {"script_env_variables": {}}, + ], +) +def test_get_script_env_variables_empty(info): + """Test that empty dict is returned when no script_env_variables are provided.""" + assert _get_script_env_variables(info) == {} + + +@pytest.mark.parametrize( + "script_env_variables, expected", + [ + # Basic variables pass through + ({"FOO": "bar"}, {"FOO": "bar"}), + ({"FOO": "bar", "BAZ": "qux"}, {"FOO": "bar", "BAZ": "qux"}), + # Single quotes are allowed + ({"MSG": "foo 'bar'"}, {"MSG": "foo 'bar'"}), + # Special batch characters are escaped + ({"PERCENT": "50%"}, {"PERCENT": "50%%"}), + ({"AMP": "a & b"}, {"AMP": "a ^& b"}), + ({"PIPE": "a | b"}, {"PIPE": "a ^| b"}), + ({"REDIR": "a < b > c"}, {"REDIR": "a ^< b ^> c"}), + ({"CARET": "a^b"}, {"CARET": "a^^b"}), + ({"BANG": "foo!"}, {"BANG": "foo^!"}), + ], +) +def test_get_script_env_variables(script_env_variables, expected): + """Test script_env_variables validation and escaping.""" + info = {"script_env_variables": script_env_variables} + assert _get_script_env_variables(info) == expected + + +@pytest.mark.parametrize( + "script_env_variables", + [ + {"BAD": 'foo "bar"'}, # double quotes in value + {'BAD"KEY': "value"}, # double quotes in key + ], +) +def test_get_script_env_variables_double_quotes_rejected(script_env_variables): + """Test that double quotes raise ValueError.""" + info = {"script_env_variables": script_env_variables} + with pytest.raises(ValueError, match="double quotes"): + _get_script_env_variables(info) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +@pytest.mark.parametrize("template_name", ["run_installation.bat", "pre_uninstall.bat"]) +def test_render_templates_with_script_env_variables(template_name): + """Test that script_env_variables are rendered in both install and uninstall templates.""" + info = mock_info.copy() + info["script_env_variables"] = {"MY_VAR": "my_value", "OTHER": "50%"} + payload = Payload(info) + rendered_templates = payload.render_templates() + + template = next(f for f in rendered_templates if f.name == template_name) + text = template.read_text(encoding="utf-8") + + assert 'set "MY_VAR=my_value"' in text + assert 'set "OTHER=50%%"' in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +@pytest.mark.parametrize("template_name", ["run_installation.bat", "pre_uninstall.bat"]) +def test_render_templates_without_script_env_variables(template_name): + """Test that no script_env_variables block is rendered when not provided.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + template = next(f for f in rendered_templates if f.name == template_name) + text = template.read_text(encoding="utf-8") + + assert "User-defined environment variables" not in text diff --git a/tests/test_examples.py b/tests/test_examples.py index 3f5afb97e..af7c6e34a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1248,7 +1248,15 @@ def test_virtual_specs_failed(tmp_path, request): _check_installer_log(install_dir) continue elif installer.suffix == ".msi": - raise NotImplementedError("Test for 'virtual_specs' not yet implemented for MSI") + # MSI writes errors to install.log in the install directory + msi_post_install_log = install_dir / "install.log" + if msi_post_install_log.exists(): + log_content = msi_post_install_log.read_text(encoding="utf-8", errors="replace") + assert "Failed to check virtual specs" in log_content + else: + # If log doesn't exist, installation failed before post-install script ran + assert process.returncode != 0 + continue elif installer.suffix == ".pkg": if not ON_CI: continue diff --git a/tests/test_utils.py b/tests/test_utils.py index 2965a580e..c646589b3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,8 @@ from os import sep from constructor.utils import ( + bat_echo_esc, + bat_env_var_esc, get_condarc_content, make_VIProductVersion, normalize_path, @@ -26,6 +28,49 @@ def test_normalize_path(): assert normalize_path(path) == "test/test/test".replace("/", sep) +def test_bat_env_var_esc(): + """Test escaping strings for Windows batch file SET commands.""" + # Plain strings pass through unchanged + assert bat_env_var_esc("foo") == "foo" + assert bat_env_var_esc("foo bar") == "foo bar" + + # Single quotes are allowed (literal in batch) + assert bat_env_var_esc("foo 'bar'") == "foo 'bar'" + + # Special characters are escaped + assert bat_env_var_esc("50%") == "50%%" + assert bat_env_var_esc("a & b") == "a ^& b" + assert bat_env_var_esc("a | b") == "a ^| b" + assert bat_env_var_esc("a < b > c") == "a ^< b ^> c" + assert bat_env_var_esc("a^b") == "a^^b" + assert bat_env_var_esc("foo!") == "foo^!" + + # Combined special characters + assert bat_env_var_esc("100% & done!") == "100%% ^& done^!" + + +def test_bat_echo_esc(): + """Test escaping strings for Windows batch file ECHO commands.""" + # Plain strings pass through unchanged + assert bat_echo_esc("foo") == "foo" + assert bat_echo_esc("foo bar") == "foo bar" + + # Special characters are escaped + assert bat_echo_esc("a & b") == "a ^& b" + assert bat_echo_esc("a | b") == "a ^| b" + assert bat_echo_esc("a < b > c") == "a ^< b ^> c" + assert bat_echo_esc("a^b") == "a^^b" + + # Percent and exclamation are NOT escaped (unlike bat_env_var_esc) + assert bat_echo_esc("50%") == "50%" + assert bat_echo_esc("foo!") == "foo!" + + # Virtual specs examples + assert bat_echo_esc("__win<0") == "__win^<0" + assert bat_echo_esc("__cuda>=11") == "__cuda^>=11" + assert bat_echo_esc("__win<0 __cuda>=11") == "__win^<0 __cuda^>=11" + + def test_get_condarc_content_with_write_condarc(): """Test that get_condarc_content returns YAML content when write_condarc is True.""" info = { From 8eacfd1fa659ee254978ac5b845e3a0e9005e3be Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:52:15 -0400 Subject: [PATCH 09/17] MSI: Add support for extra_envs (#1186) * add support for extra_envs, work in progress * Fix rebase issues * Add more examples to MSI testing * fix error: TypeError: unhashable type * Changed default value of shortcut option for consistency with NSIS * Add debug output * Always uninstall with conda-standalone * Add debug output * Improve debug output * Remove debug output, skip test * Remove more debug statements * Use pytest.xfail * Review fix * Add suggestion from review Co-authored-by: Marco Esters * Bump min conda-standalone --------- Co-authored-by: Marco Esters --- constructor/briefcase.py | 69 ++++++++-- constructor/briefcase/pre_uninstall.bat | 26 ---- constructor/briefcase/run_installation.bat | 27 ++-- constructor/main.py | 13 ++ examples/outputs/construct.yaml | 2 +- examples/shortcuts/construct.yaml | 2 +- recipe/meta.yaml | 2 +- tests/test_briefcase.py | 149 ++++++++++++++++++--- tests/test_examples.py | 4 + 9 files changed, 226 insertions(+), 68 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 8ac085960..a530c054e 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -28,6 +28,7 @@ bat_env_var_esc, copy_conda_exe, filename_dist, + get_final_channels, shortcuts_flags, ) @@ -151,11 +152,50 @@ def _get_script_env_variables(info: dict) -> dict[str, str]: return escaped_vars +def _setup_envs_commands(info: dict) -> list[dict]: + """Build environment setup data for base and extra_envs. + + Returns a list of dicts, each containing the data needed to install + one environment. Used by the run_installation.bat template. + """ + environments = [] + + # Base environment + environments.append( + { + "name": "base", + "prefix": "%BASE_PATH%", + "lockfile": r"%BASE_PATH%\conda-meta\initial-state.explicit.txt", + "channels": ",".join(get_final_channels(info)), + "shortcuts": shortcuts_flags(info), + } + ) + + # Extra environments + for env_name in info.get("_extra_envs_info", {}): + env_config = info["extra_envs"][env_name] + # Needed for shortcuts_flags function + if "_conda_exe_type" not in env_config: + env_config["_conda_exe_type"] = info.get("_conda_exe_type") + channel_info = { + "channels": env_config.get("channels", info.get("channels", ())), + "channels_remap": env_config.get("channels_remap", info.get("channels_remap", ())), + } + environments.append( + { + "name": env_name, + "prefix": rf"%BASE_PATH%\envs\{env_name}", + "lockfile": rf"%BASE_PATH%\envs\{env_name}\conda-meta\initial-state.explicit.txt", + "channels": ",".join(get_final_channels(channel_info)), + "shortcuts": shortcuts_flags(env_config), + } + ) + + return environments + + def create_uninstall_options_list(info: dict) -> list[dict]: - """Returns a list of dicts with data formatted for the uninstallation options page. - Options are currently only shown when uninstall_with_conda_exe is True.""" - if not bool(info.get("uninstall_with_conda_exe")): - return [] + """Returns a list of dicts with data formatted for the uninstallation options page.""" return [ { "name": "remove_user_data", @@ -243,7 +283,7 @@ def create_install_options_list(info: dict) -> list[dict]: "name": "enable_shortcuts", "title": "Create shortcuts", "description": "Create shortcuts (supported packages only).", - "default": False, + "default": True, } ) @@ -411,14 +451,8 @@ def render_templates(self) -> list[Path]: # In the .bat template this is used in the "shortcuts enabled" branch, # so passing an empty string here is correct when all shortcuts are wanted. "shortcuts": shortcuts_flags(self.info), - # --- uninstall_with_conda_exe --- - "uninstall_with_conda_exe": bool(self.info.get("uninstall_with_conda_exe")), - # --- has_conda --- - "has_conda": self.info.get("_has_conda", False), # --- setup_envs --- - # Placeholder for extra_envs support. Currently only contains base env. - # Will be expanded when extra_envs is implemented for MSI installers. - "setup_envs": [{"name": "base", "prefix": "%BASE_PATH%"}], + "setup_envs": _setup_envs_commands(self.info), # --- virtual_specs --- # virtual_specs: quoted for command-line use # virtual_specs_debug: unquoted for display @@ -475,7 +509,11 @@ def write_pyproject_toml(self, root: Path, external: Path) -> None: def _stage_dists(self, pkgs_dir: Path) -> None: download_dir = Path(self.info["_download_dir"]) - for dist in self.info["_dists"]: + # Collect dists from base and extra_envs, de-duplicated + dists = set(self.info["_dists"]) + for env_info in self.info.get("_extra_envs_info", {}).values(): + dists.update(env_info.get("_dists", [])) + for dist in sorted(dists): shutil.copy(download_dir / filename_dist(dist), pkgs_dir) def _stage_conda(self, external_dir: Path) -> None: @@ -489,6 +527,11 @@ def create(info, verbose=False): if not info.get("_conda_exe_supports_logging"): raise Exception("MSI installers require conda-standalone with logging support.") + # MSI installers always use conda-standalone for uninstallation. + # This ensures proper cleanup of conda init, environments, and shortcuts + # via the `conda constructor uninstall` command. + info["uninstall_with_conda_exe"] = True + payload = Payload(info) payload.prepare() diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 0624be5fa..0824bb87c 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -115,7 +115,6 @@ exit /b 0 :after_remove_python_registry {%- endif %} -{%- if uninstall_with_conda_exe %} rem Run constructor uninstall, conditionally passing optional flags set "UNINST_ARGS=" if "%OPTION_REMOVE_USER_DATA%"=="1" ( @@ -136,31 +135,6 @@ if "%OPTION_REMOVE_CONFIG_FILES%"=="1" ( {{ tee("Running constructor uninstall...") }} "%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"!UNINST_ARGS! --log-file "%LOG%" if errorlevel 1 ( exit /b %errorlevel% ) -{%- else %} -rem Remove menus for each environment. -{%- for env in setup_envs %} -{{ tee("Removing menus for " + env.name + "...") }} -"%CONDA_EXE%" constructor --prefix "{{ env.prefix }}" --rm-menus --log-file "%LOG%" -if errorlevel 1 ( exit /b %errorlevel% ) -{%- endfor %} - -{%- if has_conda %} -rem Reverse conda shell initialization -if "%REG_HIVE%"=="HKCU" ( - set "CONDA_INIT_SCOPE=user" -) else ( - set "CONDA_INIT_SCOPE=system" -) -{{ tee("Reversing conda shell initialization...") }} -"%BASE_PATH%\condabin\conda.bat" init cmd.exe --reverse --!CONDA_INIT_SCOPE! --log-file "%LOG%" -if errorlevel 1 ( exit /b %errorlevel% ) -{%- endif %} - -rem Remove conda environments. INSTDIR itself is cleaned up by the MSI engine. -{{ tee("Removing environments...") }} -rmdir /s /q "%BASE_PATH%" -if errorlevel 1 ( exit /b %errorlevel% ) -{%- endif %} rem If we reached this far without any errors, remove any log files. if exist "%INSTDIR%\install.log" del "%INSTDIR%\install.log" diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index d6c2eb31c..407adc284 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -15,6 +15,18 @@ echo {{ message }} >> "%LOG%" echo {{ message }} {%- endmacro %} +{% macro install_env(env) %} +{{ tee("Setting up " ~ env.name ~ " environment...") }} +set "CONDA_CHANNELS={{ env.channels }}" +if "%OPTION_ENABLE_SHORTCUTS%"=="1" ( + "%CONDA_EXE%" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile }}" {{ env.shortcuts }} {{ no_rcs_arg }} --log-file "%LOG%" +) else ( + "%CONDA_EXE%" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile }}" --no-shortcuts {{ no_rcs_arg }} --log-file "%LOG%" +) +set "INSTALL_ERRORLEVEL=%errorlevel%" +if %INSTALL_ERRORLEVEL% neq 0 ( exit /b %INSTALL_ERRORLEVEL% ) +{% endmacro %} + rem Assign INSTDIR and normalize the path set "INSTDIR=%~dp0.." for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" @@ -98,8 +110,6 @@ set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" "%CONDA_EXE%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs --log-file "%LOG%" if errorlevel 1 ( exit /b %errorlevel% ) -rem TODO: loop over extra_envs when extra_envs support is implemented for MSI. - rem Create .nonadmin marker file for user-scoped installs inside BASE_PATH. rem This is used by the uninstaller (and menuinst) to determine the install mode. if "%ALLUSERS%"=="0" ( @@ -107,15 +117,10 @@ if "%ALLUSERS%"=="0" ( if errorlevel 1 ( exit /b %errorlevel% ) ) -rem Install packages, conditionally creating shortcuts -if "%OPTION_ENABLE_SHORTCUTS%"=="1" ( - {{ tee("Installing packages with shortcuts...") }} - "%CONDA_EXE%" install --offline -yp "%BASE_PATH%" --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" {{ shortcuts }} {{ no_rcs_arg }} --log-file "%LOG%" -) else ( - {{ tee("Installing packages...") }} - "%CONDA_EXE%" install --offline -yp "%BASE_PATH%" --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" --no-shortcuts {{ no_rcs_arg }} --log-file "%LOG%" -) -if errorlevel 1 ( exit /b %errorlevel% ) +rem Install packages for each environment +{%- for env in setup_envs %} +{{ install_env(env) }} +{%- endfor %} rem Delete the payload to save disk space. rem A truncated placeholder of 0 bytes is recreated during uninstall diff --git a/constructor/main.py b/constructor/main.py index e1168fbad..80d06d8e7 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -48,6 +48,19 @@ def get_installer_type(info: dict): return os_allowed[osname][:1] elif itype == "all": return os_allowed[osname] + elif isinstance(itype, (list, tuple)): + # Handle list of installer types, e.g. [exe, msi] + for t in itype: + if t not in all_allowed: + all_allowed_str = ", ".join(sorted(all_allowed)) + sys.exit("Error: invalid installer type '%s'; allowed: %s" % (t, all_allowed_str)) + if t not in os_allowed[osname]: + os_allowed_str = ", ".join(sorted(os_allowed[osname])) + sys.exit( + "Error: invalid installer type '%s' for %s; allowed: %s" + % (t, osname, os_allowed_str) + ) + return tuple(itype) elif itype not in all_allowed: all_allowed = ", ".join(sorted(all_allowed)) sys.exit("Error: invalid installer type '%s'; allowed: %s" % (itype, all_allowed)) diff --git a/examples/outputs/construct.yaml b/examples/outputs/construct.yaml index 9080dc36d..67909fcfc 100644 --- a/examples/outputs/construct.yaml +++ b/examples/outputs/construct.yaml @@ -4,7 +4,7 @@ name: Outputs version: 1.0.0 installer_type: sh # [unix] -installer_type: exe # [win] +installer_type: [exe, msi] # [win] channels: - https://conda.anaconda.org/conda-forge specs: diff --git a/examples/shortcuts/construct.yaml b/examples/shortcuts/construct.yaml index e7c8877f4..a17be497c 100644 --- a/examples/shortcuts/construct.yaml +++ b/examples/shortcuts/construct.yaml @@ -3,7 +3,7 @@ name: MinicondaWithShortcuts version: X -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all channels: - conda-test/label/menuinst-tests diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 595ece483..d550e5b68 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -25,7 +25,7 @@ requirements: - conda >=4.6 - python # >=3.10 - ruamel.yaml >=0.11.14,<0.19 - - conda-standalone >=24.1.2 + - conda-standalone >=24.11.0 - jinja2 - jsonschema >=4 - pillow >=3.1 # [win or osx] diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index bdb8afd61..cefa17c75 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -8,6 +8,7 @@ Payload, _get_python_info, _get_script_env_variables, + _setup_envs_commands, create_uninstall_options_list, get_bundle_app_name, get_name_version, @@ -26,7 +27,6 @@ "_dists": [], "_platform": cc_platform, "_urls": [], - "uninstall_with_conda_exe": False, } @@ -435,7 +435,6 @@ def test_render_templates_uninstall_option_variable_names(): """Verify that the uninstall option variable names in the rendered template match exactly what run_pre_uninstall.bat sets via positional arguments.""" info = mock_info.copy() - info["uninstall_with_conda_exe"] = True payload = Payload(info) rendered_templates = payload.render_templates() @@ -602,11 +601,9 @@ def test_pre_uninstall_conda_root_prefix(): assert "CONDA_ROOT_PREFIX=%BASE_PATH%" in text -def test_create_uninstall_options_list_with_conda_exe(): - """Test that create_uninstall_options_list returns all expected options - when uninstall_with_conda_exe is True.""" - info = {"uninstall_with_conda_exe": True} - options = create_uninstall_options_list(info) +def test_create_uninstall_options_list(): + """Test that create_uninstall_options_list returns all expected options.""" + options = create_uninstall_options_list({}) option_names = [opt["name"] for opt in options] assert "remove_user_data" in option_names @@ -614,14 +611,6 @@ def test_create_uninstall_options_list_with_conda_exe(): assert "remove_config_files" in option_names -def test_create_uninstall_options_list_without_conda_exe(): - """Test that create_uninstall_options_list returns empty list - when uninstall_with_conda_exe is False.""" - info = {"uninstall_with_conda_exe": False} - options = create_uninstall_options_list(info) - assert options == [] - - def test_render_templates_with_virtual_specs(): """Test that virtual_specs check block is rendered when specs are provided.""" info = mock_info.copy() @@ -730,3 +719,133 @@ def test_render_templates_without_script_env_variables(template_name): text = template.read_text(encoding="utf-8") assert "User-defined environment variables" not in text + + +def test_setup_envs_commands_base_only(): + """Test _setup_envs_commands returns only base env when no extra_envs.""" + info = { + "channels": ["conda-forge", "defaults"], + "_extra_envs_info": {}, + } + envs = _setup_envs_commands(info) + + assert len(envs) == 1 + assert envs[0]["name"] == "base" + assert envs[0]["prefix"] == "%BASE_PATH%" + assert envs[0]["lockfile"] == r"%BASE_PATH%\conda-meta\initial-state.explicit.txt" + assert "conda-forge" in envs[0]["channels"] + + +def test_setup_envs_commands_with_extra_envs(): + """Test _setup_envs_commands includes extra_envs.""" + info = { + "channels": ["defaults"], + "_extra_envs_info": { + "py311": {"_dists": []}, + "tools": {"_dists": []}, + }, + "extra_envs": { + "py311": { + "channels": ["conda-forge"], + }, + "tools": { + # No channels specified, should inherit from base + }, + }, + } + envs = _setup_envs_commands(info) + + assert len(envs) == 3 + + # Base env + assert envs[0]["name"] == "base" + assert envs[0]["prefix"] == "%BASE_PATH%" + + # Extra envs + env_names = [e["name"] for e in envs] + assert "py311" in env_names + assert "tools" in env_names + + py311_env = next(e for e in envs if e["name"] == "py311") + assert py311_env["prefix"] == r"%BASE_PATH%\envs\py311" + assert py311_env["lockfile"] == r"%BASE_PATH%\envs\py311\conda-meta\initial-state.explicit.txt" + assert "conda-forge" in py311_env["channels"] + + tools_env = next(e for e in envs if e["name"] == "tools") + assert tools_env["prefix"] == r"%BASE_PATH%\envs\tools" + # tools inherits channels from base + assert "defaults" in tools_env["channels"] + + +def test_setup_envs_commands_shortcuts(): + """Test _setup_envs_commands handles menu_packages/shortcuts correctly.""" + info = { + "channels": ["defaults"], + "menu_packages": ["console_shortcut"], + "_extra_envs_info": { + "myenv": {"_dists": []}, + }, + "extra_envs": { + "myenv": { + "menu_packages": ["other_shortcut"], + }, + }, + } + envs = _setup_envs_commands(info) + + assert len(envs) == 2 + + base_env = envs[0] + assert "console_shortcut" in base_env["shortcuts"] + + myenv = envs[1] + assert "other_shortcut" in myenv["shortcuts"] + + +def test_setup_envs_commands_channels_remap(): + """Test _setup_envs_commands handles channels_remap correctly.""" + info = { + "channels": ["https://foo.bar"], + "channels_remap": [{"src": "https://foo.bar", "dest": "conda-forge"}], + "_extra_envs_info": { + "myenv": {"_dists": []}, + }, + "extra_envs": { + "myenv": { + "channels": ["https://foo.bar"], + "channels_remap": [{"src": "https://foo.bar", "dest": "my-mirror"}], + }, + }, + } + envs = _setup_envs_commands(info) + + base_env = envs[0] + assert "conda-forge" in base_env["channels"] + + myenv = envs[1] + assert "my-mirror" in myenv["channels"] + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +def test_render_templates_with_extra_envs(): + """Test that extra_envs are rendered in the install template.""" + info = mock_info.copy() + info["channels"] = ["defaults"] + info["_extra_envs_info"] = { + "py311": {"_dists": []}, + } + info["extra_envs"] = { + "py311": { + "channels": ["conda-forge"], + }, + } + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + # Both base and py311 environments should be installed + assert "Setting up base environment" in text + assert "Setting up py311 environment" in text + assert r"%BASE_PATH%\envs\py311" in text diff --git a/tests/test_examples.py b/tests/test_examples.py index af7c6e34a..d50b5d247 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -992,6 +992,10 @@ def test_example_shortcuts(tmp_path, request): _run_installer(input_path, installer, install_dir, request=request, uninstall=False) # check that the shortcuts are created if sys.platform == "win32": + # MSI shortcut verification expected to fail due to menuinst bug + # https://github.com/conda/menuinst/issues/453 + if installer.suffix == ".msi": + pytest.xfail("MSI shortcut verification fails due to menuinst#453") for key in ("ProgramData", "AppData"): start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" package_1 = start_menu / "Package 1" From 73b7c09059a351c459b0cd056b1da858a23f254c Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:21:23 -0400 Subject: [PATCH 10/17] Misc improvements (#1191) --- constructor/briefcase.py | 66 +++++++++++++++++++++------------------- tests/test_briefcase.py | 28 ++++++++--------- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index a530c054e..9390cc2e0 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -360,7 +360,7 @@ def remove(self, *, ignore_errors: bool = True) -> None: # delattr on a cached_property may raise on some versions / edge cases pass - def prepare(self) -> tuple: + def prepare(self) -> None: """Prepares the payload. Directory structure created during preparation: @@ -369,9 +369,10 @@ def prepare(self) -> tuple: └── / (external_dir: contains the payload archive and conda exe) └── base/ (base_dir: represents the base conda environment) └── pkgs/ (pkgs_dir: staging area for conda package distributions) + + Note: base_dir and pkgs_dir are removed after archiving. """ - root = self.root - external_dir = root / EXTERNAL_PACKAGE_PATH + external_dir = self.root / EXTERNAL_PACKAGE_PATH external_dir.mkdir(parents=True, exist_ok=True) # Note that the directory name "base" is also explicitly defined in `run_installation.bat` @@ -380,9 +381,9 @@ def prepare(self) -> tuple: pkgs_dir = base_dir / "pkgs" pkgs_dir.mkdir() - # Render the template files and add them to the necessary config field + self.render_templates() - self.write_pyproject_toml(root, external_dir) + self.write_pyproject_toml(self.root, external_dir) preconda.write_files(self.info, base_dir) preconda.copy_extra_files(self.info.get("extra_files", []), external_dir) @@ -392,7 +393,6 @@ def prepare(self) -> tuple: archive_path = self.make_archive(base_dir, external_dir) if not archive_path.exists(): raise RuntimeError(f"Unexpected error, failed to create archive: {archive_path}") - return (root, external_dir, base_dir, pkgs_dir) def make_archive(self, src: Path, dst: Path) -> Path: """Create an archive of the directory 'src'. @@ -522,40 +522,42 @@ def _stage_conda(self, external_dir: Path) -> None: def create(info, verbose=False): if not IS_WINDOWS: - raise Exception(f"Invalid platform '{sys.platform}'. MSI installers require Windows.") + raise OSError(f"Invalid platform '{sys.platform}'. MSI installers require Windows.") if not info.get("_conda_exe_supports_logging"): - raise Exception("MSI installers require conda-standalone with logging support.") - - # MSI installers always use conda-standalone for uninstallation. - # This ensures proper cleanup of conda init, environments, and shortcuts - # via the `conda constructor uninstall` command. - info["uninstall_with_conda_exe"] = True - - payload = Payload(info) - payload.prepare() + raise ValueError("MSI installers require conda-standalone with logging support.") + # Check briefcase exists before doing any work briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe" if not briefcase.exists(): raise FileNotFoundError( f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}" ) - logger.info("Building MSI installer") - run( - [briefcase, "package"] + (["-v"] if verbose else []), - cwd=payload.root, - check=True, - ) - - dist_dir = payload.root / "dist" - msi_paths = list(dist_dir.glob("*.msi")) - if len(msi_paths) != 1: - raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}, expected 1.") + # MSI installers always use conda-standalone for uninstallation. + # This ensures proper cleanup of conda init, environments, and shortcuts + # via the `conda constructor uninstall` command. + info["uninstall_with_conda_exe"] = True - outpath = Path(info["_outpath"]) - outpath.unlink(missing_ok=True) - shutil.move(msi_paths[0], outpath) + payload = Payload(info) + try: + payload.prepare() + + logger.info("Building MSI installer") + run( + [briefcase, "package"] + (["-v"] if verbose else []), + cwd=payload.root, + check=True, + ) - if not info.get("_debug"): - payload.remove() + dist_dir = payload.root / "dist" + msi_paths = list(dist_dir.glob("*.msi")) + if len(msi_paths) != 1: + raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}, expected 1.") + + outpath = Path(info["_outpath"]) + outpath.unlink(missing_ok=True) + shutil.move(msi_paths[0], outpath) + finally: + if not info.get("_debug"): + payload.remove() diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index cefa17c75..5609b9092 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -198,15 +198,13 @@ def test_payload_layout(): """ info = mock_info.copy() payload = Payload(info) - prepared_payload = payload.prepare() + payload.prepare() - root = prepared_payload[0] - external_dir = root / "external" - # The second item in prepared_payload is the 'external' directory - assert external_dir.is_dir() and external_dir == prepared_payload[1] + external_dir = payload.root / "external" + assert external_dir.is_dir() - base_dir = root / "external" / "base" - pkgs_dir = root / "external" / "base" / "pkgs" + base_dir = payload.root / "external" / "base" + pkgs_dir = payload.root / "external" / "base" / "pkgs" archive_path = external_dir / payload.archive_name # Since archiving removes the directory 'base_dir' and its contents assert not base_dir.exists() @@ -241,11 +239,12 @@ def test_payload_remove(): """Test removing the payload.""" info = mock_info.copy() payload = Payload(info) - prepared_payload = payload.prepare() + payload.prepare() + root = payload.root - assert prepared_payload[0].is_dir() + assert root.is_dir() payload.remove() - assert not prepared_payload[0].is_dir() + assert not root.is_dir() @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") @@ -253,8 +252,8 @@ def test_payload_pyproject_toml(): """Test that the pyproject.toml file is created when the payload is prepared.""" info = mock_info.copy() payload = Payload(info) - prepared_payload = payload.prepare() - pyproject_toml = prepared_payload[0] / "pyproject.toml" + payload.prepare() + pyproject_toml = payload.root / "pyproject.toml" assert pyproject_toml.is_file() @@ -263,8 +262,9 @@ def test_payload_conda_exe(): """Test that conda-standalone is prepared.""" info = mock_info.copy() payload = Payload(info) - prepared_payload = payload.prepare() - conda_exe = prepared_payload[1] / "_conda.exe" # The second item is the 'external' directory + payload.prepare() + external_dir = payload.root / "external" + conda_exe = external_dir / "_conda.exe" assert conda_exe.is_file() From 50a7258e4b867d043465a135cff803217fc3b215 Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:57:37 -0400 Subject: [PATCH 11/17] MSI: Add support for user supplied scripts (#1189) * Add support for user supplied scripts * Enable some new examples * Fix type error for test * Change copying of extra_files to base environment * Fix issue with license file not being copied * Add debug output * Fix order of user script, fixes last error --- CONSTRUCT.md | 7 +- constructor/_schema.py | 7 +- constructor/briefcase.py | 35 +++++- constructor/briefcase/pre_uninstall.bat | 16 +++ constructor/briefcase/run_installation.bat | 35 ++++++ constructor/data/construct.schema.json | 4 +- docs/source/construct-yaml.md | 7 +- examples/customize_controls/construct.yaml | 2 +- examples/from_env_yaml/construct.yaml | 2 +- tests/test_briefcase.py | 137 +++++++++++++++++++++ tests/test_examples.py | 2 +- 11 files changed, 242 insertions(+), 12 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 0cb245960..72e518ba7 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -388,6 +388,9 @@ Path to a post-install script. Some notes: the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise. +- For Windows `.msi` installers, the script must be a `.bat` file. + The same variables as `.exe` installers are available, except + `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available. If necessary, you can activate the installed `base` environment like this: @@ -406,11 +409,11 @@ This option has no effect on `SH` installers. ### `pre_uninstall` -Path to a pre uninstall script. This is only supported on Windows, +Path to a pre uninstall script. This is only supported on Windows (EXE and MSI), and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. -`%INSTALLER_TYPE%` is set to `EXE`. +`%INSTALLER_TYPE%` is set to `EXE` or `MSI`. If the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to diff --git a/constructor/_schema.py b/constructor/_schema.py index 543deb108..4019e6e70 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -558,6 +558,9 @@ class ConstructorConfiguration(BaseModel): the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise. + - For Windows `.msi` installers, the script must be a `.bat` file. + The same variables as `.exe` installers are available, except + `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available. If necessary, you can activate the installed `base` environment like this: @@ -576,11 +579,11 @@ class ConstructorConfiguration(BaseModel): """ pre_uninstall: NonEmptyStr | None = None """ - Path to a pre uninstall script. This is only supported on Windows, + Path to a pre uninstall script. This is only supported on Windows (EXE and MSI), and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. - `%INSTALLER_TYPE%` is set to `EXE`. + `%INSTALLER_TYPE%` is set to `EXE` or `MSI`. If the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 9390cc2e0..d6f7e855a 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -386,8 +386,12 @@ def prepare(self) -> None: self.write_pyproject_toml(self.root, external_dir) preconda.write_files(self.info, base_dir) - preconda.copy_extra_files(self.info.get("extra_files", []), external_dir) + preconda.copy_extra_files(self.info.get("extra_files", []), base_dir) + # Copy license file to PREFIX, matching behavior of other installer types + if license_file := self.info.get("license_file"): + preconda.copy_extra_files([license_file], base_dir) self._stage_dists(pkgs_dir) + self._stage_user_scripts(pkgs_dir) self._stage_conda(external_dir) archive_path = self.make_archive(base_dir, external_dir) @@ -463,6 +467,19 @@ def render_templates(self) -> list[Path]: # --- script_env_variables --- # User-defined environment variables for pre/post install scripts "script_env_variables": _get_script_env_variables(self.info), + # --- user scripts --- + # Flags indicating whether user-supplied scripts are present + "has_pre_install": bool(self.info.get("pre_install")), + "has_post_install": bool(self.info.get("post_install")), + "has_pre_uninstall": bool(self.info.get("pre_uninstall")), + # Flags indicating whether scripts are optional (have a description) + "has_pre_install_desc": bool(self.info.get("pre_install_desc")), + "has_post_install_desc": bool(self.info.get("post_install_desc")), + # --- installer metadata --- + # Used by user scripts to identify the installer + "installer_name": self.info.get("name", ""), + "installer_version": self.info.get("version", ""), + "installer_platform": self.info.get("_platform", ""), } # Render the templates now using jinja and the defined context @@ -516,6 +533,22 @@ def _stage_dists(self, pkgs_dir: Path) -> None: for dist in sorted(dists): shutil.copy(download_dir / filename_dist(dist), pkgs_dir) + def _stage_user_scripts(self, pkgs_dir: Path) -> None: + """Copy user-supplied pre/post install scripts to the pkgs directory.""" + script_mappings = [ + ("pre_install", "user_pre_install.bat"), + ("post_install", "user_post_install.bat"), + ("pre_uninstall", "user_pre_uninstall.bat"), + ] + for key, dest_name in script_mappings: + if script_path := self.info.get(key): + script_path = Path(script_path) + if not is_bat_file(script_path): + raise ValueError( + f"Specified {key} script '{script_path}' must be an existing '.bat' file." + ) + shutil.copy(script_path, pkgs_dir / dest_name) + def _stage_conda(self, external_dir: Path) -> None: copy_conda_exe(external_dir, self.conda_exe_name, self.info["_conda_exe"]) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 0824bb87c..400b2faa1 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -37,6 +37,15 @@ set "{{ key }}={{ val }}" {%- endfor %} {%- endif %} +rem Installer metadata for pre-uninstall script +set "INSTALLER_NAME={{ installer_name }}" +set "INSTALLER_VER={{ installer_version }}" +set "INSTALLER_PLAT={{ installer_platform }}" +set "INSTALLER_TYPE=MSI" +rem INSTALLER_UNATTENDED is not available for MSI installers. +rem Detecting silent mode requires UILevel from WiX, which would need +rem changes to the briefcase-windows-app-template to pass to this script. + rem Determine install mode from .nonadmin marker file written at install time if exist "%BASE_PATH%\.nonadmin" ( set "REG_HIVE=HKCU" @@ -78,6 +87,13 @@ if errorlevel 1 ( {{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }} ) +{%- if has_pre_uninstall %} +rem Run user-supplied pre-uninstall script +{{ tee("Running pre-uninstall script...") }} +call "%BASE_PATH%\pkgs\user_pre_uninstall.bat" +if errorlevel 1 ( exit /b %errorlevel% ) +{%- endif %} + rem Remove PATH entries only for user-scoped installs (mirrors NSIS .nonadmin check) {%- set pathflag = "--condabin" if initialize_conda == "condabin" else "--classic" %} if exist "%BASE_PATH%\.nonadmin" ( diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 407adc284..4e30123b3 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -53,6 +53,15 @@ set "{{ key }}={{ val }}" {%- endfor %} {%- endif %} +rem Installer metadata for pre/post install scripts +set "INSTALLER_NAME={{ installer_name }}" +set "INSTALLER_VER={{ installer_version }}" +set "INSTALLER_PLAT={{ installer_platform }}" +set "INSTALLER_TYPE=MSI" +rem INSTALLER_UNATTENDED is not available for MSI installers. +rem Detecting silent mode requires UILevel from WiX, which would need +rem changes to the briefcase-windows-app-template to pass to this script. + {%- if add_debug %} >> "%LOG%" echo ==== run_installation start ==== >> "%LOG%" echo SCRIPT=%~f0 @@ -117,6 +126,19 @@ if "%ALLUSERS%"=="0" ( if errorlevel 1 ( exit /b %errorlevel% ) ) +{%- if has_pre_install %} +rem Run user-supplied pre-install script +{%- if has_pre_install_desc %} +if "%OPTION_PRE_INSTALL_SCRIPT%"=="1" ( +{%- endif %} + {{ tee("Running pre-install script...") }} + call "%BASE_PATH%\pkgs\user_pre_install.bat" + if errorlevel 1 ( exit /b %errorlevel% ) +{%- if has_pre_install_desc %} +) +{%- endif %} +{%- endif %} + rem Install packages for each environment {%- for env in setup_envs %} {{ install_env(env) }} @@ -166,6 +188,19 @@ if "%OPTION_REGISTER_PYTHON%"=="1" ( ) {%- endif %} +{%- if has_post_install %} +rem Run user-supplied post-install script +{%- if has_post_install_desc %} +if "%OPTION_POST_INSTALL_SCRIPT%"=="1" ( +{%- endif %} + {{ tee("Running post-install script...") }} + call "%BASE_PATH%\pkgs\user_post_install.bat" + if errorlevel 1 ( exit /b %errorlevel% ) +{%- if has_post_install_desc %} +) +{%- endif %} +{%- endif %} + rem Clear the package cache if the option was selected if "%OPTION_CLEAR_PACKAGE_CACHE%"=="1" ( {{ tee("Clearing package cache...") }} diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 38770df84..802810da7 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -996,7 +996,7 @@ } ], "default": null, - "description": "Path to a post-install script. Some notes:\n- For Unix `.sh` installers, the shebang line is respected if present; otherwise, the script is run by the POSIX shell `sh`. Note that the use of a shebang can reduce the portability of the installer. The installation path is available as `${PREFIX}`. Installer metadata is available in the `${INSTALLER_NAME}`, `${INSTALLER_VER}`, `${INSTALLER_PLAT}` environment variables. `${INSTALLER_TYPE}` is set to `SH`. `${INSTALLER_UNATTENDED}` will be `\"1\"` in batch mode (`-b`), `\"0\"` otherwise.\n- For PKG installers, the shebang line is respected if present; otherwise, `bash` is used. The same variables mentioned for `sh` installers are available here. `${INSTALLER_TYPE}` is set to `PKG`. `${INSTALLER_UNATTENDED}` will be `\"1\"` for command line installs, `\"0\"` otherwise.\n- For Windows `.exe` installers, the script must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `\"1\"` in silent mode (`/S`), `\"0\"` otherwise.\nIf necessary, you can activate the installed `base` environment like this:\n- Unix: `. \"$PREFIX/etc/profile.d/conda.sh\" && conda activate \"$PREFIX\"`\n- Windows: `call \"%PREFIX%\\Scripts\\activate.bat\"`", + "description": "Path to a post-install script. Some notes:\n- For Unix `.sh` installers, the shebang line is respected if present; otherwise, the script is run by the POSIX shell `sh`. Note that the use of a shebang can reduce the portability of the installer. The installation path is available as `${PREFIX}`. Installer metadata is available in the `${INSTALLER_NAME}`, `${INSTALLER_VER}`, `${INSTALLER_PLAT}` environment variables. `${INSTALLER_TYPE}` is set to `SH`. `${INSTALLER_UNATTENDED}` will be `\"1\"` in batch mode (`-b`), `\"0\"` otherwise.\n- For PKG installers, the shebang line is respected if present; otherwise, `bash` is used. The same variables mentioned for `sh` installers are available here. `${INSTALLER_TYPE}` is set to `PKG`. `${INSTALLER_UNATTENDED}` will be `\"1\"` for command line installs, `\"0\"` otherwise.\n- For Windows `.exe` installers, the script must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `\"1\"` in silent mode (`/S`), `\"0\"` otherwise.\n- For Windows `.msi` installers, the script must be a `.bat` file. The same variables as `.exe` installers are available, except `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available.\nIf necessary, you can activate the installed `base` environment like this:\n- Unix: `. \"$PREFIX/etc/profile.d/conda.sh\" && conda activate \"$PREFIX\"`\n- Windows: `call \"%PREFIX%\\Scripts\\activate.bat\"`", "title": "Post Install" }, "post_install_desc": { @@ -1073,7 +1073,7 @@ } ], "default": null, - "description": "Path to a pre uninstall script. This is only supported on Windows, and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`.\nIf the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to `system`, `user`, or `all` if selected), `%UNINSTALLER_REMOVE_USER_DATA%` (set to `1` if set), and `%UNINSTALLER_REMOVE_CACHES%` (set to `1` if set).", + "description": "Path to a pre uninstall script. This is only supported on Windows (EXE and MSI), and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE` or `MSI`.\nIf the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to `system`, `user`, or `all` if selected), `%UNINSTALLER_REMOVE_USER_DATA%` (set to `1` if set), and `%UNINSTALLER_REMOVE_CACHES%` (set to `1` if set).", "title": "Pre Uninstall" }, "progress_notifications": { diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 0cb245960..72e518ba7 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -388,6 +388,9 @@ Path to a post-install script. Some notes: the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise. +- For Windows `.msi` installers, the script must be a `.bat` file. + The same variables as `.exe` installers are available, except + `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available. If necessary, you can activate the installed `base` environment like this: @@ -406,11 +409,11 @@ This option has no effect on `SH` installers. ### `pre_uninstall` -Path to a pre uninstall script. This is only supported on Windows, +Path to a pre uninstall script. This is only supported on Windows (EXE and MSI), and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. -`%INSTALLER_TYPE%` is set to `EXE`. +`%INSTALLER_TYPE%` is set to `EXE` or `MSI`. If the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to diff --git a/examples/customize_controls/construct.yaml b/examples/customize_controls/construct.yaml index 161ac88e9..907ba11c9 100644 --- a/examples/customize_controls/construct.yaml +++ b/examples/customize_controls/construct.yaml @@ -3,7 +3,7 @@ name: NoCondaOptions version: X -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/from_env_yaml/construct.yaml b/examples/from_env_yaml/construct.yaml index 6711be0c9..caa5922b8 100644 --- a/examples/from_env_yaml/construct.yaml +++ b/examples/from_env_yaml/construct.yaml @@ -3,7 +3,7 @@ name: EnvironmentYAML version: 1.0.0 -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all environment_file: env.yaml initialize_by_default: false register_python: False diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 5609b9092..faa551ef3 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -849,3 +849,140 @@ def test_render_templates_with_extra_envs(): assert "Setting up base environment" in text assert "Setting up py311 environment" in text assert r"%BASE_PATH%\envs\py311" in text + + +@pytest.mark.parametrize("template_name", ["run_installation.bat", "pre_uninstall.bat"]) +def test_render_templates_installer_metadata(template_name): + """Test that installer metadata env vars are rendered in both templates.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + template = next(f for f in rendered_templates if f.name == template_name) + text = template.read_text(encoding="utf-8") + + assert 'set "INSTALLER_NAME=MockInfo"' in text + assert 'set "INSTALLER_VER=1.0.0"' in text + assert f'set "INSTALLER_PLAT={cc_platform}"' in text + assert 'set "INSTALLER_TYPE=MSI"' in text + assert "INSTALLER_UNATTENDED is not available" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +@pytest.mark.parametrize( + "script_type,has_desc", + [ + ("pre_install", False), + ("pre_install", True), + ("post_install", False), + ("post_install", True), + ], +) +def test_render_templates_user_install_scripts(tmp_path, script_type, has_desc): + """Test that user install scripts are rendered correctly. + + - Mandatory scripts (no desc) are always called + - Optional scripts (with desc) are gated by OPTION flag + """ + script = tmp_path / f"{script_type}.bat" + script.write_text(f"@echo {script_type}") + + info = mock_info.copy() + info[script_type] = str(script) + if has_desc: + info[f"{script_type}_desc"] = "Custom script description" + + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + # Script call should always be present + label = script_type.replace("_", "-") # pre_install -> pre-install + assert f"Running {label} script" in text + assert f"user_{script_type}.bat" in text + + # OPTION check only present for optional scripts + option_var = f"OPTION_{script_type.upper()}_SCRIPT" + if has_desc: + assert option_var in text + else: + assert option_var not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +def test_render_templates_with_pre_uninstall(tmp_path): + """Test that pre_uninstall script call is rendered.""" + script = tmp_path / "pre_uninstall.bat" + script.write_text("@echo pre_uninstall") + + info = mock_info.copy() + info["pre_uninstall"] = str(script) + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "Running pre-uninstall script" in text + assert "user_pre_uninstall.bat" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +def test_render_templates_without_user_scripts(): + """Test that no user script blocks are rendered when scripts are not provided.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + + assert "user_pre_install.bat" not in run_installation.read_text(encoding="utf-8") + assert "user_post_install.bat" not in run_installation.read_text(encoding="utf-8") + assert "user_pre_uninstall.bat" not in pre_uninstall.read_text(encoding="utf-8") + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +@pytest.mark.parametrize( + "script_key,dest_name", + [ + ("pre_install", "user_pre_install.bat"), + ("post_install", "user_post_install.bat"), + ("pre_uninstall", "user_pre_uninstall.bat"), + ], +) +def test_stage_user_scripts(tmp_path, script_key, dest_name): + """Test that user scripts are staged to the correct location.""" + script = tmp_path / f"{script_key}.bat" + script.write_text(f"@echo {script_key}") + + info = mock_info.copy() + info[script_key] = str(script) + payload = Payload(info) + + pkgs_dir = tmp_path / "pkgs" + pkgs_dir.mkdir() + payload._stage_user_scripts(pkgs_dir) + + staged_script = pkgs_dir / dest_name + assert staged_script.is_file() + assert staged_script.read_text() == f"@echo {script_key}" + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +def test_stage_user_scripts_validates_bat_extension(tmp_path): + """Test that non-.bat files are rejected.""" + script = tmp_path / "pre_install.sh" + script.write_text("foo") + + info = mock_info.copy() + info["pre_install"] = str(script) + payload = Payload(info) + + pkgs_dir = tmp_path / "pkgs" + pkgs_dir.mkdir() + + with pytest.raises(ValueError, match="must be an existing '.bat' file"): + payload._stage_user_scripts(pkgs_dir) diff --git a/tests/test_examples.py b/tests/test_examples.py index d50b5d247..c444880c7 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -518,7 +518,7 @@ def _run_uninstaller_msi( cmd=cmd, returncode=e.returncode, msi_log=log_path, - post_install_log=pre_uninstall_log, + pre_uninstall_log=pre_uninstall_log, ), original_exception=e, ) From 82b37ad1773dc7ba9620d8731bdfb316bec5cf15 Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:38:04 -0400 Subject: [PATCH 12/17] Fix path issue (#1195) --- constructor/briefcase/pre_uninstall.bat | 2 +- constructor/briefcase/run_installation.bat | 2 +- tests/test_briefcase.py | 24 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 400b2faa1..9f2dd9094 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -98,7 +98,7 @@ rem Remove PATH entries only for user-scoped installs (mirrors NSIS .nonadmin ch {%- set pathflag = "--condabin" if initialize_conda == "condabin" else "--classic" %} if exist "%BASE_PATH%\.nonadmin" ( {{ tee("Removing from PATH...") }} - "%CONDA_EXE%" constructor windows path --remove=user --prefix "%INSTDIR%" {{ pathflag }} --log-file "%LOG%" + "%CONDA_EXE%" constructor windows path --remove=user --prefix "%BASE_PATH%" {{ pathflag }} --log-file "%LOG%" if errorlevel 1 ( exit /b %errorlevel% ) ) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 4e30123b3..aabf9ecc9 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -154,7 +154,7 @@ rem Add to PATH / run conda init if the option was selected {%- set pathflag = "--condabin" if initialize_conda == "condabin" else "--classic" %} if "%OPTION_INITIALIZE_CONDA%"=="1" ( {{ tee("Adding to PATH...") }} - "%CONDA_EXE%" constructor windows path --prepend=user --prefix "%INSTDIR%" {{ pathflag }} --log-file "%LOG%" + "%CONDA_EXE%" constructor windows path --prepend=user --prefix "%BASE_PATH%" {{ pathflag }} --log-file "%LOG%" if errorlevel 1 ( exit /b %errorlevel% ) ) diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index faa551ef3..5e7343edd 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -391,6 +391,30 @@ def test_render_templates_registry_uses_base_path(): assert "%INSTDIR%\\Doc\\" not in text +def test_render_templates_path_commands_use_base_path(): + """Verify that the 'constructor windows path' commands use BASE_PATH + (INSTDIR\\base) and not INSTDIR directly, since the conda environment + lives in BASE_PATH in the MSI layout.""" + info = mock_info.copy() + info["initialize_conda"] = "classic" + payload = Payload(info) + rendered_templates = payload.render_templates() + + # Check run_installation.bat uses BASE_PATH for adding to PATH + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + run_text = run_installation.read_text(encoding="utf-8") + + assert 'constructor windows path --prepend=user --prefix "%BASE_PATH%"' in run_text + assert 'constructor windows path --prepend=user --prefix "%INSTDIR%"' not in run_text + + # Check pre_uninstall.bat uses BASE_PATH for removing from PATH + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + pre_text = pre_uninstall.read_text(encoding="utf-8") + + assert 'constructor windows path --remove=user --prefix "%BASE_PATH%"' in pre_text + assert 'constructor windows path --remove=user --prefix "%INSTDIR%"' not in pre_text + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_render_templates_nonadmin_created_for_user_install(): """Verify that run_installation.bat creates a .nonadmin marker file From 63272e988ffcb9a157908aff9772dfcfd3bc3d3b Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:42:18 -0400 Subject: [PATCH 13/17] MSI: Set CONDA_QUIET and add new NSIS variables to MSI scripts. (#1200) * Set CONDA_QUIET * Add new uninstaller variables --- constructor/briefcase/pre_uninstall.bat | 12 ++++++++++++ constructor/briefcase/run_installation.bat | 2 ++ 2 files changed, 14 insertions(+) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 9f2dd9094..47b31713f 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -25,6 +25,8 @@ set "PREFIX=%BASE_PATH%" set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" set "CONDA_ROOT_PREFIX=%BASE_PATH%" +rem Set CONDA_QUIET primarily to disable the spinners +set CONDA_QUIET={{ 0 if add_debug else 1 }} rem Get the name of the install directory for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" @@ -53,6 +55,13 @@ if exist "%BASE_PATH%\.nonadmin" ( set "REG_HIVE=HKLM" ) +rem Map OPTION_* to UNINSTALLER_* for compatibility with NSIS uninstaller scripts +if "%OPTION_REMOVE_USER_DATA%"=="1" set "UNINSTALLER_REMOVE_USER_DATA=1" +if "%OPTION_REMOVE_CACHES%"=="1" set "UNINSTALLER_REMOVE_CACHES=1" +if "%OPTION_REMOVE_CONFIG_FILES%"=="1" ( + if "%REG_HIVE%"=="HKCU" (set "UNINSTALLER_REMOVE_CONFIG_FILES=user") else (set "UNINSTALLER_REMOVE_CONFIG_FILES=all") +) + {%- if add_debug %} >> "%LOG%" echo ==== pre_uninstall start ==== >> "%LOG%" echo SCRIPT=%~f0 @@ -67,6 +76,9 @@ if exist "%BASE_PATH%\.nonadmin" ( >> "%LOG%" echo OPTION_REMOVE_USER_DATA=%OPTION_REMOVE_USER_DATA% >> "%LOG%" echo OPTION_REMOVE_CACHES=%OPTION_REMOVE_CACHES% >> "%LOG%" echo OPTION_REMOVE_CONFIG_FILES=%OPTION_REMOVE_CONFIG_FILES% +>> "%LOG%" echo UNINSTALLER_REMOVE_USER_DATA=%UNINSTALLER_REMOVE_USER_DATA% +>> "%LOG%" echo UNINSTALLER_REMOVE_CACHES=%UNINSTALLER_REMOVE_CACHES% +>> "%LOG%" echo UNINSTALLER_REMOVE_CONFIG_FILES=%UNINSTALLER_REMOVE_CONFIG_FILES% "%CONDA_EXE%" --version >> "%LOG%" 2>&1 {%- endif %} diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index aabf9ecc9..1abdc7066 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -41,6 +41,8 @@ set CONDA_PROTECT_FROZEN_ENVS=0 set CONDA_REGISTER_ENVS={{ register_envs }} set CONDA_SAFETY_CHECKS=disabled set "CONDA_ROOT_PREFIX=%BASE_PATH%" +rem Set CONDA_QUIET primarily to disable the spinners +set CONDA_QUIET={{ 0 if add_debug else 1 }} rem Get the name of the install directory for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" From f0ec7b7581d511841510f8cf28ccdedd30a3eaf7 Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:36:41 -0400 Subject: [PATCH 14/17] MSI: Enable protected base (#1220) * Enable test for MSI * Dummy commit * Account for 'base' in test for MSI * pre-commit fix --- constructor/briefcase.py | 3 +-- tests/test_examples.py | 16 +++++++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index d6f7e855a..688d7d7bd 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -81,9 +81,8 @@ def get_name_version(info): return name, version -# Takes an arbitrary string with at least one alphanumeric character, and makes it into -# a valid Python package name. def make_app_name(name, source): + """Takes an arbitrary string with at least one alphanumeric character, and makes it into a valid Python package name.""" app_name = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") if not app_name: raise ValueError(f"{source} contains no alphanumeric characters") diff --git a/tests/test_examples.py b/tests/test_examples.py index c444880c7..9a85e67e2 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1785,11 +1785,6 @@ def test_frozen_environment(tmp_path, request, has_conflict): with open(input_path / "construct.yaml") as f: config = yaml.load(f) - # Since the above yaml.load does not rely on jinja rendering, - # set installer_type based on platform instead of using Jinja in the YAML. - # This is needed until MSI installers support protected base environments. - config["installer_type"] = "exe" if os.name == "nt" else "all" - if has_conflict: config.setdefault("extra_files", []).append({"frozen.json": "conda-meta/frozen"}) @@ -1800,11 +1795,14 @@ def test_frozen_environment(tmp_path, request, has_conflict): for installer, install_dir in create_installer(input_path, tmp_path): _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + # MSI installers use a 'base' subdirectory for the conda environment + prefix = install_dir / "base" if installer.suffix == ".msi" else install_dir + expected_frozen = { - install_dir / "conda-meta" / "frozen": config["freeze_base"]["conda"], - install_dir / "envs" / "env1" / "conda-meta" / "frozen": config["extra_envs"][ - "env1" - ]["freeze_env"]["conda"], + prefix / "conda-meta" / "frozen": config["freeze_base"]["conda"], + prefix / "envs" / "env1" / "conda-meta" / "frozen": config["extra_envs"]["env1"][ + "freeze_env" + ]["conda"], } for frozen_path, expected_content in expected_frozen.items(): From 7f2977eb59f06b63c583d7d8287c61368ebe8e1f Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Fri, 1 May 2026 10:43:17 -0400 Subject: [PATCH 15/17] MSI: Add support for signing of installers (#1224) * add signing of installers - work in progress * Review fixes: sign before move, docstring update, rename func --- constructor/briefcase.py | 7 ++ constructor/signing.py | 135 +++++++++++++++++++++++++++++++++++++-- constructor/winexe.py | 16 ++--- tests/test_examples.py | 19 ++++++ 4 files changed, 160 insertions(+), 17 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 688d7d7bd..97130dfb1 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -22,6 +22,7 @@ from . import preconda from .jinja import render_template +from .signing import create_windows_signing_tool from .utils import ( DEFAULT_REVERSE_DOMAIN_ID, bat_echo_esc, @@ -571,6 +572,8 @@ def create(info, verbose=False): # via the `conda constructor uninstall` command. info["uninstall_with_conda_exe"] = True + signing_tool = create_windows_signing_tool(info) + payload = Payload(info) try: payload.prepare() @@ -587,6 +590,10 @@ def create(info, verbose=False): if len(msi_paths) != 1: raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}, expected 1.") + if signing_tool: + signing_tool.sign(msi_paths[0]) + signing_tool.verify_signature(msi_paths[0]) + outpath = Path(info["_outpath"]) outpath.unlink(missing_ok=True) shutil.move(msi_paths[0], outpath) diff --git a/constructor/signing.py b/constructor/signing.py index 02e7b8925..bf316b9dc 100644 --- a/constructor/signing.py +++ b/constructor/signing.py @@ -67,6 +67,10 @@ def verify_signature(self): """Verify the signed installer.""" raise NotImplementedError("Signature verification not implemented for base class.") + def sign(self, file_path: str | Path): + """Sign the specified file.""" + raise NotImplementedError("Signing not implemented for base class.") + class WindowsSignTool(SigningTool): def __init__(self, certificate_file=None): @@ -75,21 +79,49 @@ def __init__(self, certificate_file=None): certificate_file=certificate_file, ) + def _get_signing_params(self): + """Get signing parameters from environment.""" + return { + "timestamp_server": os.environ.get( + "CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL", "http://timestamp.sectigo.com" + ), + "timestamp_digest": os.environ.get("CONSTRUCTOR_SIGNTOOL_TIMESTAMP_DIGEST", "sha256"), + "file_digest": os.environ.get("CONSTRUCTOR_SIGNTOOL_FILE_DIGEST", "sha256"), + "password": os.environ.get("CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD"), + } + def get_signing_command(self) -> str: - timestamp_server = os.environ.get( - "CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL", "http://timestamp.sectigo.com" - ) - timestamp_digest = os.environ.get("CONSTRUCTOR_SIGNTOOL_TIMESTAMP_DIGEST", "sha256") - file_digest = os.environ.get("CONSTRUCTOR_SIGNTOOL_FILE_DIGEST", "sha256") + params = self._get_signing_params() command = ( f"{win_str_esc(self.executable)} sign /f {win_str_esc(self.certificate_file)} " - f"/tr {win_str_esc(timestamp_server)} /td {timestamp_digest} /fd {file_digest}" + f"/tr {win_str_esc(params['timestamp_server'])} /td {params['timestamp_digest']} " + f"/fd {params['file_digest']}" ) - if "CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD" in os.environ: + if params["password"]: # signtool can get the password from the env var on its own command += ' /p "%CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD%"' return command + def sign(self, file_path: str | Path): + """Sign a file using signtool.""" + params = self._get_signing_params() + command = [ + self.executable, + "sign", + "/f", + str(self.certificate_file), + "/tr", + params["timestamp_server"], + "/td", + params["timestamp_digest"], + "/fd", + params["file_digest"], + ] + if params["password"]: + command.extend(["/p", params["password"]]) + command.append(str(file_path)) + check_call(command) + def verify_signing_tool(self): super()._verify_tool_is_available() if not Path(self.certificate_file).exists(): @@ -120,6 +152,75 @@ class AzureSignTool(SigningTool): def __init__(self): super().__init__(os.environ.get("AZURE_SIGNTOOL_PATH", "AzureSignTool")) + def _get_signing_params(self): + """Get signing parameters from environment.""" + required_env_vars = ( + "AZURE_SIGNTOOL_KEY_VAULT_URL", + "AZURE_SIGNTOOL_KEY_VAULT_CERTIFICATE", + ) + check_required_env_vars(required_env_vars) + + return { + "key_vault_url": os.environ["AZURE_SIGNTOOL_KEY_VAULT_URL"], + "key_vault_certificate": os.environ["AZURE_SIGNTOOL_KEY_VAULT_CERTIFICATE"], + "timestamp_server": os.environ.get( + "AZURE_SIGNTOOL_TIMESTAMP_SERVER_URL", "http://timestamp.sectigo.com" + ), + "timestamp_digest": os.environ.get("AZURE_SIGNTOOL_TIMESTAMP_DIGEST", "sha256"), + "file_digest": os.environ.get("AZURE_SIGNTOOL_FILE_DIGEST", "sha256"), + "access_token": os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN"), + "secret": os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_SECRET"), + "client_id": os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_CLIENT_ID"), + "tenant_id": os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID"), + } + + def sign(self, file_path: str | Path): + """Sign a file using AzureSignTool.""" + params = self._get_signing_params() + command = [ + self.executable, + "sign", + "-v", + "-kvu", + params["key_vault_url"], + "-kvc", + params["key_vault_certificate"], + "-tr", + params["timestamp_server"], + "-td", + params["timestamp_digest"], + "-fd", + params["file_digest"], + ] + + if params["access_token"]: + logger.info("AzureSignTool: signing binary using access token.") + command.extend(["-kva", params["access_token"]]) + elif params["secret"]: + logger.info("AzureSignTool: signing binary using secret.") + check_required_env_vars( + ( + "AZURE_SIGNTOOL_KEY_VAULT_CLIENT_ID", + "AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID", + ) + ) + command.extend( + [ + "-kvi", + params["client_id"], + "-kvt", + params["tenant_id"], + "-kvs", + params["secret"], + ] + ) + else: + logger.info("AzureSignTool: signing binary using managed identity.") + command.append("-kvm") + + command.append(str(file_path)) + check_call(command) + def get_signing_command(self) -> str: required_env_vars = ( "AZURE_SIGNTOOL_KEY_VAULT_URL", @@ -261,3 +362,23 @@ def sign_bundle( else: command = self.get_signing_command(bundle, entitlements=entitlements) explained_check_call(command) + + +def create_windows_signing_tool(info: dict) -> SigningTool | None: + """Create a Windows signing tool based on construct.yaml. + + Returns None if no signing is configured. + """ + signing_tool_name = info.get("windows_signing_tool") + if not signing_tool_name: + return None + + if signing_tool_name == "signtool": + signing_tool = WindowsSignTool(certificate_file=info.get("signing_certificate")) + elif signing_tool_name == "azuresigntool": + signing_tool = AzureSignTool() + else: + raise ValueError(f"Unknown signing tool: {signing_tool_name}") + + signing_tool.verify_signing_tool() + return signing_tool diff --git a/constructor/winexe.py b/constructor/winexe.py index 2efa7b0ec..41ff9f9bd 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -17,13 +17,14 @@ from os.path import abspath, basename, dirname, isfile, join from pathlib import Path from subprocess import check_output, run +from typing import TYPE_CHECKING from .construct import ns_platform from .imaging import write_images from .jinja import render_template from .preconda import copy_extra_files from .preconda import write_files as preconda_write_files -from .signing import AzureSignTool, WindowsSignTool +from .signing import create_windows_signing_tool from .utils import ( approx_size_kb, copy_conda_exe, @@ -34,6 +35,9 @@ win_str_esc, ) +if TYPE_CHECKING: + from .signing import AzureSignTool, WindowsSignTool + NSIS_DIR = join(abspath(dirname(__file__)), "nsis") MAKENSIS_EXE = abspath(join(sys.prefix, "NSIS", "makensis.exe")) @@ -357,15 +361,7 @@ def verify_nsis_install(): def create(info, verbose=False): verify_nsis_install() - signing_tool = None - if signing_tool_name := info.get("windows_signing_tool"): - if signing_tool_name == "signtool": - signing_tool = WindowsSignTool(certificate_file=info.get("signing_certificate")) - elif signing_tool_name == "azuresigntool": - signing_tool = AzureSignTool() - else: - raise ValueError(f"Unknown signing tool: {signing_tool_name}") - signing_tool.verify_signing_tool() + signing_tool = create_windows_signing_tool(info) tmp_dir = tempfile.mkdtemp() preconda_write_files(info, tmp_dir) copied_extra_files = copy_extra_files(info.get("extra_files", []), tmp_dir) diff --git a/tests/test_examples.py b/tests/test_examples.py index 9a85e67e2..b7fe4e584 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1029,6 +1029,24 @@ def test_example_shortcuts(tmp_path, request): assert (applications / "package-1_b.desktop").exists() +def _verify_windows_signature(installer: Path): + """Verify a Windows installer has a valid signature.""" + proc = subprocess.run( + [ + "powershell", + "-c", + f"$sig = Get-AuthenticodeSignature -LiteralPath '{installer}';$sig.Status.value__", + ], + capture_output=True, + text=True, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + raise AssertionError(f"Failed to verify signature for {installer}: {proc.stderr}") + status = int(proc.stdout.strip()) + # 0 = Valid, 1 = UnknownError (self-signed certs), >1 = Error/NotSigned + assert status <= 1, f"Signature verification failed for {installer}: status={status}" + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_example_signing(tmp_path, request): input_path = _example_path("signing") @@ -1046,6 +1064,7 @@ def test_example_signing(tmp_path, request): CONSTRUCTOR_SIGNING_CERTIFICATE=str(cert_path), CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD=cert_pwd, ): + _verify_windows_signature(installer) _run_installer(input_path, installer, install_dir, request=request) From c15508e905f93cdda15cef47972561c1085dc809 Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Wed, 20 May 2026 16:59:32 -0400 Subject: [PATCH 16/17] MSI: Add support for installer branding (#1235) * Update write_images and add tests * Update calls to write_images * Add second test * Add image generation to MSI installers * Remove obsolete old code * Update docs * Update formatting and add comment * Add news * Make fixes for issues visible from tests * pre-commit fix * review fixes * Review fixes, update docs, remove invalid test * Update doc to be explicit --- CONSTRUCT.md | 6 +- constructor/_schema.py | 6 +- constructor/briefcase.py | 24 ++++++++ constructor/data/construct.schema.json | 4 +- constructor/imaging.py | 61 +++++++++++++++----- constructor/osxpkg.py | 4 +- docs/source/construct-yaml.md | 6 +- examples/miniconda/bird.png | Bin 12203 -> 0 bytes news/1235-msi-branding | 19 +++++++ tests/test_briefcase.py | 75 +++++++++++++++++++++++++ 10 files changed, 182 insertions(+), 23 deletions(-) delete mode 100644 examples/miniconda/bird.png create mode 100644 news/1235-msi-branding diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 72e518ba7..65b2e649b 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -493,7 +493,9 @@ so the user receives updates after each command executed by the installer. Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. -The image is re-sized to 164 x 314 pixels on Windows and 1227 x 600 on macOS. +The image is re-sized to 164 x 314 pixels for EXE installers, 1227 x 600 on macOS, +and for MSI installers it is scaled to fit a 164-pixel wide side panel (maintaining +aspect ratio) with white padding on the right. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `""` (empty string). @@ -585,7 +587,7 @@ shown before the license information, right after the introduction. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence. -If the installer is for Windows and the welcome file type is nsi, +If the installer is for Windows EXE and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process. diff --git a/constructor/_schema.py b/constructor/_schema.py index 4019e6e70..bc2d3df17 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -663,7 +663,9 @@ class ConstructorConfiguration(BaseModel): """ Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. - The image is re-sized to 164 x 314 pixels on Windows and 1227 x 600 on macOS. + The image is re-sized to 164 x 314 pixels for EXE installers, 1227 x 600 on macOS, + and for MSI installers it is scaled to fit a 164-pixel wide side panel (maintaining + aspect ratio) with white padding on the right. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `""` (empty string). @@ -755,7 +757,7 @@ class ConstructorConfiguration(BaseModel): File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence. - If the installer is for Windows and the welcome file type is nsi, + If the installer is for Windows EXE and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process. """ diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 97130dfb1..99f08c365 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -17,8 +17,11 @@ IS_WINDOWS = sys.platform == "win32" if IS_WINDOWS: import tomli_w + + from .imaging import write_images else: tomli_w = None # This file is only intended for Windows use + write_images = None # imaging.py requires PIL, which is only available on Windows from . import preconda from .jinja import render_template @@ -36,6 +39,12 @@ BRIEFCASE_DIR = Path(__file__).parent / "briefcase" EXTERNAL_PACKAGE_PATH = "external" +# MSI Branding Limitations: +# The following EXE branding options are not supported for MSI installers +# because they require modifications to the WiX template in briefcase-windows-app-template: +# - welcome_file / welcome_text (custom welcome page text) +# - conclusion_file / conclusion_text (finish page text) + # Default to a low version, so that if a valid version is provided in the future, it'll # be treated as an upgrade. DEFAULT_VERSION = "0.0.1" @@ -375,6 +384,9 @@ def prepare(self) -> None: external_dir = self.root / EXTERNAL_PACKAGE_PATH external_dir.mkdir(parents=True, exist_ok=True) + # Generate branding images for MSI installer (only if user provided custom images) + write_images(self.info, external_dir, installer_type="msi") + # Note that the directory name "base" is also explicitly defined in `run_installation.bat` base_dir = external_dir / "base" base_dir.mkdir() @@ -516,6 +528,18 @@ def write_pyproject_toml(self, root: Path, external: Path) -> None: }, } + # Add optional branding images (only if user provided them in construct.yaml) + icon_ico = external / "icon.ico" + if icon_ico.exists(): + # Briefcase expects icon path WITHOUT extension - it appends .ico + config["app"][app_name]["icon"] = str(external / "icon") + welcome_bmp = external / "welcome.bmp" + if welcome_bmp.exists(): + config["app"][app_name]["installer_background"] = str(welcome_bmp) + header_bmp = external / "header.bmp" + if header_bmp.exists(): + config["app"][app_name]["installer_banner"] = str(header_bmp) + # Add optional content if "company" in self.info: config["author"] = self.info["company"] diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 802810da7..013ae8af8 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -1310,7 +1310,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence.\nIf the installer is for Windows and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process.", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence.\nIf the installer is for Windows EXE and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process.", "title": "Welcome File" }, "welcome_image": { @@ -1323,7 +1323,7 @@ } ], "default": null, - "description": "Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. The image is re-sized to 164 x 314 pixels on Windows and 1227 x 600 on macOS. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `\"\"` (empty string).", + "description": "Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. The image is re-sized to 164 x 314 pixels for EXE installers, 1227 x 600 on macOS, and for MSI installers it is scaled to fit a 164-pixel wide side panel (maintaining aspect ratio) with white padding on the right. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `\"\"` (empty string).", "title": "Welcome Image" }, "welcome_image_text": { diff --git a/constructor/imaging.py b/constructor/imaging.py index b6ecd3347..31961ec87 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -25,6 +25,12 @@ icon_size = 256, 256 # These are for OSX welcome_size_osx = 1227, 600 +# MSI/WiX image sizes +# WiX WelcomeDlg uses a full-background image with text overlaid on the right side. +# We create a side-panel effect: branding on left 164px, white padding on right. +welcome_size_msi = (493, 312) +welcome_side_panel_width_msi = 164 # Width for branding area (matches EXE welcome width) +header_size_msi = (493, 58) def new_background(size, color, bs=20, boxes=50): @@ -99,19 +105,43 @@ def add_color_info(info): sys.exit("Error: color '%s' not defined" % color_name) -def write_images(info, dir_path, os="windows"): - if os == "windows": +def _resize_for_msi_welcome(image_path): + """Resize image for MSI welcome dialog with side-panel layout. + + WiX WelcomeDlg uses a full-background bitmap with text overlaid on the right. + The user's image is resized to 164x312 and placed on the left, with white + padding on the right for the dialog text. + """ + im = Image.open(image_path) + + # Resize to side panel dimensions (164x312) + panel_size = (welcome_side_panel_width_msi, welcome_size_msi[1]) + im = im.resize(panel_size) + + # Create white canvas (493x312) and paste image on left side + canvas = Image.new("RGB", welcome_size_msi, color=white) + canvas.paste(im, (0, 0)) + return canvas + + +def write_images(info, dir_path, installer_type="exe"): + if installer_type == "exe": instructions = [ ("welcome", welcome_size, mk_welcome_image, ".bmp"), ("header", header_size, mk_header_image, ".bmp"), ("icon", icon_size, mk_icon_image, ".ico"), ] - elif os == "osx": + elif installer_type == "pkg": instructions = [ ("welcome", welcome_size_osx, mk_welcome_image_osx, ".png"), ] + elif installer_type == "msi": + # MSI uses WiX defaults; user-provided images handled separately below + instructions = [] else: - raise ValueError(f"OS {os} not supported. Choose `windows` or `osx`.") + raise ValueError( + f"Installer type '{installer_type}' not supported. Choose 'exe', 'pkg', or 'msi'." + ) for name, size, function, ext in instructions: key = name + "_image" @@ -124,12 +154,17 @@ def write_images(info, dir_path, os="windows"): assert im.size == size im.save(join(dir_path, name + ext)) - -if __name__ == "__main__": - info = { - "name": "test", - "version": "0.3.1", - "default_image_color": "yellow", - "welcome_image": "../examples/miniconda/bird.png", - } - write_images(info, ".") + # MSI: handle custom images if provided (no auto-generation) + if installer_type == "msi": + if info.get("welcome_image"): + im = _resize_for_msi_welcome(info["welcome_image"]) + assert im.size == welcome_size_msi + im.save(join(dir_path, "welcome.bmp")) + if info.get("header_image"): + im = Image.open(info["header_image"]) + im = im.resize(header_size_msi) + im.save(join(dir_path, "header.bmp")) + if info.get("icon_image"): + im = Image.open(info["icon_image"]) + im = im.resize(icon_size) + im.save(join(dir_path, "icon.ico")) diff --git a/constructor/osxpkg.py b/constructor/osxpkg.py index e2ed9bdef..3174cb884 100644 --- a/constructor/osxpkg.py +++ b/constructor/osxpkg.py @@ -150,10 +150,10 @@ def modify_xml(xml_path, info): if not info["welcome_image"]: background_path = None else: - write_images(info, PACKAGES_DIR, os="osx") + write_images(info, PACKAGES_DIR, installer_type="pkg") background_path = os.path.join(PACKAGES_DIR, "welcome.png") elif "welcome_image_text" in info: - write_images(info, PACKAGES_DIR, os="osx") + write_images(info, PACKAGES_DIR, installer_type="pkg") background_path = os.path.join(PACKAGES_DIR, "welcome.png") else: # Default to Anaconda's logo if the keys above were not specified diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 72e518ba7..65b2e649b 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -493,7 +493,9 @@ so the user receives updates after each command executed by the installer. Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. -The image is re-sized to 164 x 314 pixels on Windows and 1227 x 600 on macOS. +The image is re-sized to 164 x 314 pixels for EXE installers, 1227 x 600 on macOS, +and for MSI installers it is scaled to fit a 164-pixel wide side panel (maintaining +aspect ratio) with white padding on the right. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `""` (empty string). @@ -585,7 +587,7 @@ shown before the license information, right after the introduction. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence. -If the installer is for Windows and the welcome file type is nsi, +If the installer is for Windows EXE and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process. diff --git a/examples/miniconda/bird.png b/examples/miniconda/bird.png deleted file mode 100644 index 2efe0f30aa3e3de5d76f1ca9165ae8c30bbf0866..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12203 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 + +### Deprecations + +* + +### Docs + +* MSI: Document that text branding options (`welcome_file`, `welcome_text`, `readme_file`, `readme_text`, `conclusion_file`, `conclusion_text`) are not supported. (#1235) + +### Other + +* diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 5e7343edd..6eda836d5 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -2,6 +2,11 @@ import tarfile from pathlib import Path +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + import pytest from constructor.briefcase import ( @@ -27,6 +32,9 @@ "_dists": [], "_platform": cc_platform, "_urls": [], + # Required for auto-generating branding images + "welcome_image_text": "MockInfo", + "header_image_text": "MockInfo", } @@ -1010,3 +1018,70 @@ def test_stage_user_scripts_validates_bat_extension(tmp_path): with pytest.raises(ValueError, match="must be an existing '.bat' file"): payload._stage_user_scripts(pkgs_dir) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize( + "has_user_images", + [ + pytest.param(True, id="user-provided-images"), + pytest.param(False, id="no-user-images"), + ], +) +def test_payload_pyproject_toml_installer_images(tmp_path, has_user_images): + """Test that pyproject.toml contains branding image paths only when user provides them. + + MSI installers only include branding images if user explicitly provides them. + Otherwise, WiX defaults are used. + """ + info = mock_info.copy() + + if has_user_images: + # Use existing test image from examples directory + repo_root = Path(__file__).parent.parent + example_image = ( + repo_root / "examples" / "customized_welcome_conclusion" / "ExtraPagesExampleImg.bmp" + ) + assert example_image.exists(), f"Test image not found: {example_image}" + + info["welcome_image"] = str(example_image) + info["header_image"] = str(example_image) + info["icon_image"] = str(example_image) + + payload = Payload(info) + payload.prepare() + + pyproject_path = payload.root / "pyproject.toml" + assert pyproject_path.is_file() + + with open(pyproject_path, "rb") as f: + config = tomllib.load(f) + + app_config = config["tool"]["briefcase"]["app"] + app_name = list(app_config.keys())[0] + app = app_config[app_name] + + if has_user_images: + # Verify branding paths are present when user provides images + assert "installer_background" in app, "installer_background missing" + assert "installer_banner" in app, "installer_banner missing" + assert "icon" in app, "icon missing from pyproject.toml" + + # Verify paths point to expected files + assert app["installer_background"].endswith("welcome.bmp") + assert app["installer_banner"].endswith("header.bmp") + assert app["icon"].endswith("icon") # No extension for icon + + # Verify the actual image files exist + assert Path(app["installer_background"]).exists() + assert Path(app["installer_banner"]).exists() + assert Path(app["icon"] + ".ico").exists() # Briefcase adds .ico + else: + # No branding images - use WiX defaults + assert "icon" not in app, "icon should not be present without user image" + assert "installer_background" not in app, ( + "installer_background should not be present without user image" + ) + assert "installer_banner" not in app, ( + "installer_banner should not be present without user image" + ) From 07c011a4d50b67308b4459814e67ef28ff6c0aef Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 12 May 2026 10:18:26 -0400 Subject: [PATCH 17/17] Add changes for new menuinst fix --- constructor/briefcase/run_installation.bat | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 1abdc7066..9d718ff3c 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -45,7 +45,8 @@ rem Set CONDA_QUIET primarily to disable the spinners set CONDA_QUIET={{ 0 if add_debug else 1 }} rem Get the name of the install directory -for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +for %%I in ("%INSTDIR%") do set "DISTRIBUTION_NAME=%%~nxI" +set MENUINST_DISTRIBUTION_NAME=%DISTRIBUTION_NAME% set "LOG=%INSTDIR%\install.log" {%- if script_env_variables %} @@ -128,6 +129,15 @@ if "%ALLUSERS%"=="0" ( if errorlevel 1 ( exit /b %errorlevel% ) ) +rem Persist distribution_name to menuinst.toml before installing packages. +rem This ensures the value is captured even if no packages have shortcuts. +rem Must run before conda install to avoid creating shortcuts twice. +"%CONDA_EXE%" menuinst --install -p "%BASE_PATH%" --root-prefix "%BASE_PATH%" +if errorlevel 1 ( exit /b %errorlevel% ) +if not exist "%BASE_PATH%\Menu\menuinst.toml" ( + {{ error_block('Failed to initialize shortcut configuration', 14) }} +) + {%- if has_pre_install %} rem Run user-supplied pre-install script {%- if has_pre_install_desc %}