From 27c6ac03cbc99e527ecb980072fa0038d206a9ed Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:21:06 -0400 Subject: [PATCH 01/14] Prepare 3.16.2 (#1283) --- .authors.yml | 8 ++++---- CHANGELOG.md | 16 ++++++++++++++++ news/1266-add-installer-type-cli | 19 ------------------- news/1273-fix-msi-signing-spaces | 19 ------------------- news/1282-fix-exe-pkgs-cache-path-length | 19 ------------------- 5 files changed, 20 insertions(+), 61 deletions(-) delete mode 100644 news/1266-add-installer-type-cli delete mode 100644 news/1273-fix-msi-signing-spaces delete mode 100644 news/1282-fix-exe-pkgs-cache-path-length diff --git a/.authors.yml b/.authors.yml index d34d4f41f..8b7ece097 100644 --- a/.authors.yml +++ b/.authors.yml @@ -372,7 +372,7 @@ github: deepeshaburse - name: pre-commit-ci[bot] email: 66853113+pre-commit-ci[bot]@users.noreply.github.com - num_commits: 89 + num_commits: 92 first_commit: 2023-05-02 12:01:43 github: pre-commit-ci[bot] - name: Matthias Kuhn @@ -382,7 +382,7 @@ github: m-kuhn - name: dependabot[bot] email: 49699333+dependabot[bot]@users.noreply.github.com - num_commits: 87 + num_commits: 89 github: dependabot[bot] first_commit: 2024-05-07 10:16:05 - name: Julien Jerphanion @@ -405,7 +405,7 @@ github: Jrice1317 alternate_emails: - 100002667+Jrice1317@users.noreply.github.com - num_commits: 6 + num_commits: 7 first_commit: 2025-07-30 14:27:00 - name: dionizijefa github: dionizijefa @@ -420,7 +420,7 @@ email: 34315751+lrandersson@users.noreply.github.com alternate_emails: - randersson@anaconda.com - num_commits: 35 + num_commits: 39 first_commit: 2025-10-21 08:30:00 - name: David Laehnemann github: dlaehnemann diff --git a/CHANGELOG.md b/CHANGELOG.md index 66945b190..47022919c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ [//]: # (current developments) +## 2026-07-07 3.16.2: +### Enhancements + +* Add a `--installer-type` command line option to build only a single installer type (`sh`, `pkg`, `exe`, or `msi`), overriding `installer_type` from the configuration. (#1266) + +### Bug fixes + +* Fix `AzureSignTool` signature verification failing for installer paths that contain spaces. (#1273) +* Improve the EXE installer path length check added in #1228 to also account for the package cache extraction path (`pkgs//`), preventing installations that were allowed to proceed from failing to extract packages when the install path was long. (#1282) + +### Contributors + +* @lrandersson + + + ## 2026-06-22 3.16.1: ### Bug fixes diff --git a/news/1266-add-installer-type-cli b/news/1266-add-installer-type-cli deleted file mode 100644 index 2c22affc8..000000000 --- a/news/1266-add-installer-type-cli +++ /dev/null @@ -1,19 +0,0 @@ -### Enhancements - -* Add a `--installer-type` command line option to build only a single installer type (`sh`, `pkg`, `exe`, or `msi`), overriding `installer_type` from the configuration. (#1266) - -### Bug fixes - -* - -### Deprecations - -* - -### Docs - -* - -### Other - -* diff --git a/news/1273-fix-msi-signing-spaces b/news/1273-fix-msi-signing-spaces deleted file mode 100644 index b8c6d3a9c..000000000 --- a/news/1273-fix-msi-signing-spaces +++ /dev/null @@ -1,19 +0,0 @@ -### Enhancements - -* - -### Bug fixes - -* Fix `AzureSignTool` signature verification failing for installer paths that contain spaces. (#1273) - -### Deprecations - -* - -### Docs - -* - -### Other - -* diff --git a/news/1282-fix-exe-pkgs-cache-path-length b/news/1282-fix-exe-pkgs-cache-path-length deleted file mode 100644 index 125a498e6..000000000 --- a/news/1282-fix-exe-pkgs-cache-path-length +++ /dev/null @@ -1,19 +0,0 @@ -### Enhancements - -* - -### Bug fixes - -* Improve the EXE installer path length check added in #1228 to also account for the package cache extraction path (`pkgs//`), preventing installations that were allowed to proceed from failing to extract packages when the install path was long. (#1282) - -### Deprecations - -* - -### Docs - -* - -### Other - -* From a2a7fa632d0d561a30670002d1b145684bb443c7 Mon Sep 17 00:00:00 2001 From: Maayan Matsliah Date: Wed, 8 Jul 2026 10:16:05 -0400 Subject: [PATCH 02/14] docs: add CONTRIBUTING.md to fix broken link in PR workflow (#1231) * docs: add CONTRIBUTING.md to fix broken link in PR workflow * docs: add news file for PR #1231 * Apply suggestion from @marcoesters --------- Co-authored-by: Marco Esters --- CONTRIBUTING.md | 9 +++++++++ news/1231.md | 2 ++ 2 files changed, 11 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 news/1231.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..0ea81280d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,9 @@ +# Contributing to Constructor + +Thank you for your interest in contributing to Constructor! + +Please refer to the [conda contributing guide](https://docs.conda.io/projects/conda/en/latest/dev-guide/contributing.html) for general guidelines on how to contribute to conda projects. + +For Constructor-specific questions or issues, please open an issue on the [issue tracker](https://github.com/conda/constructor/issues) or start a discussion. + +Please also review our [Code of Conduct](https://github.com/conda/constructor/blob/main/CODE_OF_CONDUCT.md) before contributing. diff --git a/news/1231.md b/news/1231.md new file mode 100644 index 000000000..cf6161629 --- /dev/null +++ b/news/1231.md @@ -0,0 +1,2 @@ +### Docs +* Added `CONTRIBUTING.md` to fix broken link in PR workflow template (#1231). From f4967e831557f457d5216d386c7013fa72a72f6d Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:11:15 -0400 Subject: [PATCH 03/14] MSI: Add support for INSTALLER_UNATTENDED (#1276) * Add support for INSTALLER_UNATTENDED * Add news * Bump briefcase dependency * Bump briefcase in all other files * Enable test for INSTALLER_UNATTENDED (MSI) --- CONSTRUCT.md | 5 +++-- constructor/_schema.py | 5 +++-- constructor/briefcase/pre_uninstall.bat | 3 --- constructor/briefcase/run_installation.bat | 3 --- constructor/data/construct.schema.json | 2 +- dev/extra-requirements-windows.txt | 2 +- docs/source/construct-yaml.md | 5 +++-- examples/scripts/post_install.bat | 3 +-- examples/scripts/pre_install.bat | 3 +-- news/1276-msi-installer-unattended | 19 +++++++++++++++++++ pyproject.toml | 2 +- recipe/meta.yaml | 12 ++++++------ tests/test_briefcase.py | 1 - 13 files changed, 39 insertions(+), 26 deletions(-) create mode 100644 news/1276-msi-installer-unattended diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 83f070738..935061b8c 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -390,8 +390,9 @@ Path to a post-install script. Some notes: `%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. + The same variables as `.exe` installers are available. + `%INSTALLER_TYPE%` is set to `MSI`. + `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`msiexec /qn`), `"0"` otherwise. If necessary, you can activate the installed `base` environment like this: diff --git a/constructor/_schema.py b/constructor/_schema.py index a9c4b0b6e..54564f98f 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -561,8 +561,9 @@ class ConstructorConfiguration(BaseModel): `%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. + The same variables as `.exe` installers are available. + `%INSTALLER_TYPE%` is set to `MSI`. + `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`msiexec /qn`), `"0"` otherwise. If necessary, you can activate the installed `base` environment like this: diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 47b31713f..0d95c8924 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -44,9 +44,6 @@ 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" ( diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 8519fb8ee..d04e0c42b 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -61,9 +61,6 @@ 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 ==== diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index e315b6a6d..da27759dd 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -1053,7 +1053,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.\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\"`", + "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. `%INSTALLER_TYPE%` is set to `MSI`. `%INSTALLER_UNATTENDED%` will be `\"1\"` in silent mode (`msiexec /qn`), `\"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\"`", "title": "Post Install" }, "post_install_desc": { diff --git a/dev/extra-requirements-windows.txt b/dev/extra-requirements-windows.txt index d382e69cb..97ca8ae06 100644 --- a/dev/extra-requirements-windows.txt +++ b/dev/extra-requirements-windows.txt @@ -1,3 +1,3 @@ -conda-forge::briefcase>=0.3.26 +conda-forge::briefcase>=0.4.3 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 83f070738..935061b8c 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -390,8 +390,9 @@ Path to a post-install script. Some notes: `%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. + The same variables as `.exe` installers are available. + `%INSTALLER_TYPE%` is set to `MSI`. + `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`msiexec /qn`), `"0"` otherwise. If necessary, you can activate the installed `base` environment like this: diff --git a/examples/scripts/post_install.bat b/examples/scripts/post_install.bat index 307f2beec..13235a9cf 100644 --- a/examples/scripts/post_install.bat +++ b/examples/scripts/post_install.bat @@ -3,8 +3,7 @@ if not "%INSTALLER_NAME%" == "Scripts" exit 1 if not "%INSTALLER_VER%" == "1.0.0" exit 1 if not "%INSTALLER_PLAT%" == "win-64" exit 1 if not "%INSTALLER_TYPE%" == "EXE" if not "%INSTALLER_TYPE%" == "MSI" exit 1 -rem INSTALLER_UNATTENDED is not yet set by MSI installers (see conda/constructor#1276). -if "%INSTALLER_TYPE%" == "EXE" if not "%INSTALLER_UNATTENDED%" == "1" exit 1 +if not "%INSTALLER_UNATTENDED%" == "1" exit 1 if "%PREFIX%" == "" exit 1 if not "%CUSTOM_VARIABLE_1%" == "FIR$T-CUSTOM_STRING WITH SPACES AND @*! CHARACTERS" exit 1 if not "%CUSTOM_VARIABLE_2%" == "$ECOND-CUSTOM_STRING WITH SPACES AND @*! CHARACTERS" exit 1 diff --git a/examples/scripts/pre_install.bat b/examples/scripts/pre_install.bat index 090aea352..3fd734d2b 100644 --- a/examples/scripts/pre_install.bat +++ b/examples/scripts/pre_install.bat @@ -2,8 +2,7 @@ if not "%INSTALLER_NAME%" == "Scripts" exit 1 if not "%INSTALLER_VER%" == "1.0.0" exit 1 if not "%INSTALLER_PLAT%" == "win-64" exit 1 if not "%INSTALLER_TYPE%" == "EXE" if not "%INSTALLER_TYPE%" == "MSI" exit 1 -rem INSTALLER_UNATTENDED is not yet set by MSI installers (see conda/constructor#1276). -if "%INSTALLER_TYPE%" == "EXE" if not "%INSTALLER_UNATTENDED%" == "1" exit 1 +if not "%INSTALLER_UNATTENDED%" == "1" exit 1 if "%PREFIX%" == "" exit 1 if not "%CUSTOM_VARIABLE_1%" == "FIR$T-CUSTOM_STRING WITH SPACES AND @*! CHARACTERS" exit 1 if not "%CUSTOM_VARIABLE_2%" == "$ECOND-CUSTOM_STRING WITH SPACES AND @*! CHARACTERS" exit 1 diff --git a/news/1276-msi-installer-unattended b/news/1276-msi-installer-unattended new file mode 100644 index 000000000..37aef57fc --- /dev/null +++ b/news/1276-msi-installer-unattended @@ -0,0 +1,19 @@ +### Enhancements + +* + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* Updated docs to clarify the use of `INSTALLER_UNATTENDED` within the context of MSI installers, and updated minimum `briefcase` version to `0.4.3`. (#1278) + +### Other + +* diff --git a/pyproject.toml b/pyproject.toml index b1430540e..f73864772 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "pillow >=3.1 ; platform_system=='Windows' or platform_system=='Darwin'", "jinja2", "jsonschema >=4", - "briefcase >=0.3.26 ; platform_system=='Windows'", + "briefcase >=0.4.3 ; platform_system=='Windows'", "tomli-w >=1.2.0 ; platform_system=='Windows'", ] diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 0a0dea84c..8f9d9ec63 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -28,12 +28,12 @@ requirements: - conda-standalone >=24.11.0 - jinja2 - 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] + - pillow >=3.1 # [win or osx] + - nsis >=3.08 # [win] + - briefcase >=0.4.3 # [win] + - tomli-w >=1.2.0 # [win] + run_constrained: # [unix] + - nsis >=3.08 # [unix] - conda-libmamba-solver !=24.11.0 - pydantic >=2 diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 28c818845..02a6f8d2a 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -919,7 +919,6 @@ def test_render_templates_installer_metadata(template_name): 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") From 4cd5e9578b9bd8489bf6c0ce0179504ec84a3429 Mon Sep 17 00:00:00 2001 From: Robin <34315751+lrandersson@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:22:23 -0400 Subject: [PATCH 04/14] Migrate more test examples to use pytest parametrization (#1278) --- constructor/exceptions.py | 6 + constructor/main.py | 38 +- examples/azure_signtool/construct.yaml | 4 +- tests/test_examples.py | 671 ++++++++++++++----------- 4 files changed, 415 insertions(+), 304 deletions(-) diff --git a/constructor/exceptions.py b/constructor/exceptions.py index f69d13aa2..51b1955ea 100644 --- a/constructor/exceptions.py +++ b/constructor/exceptions.py @@ -57,3 +57,9 @@ def error_body(self): """), ] ) + + +class InvalidInstallerTypeError(Exception): + """Raised when installer_type is invalid for the target platform.""" + + pass diff --git a/constructor/main.py b/constructor/main.py index 4c59b5bce..010ec0854 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -30,6 +30,7 @@ from .construct import parse as construct_parse from .construct import render as construct_render from .construct import verify as construct_verify +from .exceptions import InvalidInstallerTypeError from .fcp import main as fcp_main from .utils import ( StandaloneExe, @@ -46,6 +47,13 @@ def get_installer_type(info: dict): + """Return the installer type(s) to build for the given platform. + + Raises + ------ + InvalidInstallerTypeError + If the installer type is not valid for the target platform. + """ osname, unused_arch = info["_platform"].split("-") os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe", "msi")} @@ -62,28 +70,31 @@ def get_installer_type(info: dict): 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)) + raise InvalidInstallerTypeError( + f"invalid installer type '{t}'; allowed: {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) + raise InvalidInstallerTypeError( + f"invalid installer type '{t}' for {osname}; allowed: {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)) + all_allowed_str = ", ".join(sorted(all_allowed)) + raise InvalidInstallerTypeError( + f"invalid installer type '{itype}'; allowed: {all_allowed_str}" + ) elif itype == "docker": if osname != "linux": - sys.exit( - "Error: Docker features are only supported for Linux target platforms. " + raise InvalidInstallerTypeError( + "Docker features are only supported for Linux target platforms. " "Use --platform linux-ARCH to build a Docker artifact." ) return ("sh", "docker") elif itype not in os_allowed[osname]: - os_allowed = ", ".join(sorted(os_allowed[osname])) - sys.exit( - "Error: invalid installer type '%s' for %s; allowed: %s" % (itype, osname, os_allowed) + os_allowed_str = ", ".join(sorted(os_allowed[osname])) + raise InvalidInstallerTypeError( + f"invalid installer type '{itype}' for {osname}; allowed: {os_allowed_str}" ) else: return (itype,) @@ -234,7 +245,10 @@ def main_build( info["_debug"] = debug if installer_type: info["installer_type"] = installer_type - itypes = get_installer_type(info) + try: + itypes = get_installer_type(info) + except InvalidInstallerTypeError as e: + sys.exit(f"Error: {e}") if "docker" in itypes: if not info.get("docker_base_image"): diff --git a/examples/azure_signtool/construct.yaml b/examples/azure_signtool/construct.yaml index 2ca0395cd..f1750cb3f 100644 --- a/examples/azure_signtool/construct.yaml +++ b/examples/azure_signtool/construct.yaml @@ -3,7 +3,9 @@ name: Signed_AzureSignTool version: 1.0.0 -installer_type: [exe, msi] +installer_type: + - exe + - msi channels: - https://repo.anaconda.com/pkgs/main/ specs: diff --git a/tests/test_examples.py b/tests/test_examples.py index 69cbac68a..8c09949db 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -25,6 +25,7 @@ from constructor.conda_interface import cc_platform from constructor.construct import parse as parse_construct +from constructor.exceptions import InvalidInstallerTypeError from constructor.main import get_installer_type from constructor.utils import ( StandaloneExe, @@ -664,12 +665,18 @@ def _install_dir_for( workspace: Path, config_filename: str, with_spaces: bool, + short: bool = False, ) -> Path: install_dir_prefix = "i n s t a l l" if with_spaces else "install" if installer.suffix == ".pkg" and ON_CI: return Path("~").expanduser() / calculate_install_dir(input_dir / config_filename) elif installer.suffix == ".msi": return calculate_msi_install_path(input_dir / config_filename) + elif short: + # Each parametrized test already has its own workspace, so the installer + # stem/suffix are not needed to keep install dirs distinct. Keeping the + # name short avoids exceeding MAX_PATH on Windows (see from_env_txt). + return workspace / install_dir_prefix else: return workspace / f"{install_dir_prefix}-{installer.stem}-{installer.suffix[1:]}" @@ -681,10 +688,20 @@ def installer_types_for_example( Reuses constructor's own get_installer_type() so the test parametrization matches what the build actually produces. + + Returns a tuple with a single skip marker if the example's installer types + are not valid for the current platform (e.g., Windows-only examples on Linux). """ info = parse_construct(str(example_path / config_filename), platform=cc_platform) info["_platform"] = cc_platform - return get_installer_type(info) + try: + return get_installer_type(info) + except InvalidInstallerTypeError: + return ( + pytest.param( + "skip", marks=pytest.mark.skip(reason="Example not valid for this platform") + ), + ) def create_single_installer( @@ -711,7 +728,9 @@ def create_single_installer( installer = next(output_dir.glob(f"*.{installer_type}"), None) if installer is None: raise FileNotFoundError(f"No .{installer_type} installer found in {output_dir}") - install_dir = _install_dir_for(installer, input_dir, workspace, config_filename, with_spaces) + install_dir = _install_dir_for( + installer, input_dir, workspace, config_filename, with_spaces, short=True + ) return installer, install_dir @@ -809,82 +828,101 @@ def platform_conda_exe(request, tmp_path) -> tuple[str, Path]: return platform, conda_exe -def test_example_customize_controls(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("customize_controls")) +) +def test_example_customize_controls(tmp_path, request, installer_type): input_path = _example_path("customize_controls") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) -def test_example_customized_welcome_conclusion(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", + installer_types_for_example(_example_path("customized_welcome_conclusion")), +) +def test_example_customized_welcome_conclusion(tmp_path, request, installer_type): input_path = _example_path("customized_welcome_conclusion") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) @pytest.mark.parametrize("extra_pages", ("str", "list")) +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("exe_extra_pages")) +) @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") -def test_example_extra_pages_win(tmp_path, request, extra_pages, monkeypatch): +def test_example_extra_pages_win(tmp_path, request, extra_pages, installer_type, monkeypatch): if extra_pages == "list": monkeypatch.setenv("POST_INSTALL_PAGES_LIST", "1") input_path = _example_path("exe_extra_pages") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) -def test_example_extra_envs(tmp_path, request): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("extra_envs"))) +def test_example_extra_envs(tmp_path, request, installer_type): input_path = _example_path("extra_envs") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) - base = (install_dir / "base") if installer.suffix == ".msi" else install_dir - assert "@EXPLICIT" in (base / "conda-meta" / "initial-state.explicit.txt").read_text() - for env in base.glob("envs/*/conda-meta/"): - envtxt = env / "initial-state.explicit.txt" - assert envtxt.exists() - assert "@EXPLICIT" in envtxt.read_text() - - if sys.platform.startswith("win"): - if installer.suffix == ".msi": - _run_uninstaller_msi(installer, install_dir) - else: - _run_uninstaller_exe(install_dir=install_dir) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + base = (install_dir / "base") if installer_type == "msi" else install_dir + assert "@EXPLICIT" in (base / "conda-meta" / "initial-state.explicit.txt").read_text() + for env in base.glob("envs/*/conda-meta/"): + envtxt = env / "initial-state.explicit.txt" + assert envtxt.exists() + assert "@EXPLICIT" in envtxt.read_text() + + if sys.platform.startswith("win"): + if installer_type == "msi": + _run_uninstaller_msi(installer, install_dir) + else: + _run_uninstaller_exe(install_dir=install_dir) -def test_example_extra_files(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("extra_files")) +) +def test_example_extra_files(tmp_path, request, installer_type): 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, - check_sentinels=CONSTRUCTOR_VERBOSE, - check_subprocess=CONSTRUCTOR_VERBOSE, - ) + installer, install_dir = create_single_installer( + input_path, tmp_path, installer_type, with_spaces=True + ) + _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): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("mirrored_channels")) +) +def test_example_mirrored_channels(tmp_path, request, installer_type): input_path = _example_path("mirrored_channels") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) - - expected_condarc = { - "channels": ["conda-forge"], - "mirrored_channels": { - "conda-forge": [ - "https://conda.anaconda.org/conda-forge", - "https://conda.anaconda.org/mirror1", - "https://conda.anaconda.org/mirror2", - ] - }, - } + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + + expected_condarc = { + "channels": ["conda-forge"], + "mirrored_channels": { + "conda-forge": [ + "https://conda.anaconda.org/conda-forge", + "https://conda.anaconda.org/mirror1", + "https://conda.anaconda.org/mirror2", + ] + }, + } - condarc_file = install_dir / ".condarc" - assert condarc_file.exists() + condarc_file = install_dir / ".condarc" + assert condarc_file.exists() - with open(condarc_file) as file: - condarc_data = YAML().load(file) + with open(condarc_file) as file: + condarc_data = YAML().load(file) - assert condarc_data == expected_condarc + assert condarc_data == expected_condarc def _check_miniforge( @@ -978,18 +1016,26 @@ def test_example_miniforge_mamba2(tmp_path, request, installer_type): ) -def test_example_noconda(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", + installer_types_for_example(_example_path("noconda"), "constructor_input.yaml"), +) +def test_example_noconda(tmp_path, request, installer_type): input_path = _example_path("noconda") - for installer, install_dir in create_installer( - input_path, tmp_path, config_filename="constructor_input.yaml", with_spaces=True - ): - _run_installer( - input_path, - installer, - install_dir, - config_filename="constructor_input.yaml", - request=request, - ) + installer, install_dir = create_single_installer( + input_path, + tmp_path, + installer_type, + config_filename="constructor_input.yaml", + with_spaces=True, + ) + _run_installer( + input_path, + installer, + install_dir, + config_filename="constructor_input.yaml", + request=request, + ) @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @@ -1109,10 +1155,13 @@ def test_macos_signing(tmp_path, self_signed_application_certificate_macos): assert validated_signatures == components -def test_example_scripts(tmp_path, request): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("scripts"))) +def test_example_scripts(tmp_path, request, installer_type): input_path = _example_path("scripts") - for installer, install_dir in create_installer(input_path, tmp_path, with_spaces=True): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer( + input_path, tmp_path, installer_type, with_spaces=True + ) + _run_installer(input_path, installer, install_dir, request=request) @pytest.mark.skipif( @@ -1122,54 +1171,56 @@ def test_example_scripts(tmp_path, request): ), reason="menuinst v2 requires conda-standalone>=23.11.0; micromamba is not supported yet", ) -def test_example_shortcuts(tmp_path, request): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("shortcuts"))) +def test_example_shortcuts(tmp_path, request, installer_type): input_path = _example_path("shortcuts") - for installer, install_dir in create_installer(input_path, tmp_path): - # console_shortcut package uses hardcoded "Anaconda3" in its menu definition - distribution_name = "Anaconda3" - if sys.platform == "win32": - # Verify shortcuts don't exist before installation (not leftover from previous run (since EXE/MSI run in a loop)) - for key in ("ProgramData", "AppData"): - start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" - package_1 = start_menu / "Package 1" - if package_1.is_dir(): - assert not (package_1 / "A.lnk").is_file(), "A.lnk exists before installation" - assert not (package_1 / "B.lnk").is_file(), "B.lnk exists before installation" - - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) - # check that the shortcuts are created - if sys.platform == "win32": - for key in ("ProgramData", "AppData"): - start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" - package_1 = start_menu / "Package 1" - console_shortcut_dir = start_menu / f"{distribution_name} (64-bit)" - if package_1.is_dir() and console_shortcut_dir.is_dir(): - assert (package_1 / "A.lnk").is_file() - assert (package_1 / "B.lnk").is_file() - # The shortcut created from the 'base' env - # should not exist because we filtered it out in the YAML - # We do expect one shortcut from 'another_env' - assert not (console_shortcut_dir / "Anaconda Prompt.lnk").is_file() - assert (console_shortcut_dir / "Anaconda Prompt (another_env).lnk").is_file() - break - else: - raise AssertionError(f"No shortcuts found! Expected '{distribution_name} (64-bit)'") - 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": - applications = Path("~/Applications").expanduser() - print("Shortcuts found:", sorted(applications.glob("**/*.app"))) - assert (applications / "A.app").exists() - assert (applications / "B.app").exists() - elif sys.platform == "linux": - applications = Path("~/.local/share/applications").expanduser() - print("Shortcuts found:", sorted(applications.glob("**/*.desktop"))) - assert (applications / "package-1_a.desktop").exists() - assert (applications / "package-1_b.desktop").exists() + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + # console_shortcut package uses hardcoded "Anaconda3" in its menu definition + distribution_name = "Anaconda3" + if sys.platform == "win32": + # Verify shortcuts don't exist before this installation (not leftover from a + # previous per-type run) + for key in ("ProgramData", "AppData"): + start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" + package_1 = start_menu / "Package 1" + if package_1.is_dir(): + assert not (package_1 / "A.lnk").is_file(), "A.lnk exists before installation" + assert not (package_1 / "B.lnk").is_file(), "B.lnk exists before installation" + + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + # check that the shortcuts are created + if sys.platform == "win32": + for key in ("ProgramData", "AppData"): + start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" + package_1 = start_menu / "Package 1" + console_shortcut_dir = start_menu / f"{distribution_name} (64-bit)" + if package_1.is_dir() and console_shortcut_dir.is_dir(): + assert (package_1 / "A.lnk").is_file() + assert (package_1 / "B.lnk").is_file() + # The shortcut created from the 'base' env + # should not exist because we filtered it out in the YAML + # We do expect one shortcut from 'another_env' + assert not (console_shortcut_dir / "Anaconda Prompt.lnk").is_file() + assert (console_shortcut_dir / "Anaconda Prompt (another_env).lnk").is_file() + break + else: + raise AssertionError(f"No shortcuts found! Expected '{distribution_name} (64-bit)'") + if installer_type == "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": + applications = Path("~/Applications").expanduser() + print("Shortcuts found:", sorted(applications.glob("**/*.app"))) + assert (applications / "A.app").exists() + assert (applications / "B.app").exists() + elif sys.platform == "linux": + applications = Path("~/.local/share/applications").expanduser() + print("Shortcuts found:", sorted(applications.glob("**/*.desktop"))) + assert (applications / "package-1_a.desktop").exists() + assert (applications / "package-1_b.desktop").exists() def _verify_windows_signature(installer: Path): @@ -1191,7 +1242,8 @@ def _verify_windows_signature(installer: Path): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") -def test_example_signing(tmp_path, request): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("signing"))) +def test_example_signing(tmp_path, request, installer_type): input_path = _example_path("signing") cert_path = tmp_path / "self-signed-cert.pfx" cert_pwd = "1234" @@ -1200,27 +1252,30 @@ def test_example_signing(tmp_path, request): certificate_in_input_dir = input_path / "certificate.pfx" shutil.copy(str(cert_path), str(certificate_in_input_dir)) request.addfinalizer(lambda: certificate_in_input_dir.unlink()) - for installer, install_dir in create_installer( + installer, install_dir = create_single_installer( input_path, tmp_path, + installer_type, with_spaces=True, 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) + ) + _verify_windows_signature(installer) + _run_installer(input_path, installer, install_dir, request=request) -@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") @pytest.mark.skipif( not shutil.which("azuresigntool") and not os.environ.get("AZURE_SIGNTOOL_PATH"), reason="AzureSignTool not available", ) +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("azure_signtool")) +) @pytest.mark.parametrize( "auth_method", os.environ.get("AZURE_SIGNTOOL_TEST_AUTH_METHODS", "token,secret").split(","), ) -def test_azure_signtool(tmp_path, request, monkeypatch, auth_method): +def test_azure_signtool(tmp_path, request, monkeypatch, auth_method, installer_type): """Test signing installers with AzureSignTool. There are three ways to authenticate with Azure: tokens, secrets, and managed identities. @@ -1241,89 +1296,107 @@ def test_azure_signtool(tmp_path, request, monkeypatch, auth_method): else: pytest.skip(f"Unknown authentication method {auth_method}.") input_path = _example_path("azure_signtool") - for installer, install_dir in create_installer( - input_path, - tmp_path, - ): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) -def test_example_use_channel_remap(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("use_channel_remap")) +) +def test_example_use_channel_remap(tmp_path, request, installer_type): input_path = _example_path("use_channel_remap") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) - p = subprocess.run( - [sys.executable, "-m", "conda", "list", "--prefix", install_dir, "--json"], - capture_output=True, - text=True, - ) - packages = json.loads(p.stdout) - for pkg in packages: - assert pkg["channel"] == "private_repo" + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + p = subprocess.run( + [sys.executable, "-m", "conda", "list", "--prefix", install_dir, "--json"], + capture_output=True, + text=True, + ) + packages = json.loads(p.stdout) + for pkg in packages: + assert pkg["channel"] == "private_repo" -def test_example_from_existing_env(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("from_existing_env")) +) +def test_example_from_existing_env(tmp_path, request, installer_type): input_path = _example_path("from_existing_env") subprocess.check_call( [sys.executable, "-mconda", "create", "-p", tmp_path / "env", "-y", "python"] ) - for installer, install_dir in create_installer( + installer, install_dir = create_single_installer( input_path, tmp_path, + installer_type, CONSTRUCTOR_TEST_EXISTING_ENV=str(tmp_path / "env"), - ): - _run_installer(input_path, installer, install_dir, request=request) - if installer.suffix == ".pkg" and not ON_CI: - return - for pkg in PrefixData(install_dir, interoperability=True).iter_records(): - assert pkg["channel"] != "pypi" + ) + _run_installer(input_path, installer, install_dir, request=request) + if installer_type == "pkg" and not ON_CI: + return + for pkg in PrefixData(install_dir, interoperability=True).iter_records(): + assert pkg["channel"] != "pypi" -def test_example_from_env_txt(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("from_env_txt")) +) +def test_example_from_env_txt(tmp_path, request, installer_type): input_path = _example_path("from_env_txt") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) - if installer.suffix == ".pkg" and not ON_CI: - return - for pkg in PrefixData(install_dir, interoperability=True).iter_records(): - assert pkg["channel"] != "pypi" + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) + if installer_type == "pkg" and not ON_CI: + return + for pkg in PrefixData(install_dir, interoperability=True).iter_records(): + assert pkg["channel"] != "pypi" -def test_example_from_env_yaml(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("from_env_yaml")) +) +def test_example_from_env_yaml(tmp_path, request, installer_type): input_path = _example_path("from_env_yaml") - for installer, install_dir in create_installer(input_path, tmp_path, timeout=600): - _run_installer(input_path, installer, install_dir, request=request) - if installer.suffix == ".pkg" and not ON_CI: - return - for pkg in PrefixData(install_dir, interoperability=True).iter_records(): - assert pkg["channel"] != "pypi" + installer, install_dir = create_single_installer( + input_path, tmp_path, installer_type, timeout=600 + ) + _run_installer(input_path, installer, install_dir, request=request) + if installer_type == "pkg" and not ON_CI: + return + for pkg in PrefixData(install_dir, interoperability=True).iter_records(): + assert pkg["channel"] != "pypi" @pytest.mark.skipif(context.subdir != "linux-64", reason="Linux x64 only") -def test_example_from_explicit(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("from_explicit")) +) +def test_example_from_explicit(tmp_path, request, installer_type): input_path = _example_path("from_explicit") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) - if installer.suffix == ".pkg" and not ON_CI: - return - out = subprocess.check_output( - [sys.executable, "-mconda", "list", "-p", install_dir, "--explicit", "--md5"], - text=True, - ) - expected = (input_path / "explicit_linux-64.txt").read_text() - # Filter comments - out = [line for line in out.split("\n") if not line.startswith("#")] - expected = [line for line in expected.split("\n") if not line.startswith("#")] - assert out == expected + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) + if installer_type == "pkg" and not ON_CI: + return + out = subprocess.check_output( + [sys.executable, "-mconda", "list", "-p", install_dir, "--explicit", "--md5"], + text=True, + ) + expected = (input_path / "explicit_linux-64.txt").read_text() + # Filter comments + out = [line for line in out.split("\n") if not line.startswith("#")] + expected = [line for line in expected.split("\n") if not line.startswith("#")] + assert out == expected -def test_register_envs(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("register_envs")) +) +def test_register_envs(tmp_path, request, installer_type): """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): - _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 + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _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 @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @@ -1396,77 +1469,86 @@ def test_cross_build_example(tmp_path, platform_conda_exe): assert installer.exists() -def test_virtual_specs_failed(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("virtual_specs_failed")) +) +def test_virtual_specs_failed(tmp_path, request, installer_type): """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( - input_path, - installer, - install_dir, - request=request, - check_subprocess=False, - uninstall=False, - ) - # This example is configured to fail due to unsatisfiable virtual specs - if installer.suffix == ".exe": - with pytest.raises(AssertionError, match="Failed to check virtual specs"): - _check_installer_log(install_dir) - continue - elif installer.suffix == ".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 - # The GUI does provide a better message with the min version and so on - # but on the CLI we fail with this one instead - msg = "Cannot install on volume" + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + process = _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=False, + uninstall=False, + ) + # This example is configured to fail due to unsatisfiable virtual specs + if installer_type == "exe": + with pytest.raises(AssertionError, match="Failed to check virtual specs"): + _check_installer_log(install_dir) + return + elif installer_type == "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: - # The shell installer has its own Bash code for __glibc and __osx - # Other virtual specs like __cuda are checked by conda-standalone/micromamba - # and will fail with solver errors like PackagesNotFound etc - msg = "Installer requires" - assert process.returncode != 0 - assert msg in process.stdout + process.stderr + # If log doesn't exist, installation failed before post-install script ran + assert process.returncode != 0 + return + elif installer_type == "pkg": + if not ON_CI: + return + # The GUI does provide a better message with the min version and so on + # but on the CLI we fail with this one instead + msg = "Cannot install on volume" + else: + # The shell installer has its own Bash code for __glibc and __osx + # Other virtual specs like __cuda are checked by conda-standalone/micromamba + # and will fail with solver errors like PackagesNotFound etc + msg = "Installer requires" + assert process.returncode != 0 + assert msg in process.stdout + process.stderr -def test_virtual_specs_ok(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("virtual_specs_ok")) +) +def test_virtual_specs_ok(tmp_path, request, installer_type): input_path = _example_path("virtual_specs_ok") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=True, - uninstall=True, - ) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=True, + ) @pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix only") -def test_virtual_specs_override(tmp_path, request, monkeypatch): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("virtual_specs_failed")) +) +def test_virtual_specs_override(tmp_path, request, monkeypatch, installer_type): input_path = _example_path("virtual_specs_failed") - for installer, install_dir in create_installer(input_path, tmp_path): - if installer.name.endswith(".pkg"): - continue - monkeypatch.setenv("CONDA_OVERRIDE_GLIBC", "20") - monkeypatch.setenv("CONDA_OVERRIDE_OSX", "30") - _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=True, - uninstall=True, - ) + if installer_type == "pkg": + pytest.skip("Override test is not applicable to .pkg installers") + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + monkeypatch.setenv("CONDA_OVERRIDE_GLIBC", "20") + monkeypatch.setenv("CONDA_OVERRIDE_OSX", "30") + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=True, + ) @pytest.mark.skipif(not ON_CI, reason="Run on CI only") @@ -1860,58 +1942,62 @@ def test_uninstallation_standalone( shutil.rmtree(system_rc.parent) -def test_output_files(tmp_path): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("outputs"))) +def test_output_files(tmp_path, installer_type): input_path = _example_path("outputs") - for installer, _ in create_installer(input_path, tmp_path): - files_expected = [ - f"{installer.name}.md5", - f"{installer.name}.sha256", - "info.json", - "licenses.json", - "pkg-list.base.txt", - "pkg-list.py310.txt", - "lockfile.base.txt", - "lockfile.py310.txt", - ] - files_not_expected = [ - "pkg-list.py311.txt", - "lockfile.py311.txt", - ] - root_path = installer.parent - files_exist = [file for file in files_expected if (root_path / file).exists()] - assert sorted(files_exist) == sorted(files_expected) - files_exist = [file for file in files_not_expected if (root_path / file).exists()] - assert files_exist == [] - - # Test that info.json contains serialized objects - info_json = json.loads((root_path / "info.json").read_text()) - assert isinstance(info_json.get("_conda_exe_version"), str) - _all_pkg_records = info_json.get("_all_pkg_records") - assert isinstance(_all_pkg_records, list), "Package record is not a list." - assert len(_all_pkg_records) > 0, "Package record is empty." - assert isinstance(_all_pkg_records[0], dict), "Package record not serialized." - _records = info_json.get("_records") - assert isinstance(_records, list), "Package record for base is not a list." - assert len(_records) > 0, "Package record for base is empty." - assert isinstance(_records[0], dict), "Package record for base is not serialized." - for env, env_info in info_json.get("_extra_envs_info", {}).items(): - _records = env_info.get("_records") - assert isinstance(_records, list), f"Record for {env} is not a list." - assert len(_records) > 0, f"Record for {env} is empty." - assert isinstance(_records[0], dict), f"Record for {env} is not serialized." - - -def test_regressions(tmp_path, request): + installer, _ = create_single_installer(input_path, tmp_path, installer_type) + files_expected = [ + f"{installer.name}.md5", + f"{installer.name}.sha256", + "info.json", + "licenses.json", + "pkg-list.base.txt", + "pkg-list.py310.txt", + "lockfile.base.txt", + "lockfile.py310.txt", + ] + files_not_expected = [ + "pkg-list.py311.txt", + "lockfile.py311.txt", + ] + root_path = installer.parent + files_exist = [file for file in files_expected if (root_path / file).exists()] + assert sorted(files_exist) == sorted(files_expected) + files_exist = [file for file in files_not_expected if (root_path / file).exists()] + assert files_exist == [] + + # Test that info.json contains serialized objects + info_json = json.loads((root_path / "info.json").read_text()) + assert isinstance(info_json.get("_conda_exe_version"), str) + _all_pkg_records = info_json.get("_all_pkg_records") + assert isinstance(_all_pkg_records, list), "Package record is not a list." + assert len(_all_pkg_records) > 0, "Package record is empty." + assert isinstance(_all_pkg_records[0], dict), "Package record not serialized." + _records = info_json.get("_records") + assert isinstance(_records, list), "Package record for base is not a list." + assert len(_records) > 0, "Package record for base is empty." + assert isinstance(_records[0], dict), "Package record for base is not serialized." + for env, env_info in info_json.get("_extra_envs_info", {}).items(): + _records = env_info.get("_records") + assert isinstance(_records, list), f"Record for {env} is not a list." + assert len(_records) > 0, f"Record for {env} is empty." + assert isinstance(_records[0], dict), f"Record for {env} is not serialized." + + +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("regressions")) +) +def test_regressions(tmp_path, request, installer_type): input_path = _example_path("regressions") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=True, - uninstall=True, - ) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=True, + ) @pytest.mark.parametrize("no_registry", (0, 1)) @@ -2138,3 +2224,6 @@ def test_docker_image_build(tmp_path, platform_conda_exe, init): finally: subprocess.run(["docker", "rmi", image_name], check=False) + # Each parametrized run leaves behind buildx cache layers; without pruning, + # disk fills up across the 5 variants and later runs fail with ENOSPC. + subprocess.run(["docker", "builder", "prune", "-f"], check=False) From 45c11c102418971734aa5b64239769880ee89e78 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 18 Jun 2026 14:54:53 -0400 Subject: [PATCH 05/14] Migrate more tests to new structure --- tests/test_examples.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 8c09949db..36960cf15 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1268,6 +1268,7 @@ def test_example_signing(tmp_path, request, installer_type): not shutil.which("azuresigntool") and not os.environ.get("AZURE_SIGNTOOL_PATH"), reason="AzureSignTool not available", ) +@pytest.mark.parametrize("installer_type", ["exe"]) @pytest.mark.parametrize( "installer_type", installer_types_for_example(_example_path("azure_signtool")) ) From 41e4cb918b41f982dbd8c5fdcbcad284edd9e6ca Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 2 Jul 2026 09:42:37 -0400 Subject: [PATCH 06/14] Update parametrization to account for recent change on main --- tests/test_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 36960cf15..03188d098 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1268,7 +1268,7 @@ def test_example_signing(tmp_path, request, installer_type): not shutil.which("azuresigntool") and not os.environ.get("AZURE_SIGNTOOL_PATH"), reason="AzureSignTool not available", ) -@pytest.mark.parametrize("installer_type", ["exe"]) +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("azure_signtool"))) @pytest.mark.parametrize( "installer_type", installer_types_for_example(_example_path("azure_signtool")) ) From 533a538f38a74d61187b8344ae9ae0e9c57eaeb1 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 2 Jul 2026 10:17:32 -0400 Subject: [PATCH 07/14] pre-commit fix --- tests/test_examples.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 03188d098..fc3c14f67 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1268,7 +1268,9 @@ def test_example_signing(tmp_path, request, installer_type): not shutil.which("azuresigntool") and not os.environ.get("AZURE_SIGNTOOL_PATH"), reason="AzureSignTool not available", ) -@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("azure_signtool"))) +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("azure_signtool")) +) @pytest.mark.parametrize( "installer_type", installer_types_for_example(_example_path("azure_signtool")) ) From 5b44f6c186b3dbd3c43d22e13a05fddf64b29ae4 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 2 Jul 2026 10:06:20 -0400 Subject: [PATCH 08/14] Work in progress --- tests/test_examples.py | 412 +++++++++++++++++++++-------------------- 1 file changed, 214 insertions(+), 198 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index fc3c14f67..0f7dac65f 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1039,7 +1039,8 @@ def test_example_noconda(tmp_path, request, installer_type): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") -def test_example_osxpkg(tmp_path, request): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("osxpkg"))) +def test_example_osxpkg(tmp_path, request, installer_type): input_path = _example_path("osxpkg") ownership_test_files_home = [ ".bash_profile", @@ -1058,21 +1059,24 @@ def test_example_osxpkg(tmp_path, request): # getpass.getuser is more reliable than os.getlogin: # https://docs.python.org/3/library/os.html#os.getlogin expected_owner = getpass.getuser() - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) - expected = {} - found = {} - for file in ownership_test_files_home: - if not file.exists(): - continue - expected[file] = expected_owner - found[file] = file.owner() - assert expected == found + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) + expected = {} + found = {} + for file in ownership_test_files_home: + if not file.exists(): + continue + expected[file] = expected_owner + found[file] = file.owner() + assert expected == found @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @pytest.mark.skipif(not shutil.which("xcodebuild"), reason="requires xcodebuild") -def test_example_osxpkg_extra_pages(tmp_path): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("osxpkg_extra_pages")) +) +def test_example_osxpkg_extra_pages(tmp_path, installer_type): try: subprocess.run(["xcodebuild", "--help"], check=True, capture_output=True) except subprocess.CalledProcessError: @@ -1081,7 +1085,7 @@ def test_example_osxpkg_extra_pages(tmp_path): input_path = tmp_path / "input" output_path = tmp_path / "output" shutil.copytree(str(recipe_path), str(input_path)) - installer, install_dir = next(create_installer(input_path, output_path)) + installer, install_dir = create_single_installer(input_path, output_path, installer_type) # expand-full is an undocumented option that extracts all archives, # including binary archives like the PlugIns file cmd = ["pkgutil", "--expand-full", installer, output_path / "expanded"] @@ -1108,7 +1112,10 @@ def test_example_osxpkg_extra_pages(tmp_path): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @pytest.mark.skipif(not shutil.which("xcodebuild"), reason="requires xcodebuild") @pytest.mark.skipif("CI" not in os.environ, reason="CI only") -def test_macos_signing(tmp_path, self_signed_application_certificate_macos): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("osxpkg_extra_pages")) +) +def test_macos_signing(tmp_path, self_signed_application_certificate_macos, installer_type): try: subprocess.run(["xcodebuild", "--help"], check=True, capture_output=True) except subprocess.CalledProcessError: @@ -1119,7 +1126,7 @@ def test_macos_signing(tmp_path, self_signed_application_certificate_macos): with open(input_path / "construct.yaml", "a") as f: f.write(f"notarization_identity_name: {self_signed_application_certificate_macos}\n") output_path = tmp_path / "output" - installer, _ = next(create_installer(input_path, output_path)) + installer, _ = create_single_installer(input_path, output_path, installer_type) # Check component signatures expanded_path = output_path / "expanded" @@ -1404,7 +1411,8 @@ def test_register_envs(tmp_path, request, installer_type): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @pytest.mark.parametrize("domains", ({}, {"enable_anywhere": "false", "enable_localSystem": True})) -def test_pkg_distribution_domains(tmp_path, domains): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("osxpkg"))) +def test_pkg_distribution_domains(tmp_path, domains, installer_type): recipe_path = _example_path("osxpkg") input_path = tmp_path / "input" output_path = tmp_path / "output" @@ -1415,7 +1423,7 @@ def test_pkg_distribution_domains(tmp_path, domains): for key, val in domains.items(): cyml.write(f" {key}: {val}\n") - installer, install_dir = next(create_installer(input_path, output_path)) + installer, install_dir = create_single_installer(input_path, output_path, installer_type) cmd = ["pkgutil", "--expand", installer, output_path / "expanded"] _execute(cmd) domains_file = output_path / "expanded" / "Distribution" @@ -1555,91 +1563,93 @@ def test_virtual_specs_override(tmp_path, request, monkeypatch, installer_type): @pytest.mark.skipif(not ON_CI, reason="Run on CI only") +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("initialization")) +) @pytest.mark.parametrize("method", ("classic", "condabin", True, False)) -def test_initialization(tmp_path, request, monkeypatch, method): +def test_initialization(tmp_path, request, monkeypatch, method, installer_type): request.addfinalizer( lambda: subprocess.run([sys.executable, "-m", "conda", "init", "--reverse"]) ) monkeypatch.setenv("initialization_method", str(method).lower()) input_path = _example_path("initialization") initialize = method is not False - for installer, install_dir in create_installer(input_path, tmp_path): - if installer.suffix == ".sh" and initialize: - options = ["-c"] - elif installer.suffix == ".exe": - # 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": - # MSI uses OPTION_INITIALIZE_CONDA property instead of /AddToPath - options = ["OPTION_INITIALIZE_CONDA=1"] if initialize else [] - else: - options = [] - _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=True, - uninstall=False, - options=options, - ) - if installer.suffix == ".exe": - try: - paths = [] - for root, keyname in ( - (winreg.HKEY_CURRENT_USER, r"Environment"), - ( - winreg.HKEY_LOCAL_MACHINE, - r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", - ), - ): - with winreg.OpenKey(root, keyname, 0, winreg.KEY_QUERY_VALUE) as key: - value = winreg.QueryValueEx(key, "PATH")[0] - paths += value.strip().split(os.pathsep) - if method == "condabin": - assert (str(install_dir / "condabin") in paths) == initialize - else: - assert (str(install_dir) in paths) == initialize - assert (str(install_dir / "Scripts") in paths) == initialize - assert (str(install_dir / "Library" / "bin") in paths) == initialize - - finally: - _run_uninstaller_exe(install_dir, check=True) - elif installer.suffix == ".msi": - try: - prefix = install_dir / "base" - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_QUERY_VALUE - ) as key: - value = winreg.QueryValueEx(key, "Path")[0] - paths = value.strip().split(os.pathsep) - if method == "condabin": - assert (str(prefix / "condabin") in paths) == initialize - else: - assert (str(prefix) in paths) == initialize - assert (str(prefix / "Scripts") in paths) == initialize - assert (str(prefix / "Library" / "bin") in paths) == initialize - finally: - _run_uninstaller_msi(installer, install_dir, check=True) - else: - # GHA's Ubuntu needs interactive, but macOS wants login :shrug: - login_flag = "-i" if sys.platform.startswith("linux") else "-l" - out = subprocess.check_output( - [os.environ.get("SHELL", "bash"), login_flag, "-c", "echo $PATH"], - text=True, - ) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + if installer_type == "sh" and initialize: + options = ["-c"] + elif installer_type == "exe": + # 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_type == "msi": + # MSI uses OPTION_INITIALIZE_CONDA property instead of /AddToPath + options = ["OPTION_INITIALIZE_CONDA=1"] if initialize else [] + else: + options = [] + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=False, + options=options, + ) + if installer_type == "exe": + try: + paths = [] + for root, keyname in ( + (winreg.HKEY_CURRENT_USER, r"Environment"), + ( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", + ), + ): + with winreg.OpenKey(root, keyname, 0, winreg.KEY_QUERY_VALUE) as key: + value = winreg.QueryValueEx(key, "PATH")[0] + paths += value.strip().split(os.pathsep) if method == "condabin": - assert ( - str(install_dir / "condabin") in out.strip().split(os.pathsep) - ) == initialize + assert (str(install_dir / "condabin") in paths) == initialize else: - assert (str(install_dir / "bin") in out.strip().split(os.pathsep)) == initialize + assert (str(install_dir) in paths) == initialize + assert (str(install_dir / "Scripts") in paths) == initialize + assert (str(install_dir / "Library" / "bin") in paths) == initialize + finally: + _run_uninstaller_exe(install_dir, check=True) + elif installer_type == "msi": + try: + prefix = install_dir / "base" + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_QUERY_VALUE + ) as key: + value = winreg.QueryValueEx(key, "Path")[0] + paths = value.strip().split(os.pathsep) + if method == "condabin": + assert (str(prefix / "condabin") in paths) == initialize + else: + assert (str(prefix) in paths) == initialize + assert (str(prefix / "Scripts") in paths) == initialize + assert (str(prefix / "Library" / "bin") in paths) == initialize + finally: + _run_uninstaller_msi(installer, install_dir, check=True) + else: + # GHA's Ubuntu needs interactive, but macOS wants login :shrug: + login_flag = "-i" if sys.platform.startswith("linux") else "-l" + out = subprocess.check_output( + [os.environ.get("SHELL", "bash"), login_flag, "-c", "echo $PATH"], + text=True, + ) + if method == "condabin": + assert (str(install_dir / "condabin") in out.strip().split(os.pathsep)) == initialize + else: + assert (str(install_dir / "bin") in out.strip().split(os.pathsep)) == initialize + +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("miniforge"))) @pytest.mark.skipif(not ON_CI, reason="CI only") @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") -def test_allusers_exe(tmp_path, request): +def test_allusers_exe(tmp_path, request, installer_type): """Ensure that AllUsers installations have the correct permissions for built-in users, domain users, and authenticated users. @@ -1717,78 +1727,78 @@ def _get_dacl_information(filepath: Path) -> dict: return dacl_info 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, - install_dir, - request=request, - check_subprocess=True, - uninstall=False, - options=["/InstallationType=AllUsers"], - ) - - # Test the installation directory - dacl = _get_dacl_information(install_dir) - assert dacl["protected"], "Installation directory must not inherit permissions." - assert len(dacl["permissions"].keys()) > 0, ( - "Directory permission must include either domain or built-in users" - ) - for acct in SDDL_ABBREVIATIONS: - permissions = dacl["permissions"].get(acct) - if permissions is None: - continue - assert not permissions["write_access"], ( - f"Installation directory must not be writable by {acct}." - ) - if acct == "AU": - continue - assert permissions["generic_execute"] and permissions["generic_read"], ( - f"Installation directory must be readable and executable by {acct}" - ) + if installer_type == "msi": + pytest.skip("AllUsers permission test is not applicable to MSI installers yet") + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=False, + options=["/InstallationType=AllUsers"], + ) - # Test all files inside installation directory - incorrect_permissions = { - "protected": [], - "not_inherited": [], - "write_access": {acct: [] for acct in SDDL_ABBREVIATIONS}, - "bad_read_exec": {acct: [] for acct in SDDL_ABBREVIATIONS if acct != "AU"}, - "not_set": [], - } - for file in install_dir.glob("**/*"): - dacl = _get_dacl_information(file) - if dacl["protected"]: - incorrect_permissions["protected"].append(file) - if not dacl["inherited"]: - incorrect_permissions["not_inherited"].append(file) - if len(dacl["permissions"].keys()) == 0: - incorrect_permissions["not_set"].append(file) - continue - for acct, files in incorrect_permissions["write_access"].items(): - permissions = dacl["permissions"].get(acct) - if permissions is not None and permissions["write_access"]: - files.append(file) - for acct, files in incorrect_permissions["bad_read_exec"].items(): - permissions = dacl["permissions"].get(acct) - if permissions is not None and not ( - permissions["generic_execute"] and permissions["generic_read"] - ): - files.append(file) - assert incorrect_permissions["protected"] == [], ( - "Files must not be protected from inheriting permissions" - ) - assert incorrect_permissions["not_inherited"] == [], ( - "Files must inherit from installation directory" + # Test the installation directory + dacl = _get_dacl_information(install_dir) + assert dacl["protected"], "Installation directory must not inherit permissions." + assert len(dacl["permissions"].keys()) > 0, ( + "Directory permission must include either domain or built-in users" + ) + for acct in SDDL_ABBREVIATIONS: + permissions = dacl["permissions"].get(acct) + if permissions is None: + continue + assert not permissions["write_access"], ( + f"Installation directory must not be writable by {acct}." ) - assert incorrect_permissions["not_set"] == [], ( - "File permission must include either domain or built-in users" + if acct == "AU": + continue + assert permissions["generic_execute"] and permissions["generic_read"], ( + f"Installation directory must be readable and executable by {acct}" ) + + # Test all files inside installation directory + incorrect_permissions = { + "protected": [], + "not_inherited": [], + "write_access": {acct: [] for acct in SDDL_ABBREVIATIONS}, + "bad_read_exec": {acct: [] for acct in SDDL_ABBREVIATIONS if acct != "AU"}, + "not_set": [], + } + for file in install_dir.glob("**/*"): + dacl = _get_dacl_information(file) + if dacl["protected"]: + incorrect_permissions["protected"].append(file) + if not dacl["inherited"]: + incorrect_permissions["not_inherited"].append(file) + if len(dacl["permissions"].keys()) == 0: + incorrect_permissions["not_set"].append(file) + continue for acct, files in incorrect_permissions["write_access"].items(): - assert files == [], f"Files must not have write access for {acct}" + permissions = dacl["permissions"].get(acct) + if permissions is not None and permissions["write_access"]: + files.append(file) for acct, files in incorrect_permissions["bad_read_exec"].items(): - assert files == [], f"Files must have generic execute and read for {acct}" + permissions = dacl["permissions"].get(acct) + if permissions is not None and not ( + permissions["generic_execute"] and permissions["generic_read"] + ): + files.append(file) + assert incorrect_permissions["protected"] == [], ( + "Files must not be protected from inheriting permissions" + ) + assert incorrect_permissions["not_inherited"] == [], ( + "Files must inherit from installation directory" + ) + assert incorrect_permissions["not_set"] == [], ( + "File permission must include either domain or built-in users" + ) + for acct, files in incorrect_permissions["write_access"].items(): + assert files == [], f"Files must not have write access for {acct}" + for acct, files in incorrect_permissions["bad_read_exec"].items(): + assert files == [], f"Files must have generic execute and read for {acct}" @pytest.mark.xfail( @@ -1916,7 +1926,7 @@ def test_uninstallation_standalone( with construct_yaml_file.open(mode="w") as file: yaml.dump(construct_yaml, file) - installer, install_dir = next(create_installer(input_path, tmp_path)) + installer, install_dir = create_single_installer(input_path, tmp_path, "exe") monkeypatch.setenv("USERPROFILE", str(tmp_path)) _run_installer( input_path, @@ -2004,43 +2014,46 @@ def test_regressions(tmp_path, request, installer_type): @pytest.mark.parametrize("no_registry", (0, 1)) +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("register_envs")) +) @pytest.mark.skipif(not ON_CI, reason="CI only") @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") -def test_not_in_installed_menu_list_(tmp_path, request, no_registry): +def test_not_in_installed_menu_list_(tmp_path, request, no_registry, installer_type): """Verify the app is in the Installed Apps Menu (or not), based on the NSIS-specific '/NoRegistry' flag. If NoRegistry=0, we expect to find the installer in the Menu, otherwise not. """ input_path = _example_path("register_envs") # The specific example we use here is not important + if installer_type == "msi": + # MSI registration is handled by Windows Installer (msiexec) and cannot + # be disabled. The /NoRegistry flag is NSIS-specific. + pytest.skip("/NoRegistry flag is NSIS-specific; not applicable to MSI") options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] - for installer, install_dir in create_installer(input_path, tmp_path): - if installer.suffix == ".msi": - # MSI registration is handled by Windows Installer (msiexec) and cannot - # be disabled. The /NoRegistry flag is NSIS-specific. - continue - _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=True, - uninstall=False, - options=options, - ) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=False, + 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) + 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" - ) + # 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( @@ -2058,7 +2071,10 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): pytest.param(False, id="without-conflict"), ), ) -def test_frozen_environment(tmp_path, request, has_conflict): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("protected_base")) +) +def test_frozen_environment(tmp_path, request, has_conflict, installer_type): example_path = _example_path("protected_base") input_path = tmp_path / "input" @@ -2077,22 +2093,22 @@ def test_frozen_environment(tmp_path, request, has_conflict): yaml.dump(config, f) with context as c: - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _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 + # MSI installers use a 'base' subdirectory for the conda environment + prefix = install_dir / "base" if installer_type == "msi" else install_dir - expected_frozen = { - prefix / "conda-meta" / "frozen": config["freeze_base"]["conda"], - prefix / "envs" / "env1" / "conda-meta" / "frozen": config["extra_envs"]["env1"][ - "freeze_env" - ]["conda"], - } + expected_frozen = { + 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(): - assert frozen_path.is_file() - assert json.loads(frozen_path.read_text()) == expected_content + for frozen_path, expected_content in expected_frozen.items(): + assert frozen_path.is_file() + assert json.loads(frozen_path.read_text()) == expected_content if has_conflict: assert all( From 12f40307b04376d08860cdf50bc1a1a610ab83a3 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 8 Jul 2026 08:46:57 -0400 Subject: [PATCH 09/14] Update last test example --- tests/test_examples.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 0f7dac65f..1e26f0f86 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1805,7 +1805,10 @@ def _get_dacl_information(filepath: Path) -> dict: CONDA_EXE == StandaloneExe.CONDA and not check_version(CONDA_EXE_VERSION, min_version="24.9.0"), reason="Pre-existing .condarc breaks installation", ) -def test_ignore_condarc_files(tmp_path, monkeypatch, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("customize_controls")) +) +def test_ignore_condarc_files(tmp_path, monkeypatch, request, installer_type): # Create a bogus .condarc file that would result in errors if read. # conda searches inside XDG_CONFIG_HOME on all systems, which is a # a safer directory to monkeypatch, especially on Windows where patching @@ -1833,22 +1836,22 @@ def test_ignore_condarc_files(tmp_path, monkeypatch, request): construct_yaml = input_path / "construct.yaml" content = construct_yaml.read_text() construct_yaml.write_text(content.replace("name: NoCondaOptions", "name: NoCondaRC")) - for installer, install_dir in create_installer(input_path, tmp_path): - proc = _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=True, - uninstall=True, - ) - if CONDA_EXE == StandaloneExe.MAMBA and installer.suffix == ".sh": - # micromamba loads the rc files even for constructor subcommands. - # This cannot be turned off with --no-rc, which causes four errors - # in stderr. If there are more, other micromamba calls have read - # the bogus .condarc file. - # pkg installers unfortunately do not output any errors into the log. - assert proc.stderr.count("Bad conversion of configurable") == 4 + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + proc = _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=True, + ) + if CONDA_EXE == StandaloneExe.MAMBA and installer_type == "sh": + # micromamba loads the rc files even for constructor subcommands. + # This cannot be turned off with --no-rc, which causes four errors + # in stderr. If there are more, other micromamba calls have read + # the bogus .condarc file. + # pkg installers unfortunately do not output any errors into the log. + assert proc.stderr.count("Bad conversion of configurable") == 4 @pytest.mark.skipif( From df2ba8dcb1a3b1b8fef16184dc6e6e4afccbc9bc Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 8 Jul 2026 16:36:44 -0400 Subject: [PATCH 10/14] Remove duplicate parametrize entry --- tests/test_examples.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 1e26f0f86..20d31407a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1278,9 +1278,6 @@ def test_example_signing(tmp_path, request, installer_type): @pytest.mark.parametrize( "installer_type", installer_types_for_example(_example_path("azure_signtool")) ) -@pytest.mark.parametrize( - "installer_type", installer_types_for_example(_example_path("azure_signtool")) -) @pytest.mark.parametrize( "auth_method", os.environ.get("AZURE_SIGNTOOL_TEST_AUTH_METHODS", "token,secret").split(","), From 3095f082d6ef4f0b1b60aa54ca22b03c5ac9e742 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 9 Jul 2026 14:33:47 -0400 Subject: [PATCH 11/14] Removed parametrization that was unnecessary for a couple of macos tests --- tests/test_examples.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 20d31407a..a5fdb1849 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1039,8 +1039,8 @@ def test_example_noconda(tmp_path, request, installer_type): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") -@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("osxpkg"))) -def test_example_osxpkg(tmp_path, request, installer_type): +def test_example_osxpkg(tmp_path, request): + installer_type = "pkg" input_path = _example_path("osxpkg") ownership_test_files_home = [ ".bash_profile", @@ -1073,10 +1073,8 @@ def test_example_osxpkg(tmp_path, request, installer_type): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @pytest.mark.skipif(not shutil.which("xcodebuild"), reason="requires xcodebuild") -@pytest.mark.parametrize( - "installer_type", installer_types_for_example(_example_path("osxpkg_extra_pages")) -) -def test_example_osxpkg_extra_pages(tmp_path, installer_type): +def test_example_osxpkg_extra_pages(tmp_path): + installer_type = "pkg" try: subprocess.run(["xcodebuild", "--help"], check=True, capture_output=True) except subprocess.CalledProcessError: @@ -1112,10 +1110,8 @@ def test_example_osxpkg_extra_pages(tmp_path, installer_type): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @pytest.mark.skipif(not shutil.which("xcodebuild"), reason="requires xcodebuild") @pytest.mark.skipif("CI" not in os.environ, reason="CI only") -@pytest.mark.parametrize( - "installer_type", installer_types_for_example(_example_path("osxpkg_extra_pages")) -) -def test_macos_signing(tmp_path, self_signed_application_certificate_macos, installer_type): +def test_macos_signing(tmp_path, self_signed_application_certificate_macos): + installer_type = "pkg" try: subprocess.run(["xcodebuild", "--help"], check=True, capture_output=True) except subprocess.CalledProcessError: @@ -1408,8 +1404,8 @@ def test_register_envs(tmp_path, request, installer_type): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @pytest.mark.parametrize("domains", ({}, {"enable_anywhere": "false", "enable_localSystem": True})) -@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("osxpkg"))) -def test_pkg_distribution_domains(tmp_path, domains, installer_type): +def test_pkg_distribution_domains(tmp_path, domains): + installer_type = "pkg" recipe_path = _example_path("osxpkg") input_path = tmp_path / "input" output_path = tmp_path / "output" From 7b5d12ba12ab782b509f1b77aedec2e2162646d0 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 10 Jul 2026 09:21:28 -0400 Subject: [PATCH 12/14] Make installer_type optional --- tests/test_examples.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index a5fdb1849..9015718a0 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -707,7 +707,7 @@ def installer_types_for_example( def create_single_installer( input_dir: Path, workspace: Path, - installer_type: str, + installer_type: str | None = None, *, with_spaces=False, timeout=420, @@ -715,7 +715,11 @@ def create_single_installer( extra_constructor_args: Iterable[str] = None, **env_vars, ) -> tuple[Path, Path]: - """Build exactly one installer type; return (installer_path, install_dir).""" + """Build exactly one installer type; return (installer_path, install_dir). + + installer_type=None relies on the example's construct.yaml to build exactly + one installer type; the type is then inferred from the resulting artifact. + """ output_dir = _build_installers( input_dir, workspace, @@ -725,9 +729,15 @@ def create_single_installer( extra_constructor_args=extra_constructor_args, **env_vars, ) - installer = next(output_dir.glob(f"*.{installer_type}"), None) + if installer_type: + installer = next(output_dir.glob(f"*.{installer_type}"), None) + else: + installer = next( + (p for p in output_dir.iterdir() if p.suffix[1:] in ("sh", "pkg", "exe", "msi")), None + ) if installer is None: - raise FileNotFoundError(f"No .{installer_type} installer found in {output_dir}") + wanted = installer_type or "(sh|pkg|exe|msi)" + raise FileNotFoundError(f"No .{wanted} installer found in {output_dir}") install_dir = _install_dir_for( installer, input_dir, workspace, config_filename, with_spaces, short=True ) @@ -1040,7 +1050,6 @@ def test_example_noconda(tmp_path, request, installer_type): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") def test_example_osxpkg(tmp_path, request): - installer_type = "pkg" input_path = _example_path("osxpkg") ownership_test_files_home = [ ".bash_profile", @@ -1074,7 +1083,6 @@ def test_example_osxpkg(tmp_path, request): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @pytest.mark.skipif(not shutil.which("xcodebuild"), reason="requires xcodebuild") def test_example_osxpkg_extra_pages(tmp_path): - installer_type = "pkg" try: subprocess.run(["xcodebuild", "--help"], check=True, capture_output=True) except subprocess.CalledProcessError: @@ -1083,7 +1091,7 @@ def test_example_osxpkg_extra_pages(tmp_path): input_path = tmp_path / "input" output_path = tmp_path / "output" shutil.copytree(str(recipe_path), str(input_path)) - installer, install_dir = create_single_installer(input_path, output_path, installer_type) + installer, install_dir = create_single_installer(input_path, output_path) # expand-full is an undocumented option that extracts all archives, # including binary archives like the PlugIns file cmd = ["pkgutil", "--expand-full", installer, output_path / "expanded"] @@ -1111,7 +1119,6 @@ def test_example_osxpkg_extra_pages(tmp_path): @pytest.mark.skipif(not shutil.which("xcodebuild"), reason="requires xcodebuild") @pytest.mark.skipif("CI" not in os.environ, reason="CI only") def test_macos_signing(tmp_path, self_signed_application_certificate_macos): - installer_type = "pkg" try: subprocess.run(["xcodebuild", "--help"], check=True, capture_output=True) except subprocess.CalledProcessError: @@ -1122,7 +1129,7 @@ def test_macos_signing(tmp_path, self_signed_application_certificate_macos): with open(input_path / "construct.yaml", "a") as f: f.write(f"notarization_identity_name: {self_signed_application_certificate_macos}\n") output_path = tmp_path / "output" - installer, _ = create_single_installer(input_path, output_path, installer_type) + installer, _ = create_single_installer(input_path, output_path) # Check component signatures expanded_path = output_path / "expanded" @@ -1405,7 +1412,6 @@ def test_register_envs(tmp_path, request, installer_type): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @pytest.mark.parametrize("domains", ({}, {"enable_anywhere": "false", "enable_localSystem": True})) def test_pkg_distribution_domains(tmp_path, domains): - installer_type = "pkg" recipe_path = _example_path("osxpkg") input_path = tmp_path / "input" output_path = tmp_path / "output" @@ -1416,7 +1422,7 @@ def test_pkg_distribution_domains(tmp_path, domains): for key, val in domains.items(): cyml.write(f" {key}: {val}\n") - installer, install_dir = create_single_installer(input_path, output_path, installer_type) + installer, install_dir = create_single_installer(input_path, output_path) cmd = ["pkgutil", "--expand", installer, output_path / "expanded"] _execute(cmd) domains_file = output_path / "expanded" / "Distribution" From ded152b5e8ea42b15213c89dd7aa13b6b1699ba0 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 10 Jul 2026 09:26:55 -0400 Subject: [PATCH 13/14] pre-commit fix --- tests/test_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 9015718a0..67900f881 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1068,7 +1068,7 @@ def test_example_osxpkg(tmp_path, request): # getpass.getuser is more reliable than os.getlogin: # https://docs.python.org/3/library/os.html#os.getlogin expected_owner = getpass.getuser() - installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + installer, install_dir = create_single_installer(input_path, tmp_path) _run_installer(input_path, installer, install_dir, request=request) expected = {} found = {} From 49fffd5f6d0abd213ddeb3191e215801ee76b059 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 10 Jul 2026 11:03:06 -0400 Subject: [PATCH 14/14] Disable test for PKG --- tests/test_examples.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 67900f881..c755b455e 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1016,6 +1016,8 @@ def test_example_miniforge(tmp_path, request, installer_type): "installer_type", installer_types_for_example(_example_path("miniforge-mamba2")) ) def test_example_miniforge_mamba2(tmp_path, request, installer_type): + if installer_type == "pkg": + pytest.skip("Miniforge does not produce PKG installers; test is flaky on macOS CI") _check_miniforge( _example_path("miniforge-mamba2"), "Miniforge3-mamba2",