From b2d20254f98ab1cc33b37832b52c20ba8f733005 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 10 Feb 2026 10:33:19 -0500 Subject: [PATCH 01/12] Update uninstallation scripts --- constructor/briefcase/pre_uninstall.bat | 11 +++++++++++ constructor/briefcase/run_installation.bat | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index eb0bd983e..39bdc6105 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -7,3 +7,14 @@ set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" rem Recreate an empty payload tar. This file was deleted during installation but the rem MSI installer expects it to exist. type nul > "%PAYLOAD_TAR%" + +"%CONDA_EXE%" menuinst --prefix "%BASE_PATH%" --remove +if errorlevel 1 ( + echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. + exit /b %errorlevel% +) +"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%" +if errorlevel 1 ( + echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. + exit /b %errorlevel% +) \ No newline at end of file diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 2eca7b7f7..1c8306829 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -4,7 +4,7 @@ set "PREFIX=%BASE_PATH%" set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" -echo "Unpacking payload..." +echo Unpacking payload... "%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs From 81a75b486a3004d5eb7b09311760cd0b6be01a8f Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 10 Feb 2026 10:33:29 -0500 Subject: [PATCH 02/12] Update pre_uninstall.bat --- constructor/briefcase/pre_uninstall.bat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 39bdc6105..1468b53cd 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -17,4 +17,4 @@ if errorlevel 1 ( if errorlevel 1 ( echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. exit /b %errorlevel% -) \ No newline at end of file +) From 33006367af8555999bc5c7737713e6c9df9c6d1c Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 08:25:00 -0500 Subject: [PATCH 03/12] Add logging --- .github/workflows/main.yml | 2 +- constructor/briefcase.py | 6 ++ constructor/briefcase/pre_uninstall.bat | 46 +++++++++++--- constructor/briefcase/run_installation.bat | 57 ++++++++++++++--- tests/test_examples.py | 71 ++++++++++++++++++++-- 5 files changed, 157 insertions(+), 25 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e9203347a..fc203ceec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -153,7 +153,7 @@ jobs: AZURE_SIGNTOOL_KEY_VAULT_URL: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_URL }} CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS: "${{ runner.temp }}/examples_artifacts" CONSTRUCTOR_SIGNTOOL_PATH: "C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x86/signtool.exe" - CONSTRUCTOR_VERBOSE: 1 + CONSTRUCTOR_VERBOSE: 0 run: | rm -rf coverage.json pytest -vv --cov=constructor --cov-branch tests/test_examples.py diff --git a/constructor/briefcase.py b/constructor/briefcase.py index fa43187c1..433f86197 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -4,6 +4,7 @@ import functools import logging +import os import re import shutil import sys @@ -249,6 +250,10 @@ class Payload: archive_name: str = "payload.tar.gz" conda_exe_name: str = "_conda.exe" + # There might be other ways we want to enable `add_debug_logging`, but it has proven + # very useful at least for the CI environment. + add_debug_logging: bool = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" + @functools.cached_property def root(self) -> Path: """Create root upon first access and cache it.""" @@ -332,6 +337,7 @@ def render_templates(self) -> list[str: TemplateFile]: context: dict[str, str] = { "archive_name": self.archive_name, "conda_exe_name": self.conda_exe_name, + "add_debug": self.add_debug_logging, } # Render the templates now using jinja and the defined context diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 1468b53cd..e596bf8d7 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -1,20 +1,46 @@ -set "INSTDIR=%cd%" +@echo {{ 'on' if add_debug else 'off' }} +setlocal + +rem Assign INSTDIR and normalize the path +set "INSTDIR=%~dp0.." +for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" + set "BASE_PATH=%INSTDIR%\base" set "PREFIX=%BASE_PATH%" set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" +{%- if add_debug %} +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +set "LOG=%TEMP%\%APPNAME%-preuninstall.log" + +echo ==== pre_uninstall start ==== >> "%LOG%" +echo SCRIPT=%~f0 >> "%LOG%" +echo CWD=%CD% >> "%LOG%" +echo INSTDIR=%INSTDIR% >> "%LOG%" +echo BASE_PATH=%BASE_PATH% >> "%LOG%" +echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" +echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" +"%CONDA_EXE%" --version >> "%LOG%" 2>&1 +{%- endif %} + +{%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} +{%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} + +rem Sanity checks +if not exist "%CONDA_EXE%" ( + {% if add_debug %}echo [ERROR] CONDA_EXE not found: "%CONDA_EXE%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 10 +) + rem Recreate an empty payload tar. This file was deleted during installation but the rem MSI installer expects it to exist. type nul > "%PAYLOAD_TAR%" - -"%CONDA_EXE%" menuinst --prefix "%BASE_PATH%" --remove -if errorlevel 1 ( - echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. - exit /b %errorlevel% -) -"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%" if errorlevel 1 ( - echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. - exit /b %errorlevel% + {% if add_debug %}echo [ERROR] Failed to create "%PAYLOAD_TAR%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b %errorlevel% ) + +"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"{{ redir }} +if errorlevel 1 ( {{ dump_and_exit }} ) + +exit /b 0 diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 1c8306829..3bb15a100 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,20 +1,61 @@ -set "INSTDIR=%cd%" +@echo {{ 'on' if add_debug else 'off' }} +setlocal + +rem Assign INSTDIR and normalize the path +set "INSTDIR=%~dp0.." +for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" + set "BASE_PATH=%INSTDIR%\base" set "PREFIX=%BASE_PATH%" set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" -echo Unpacking payload... -"%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" -"%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs - +set CONDA_EXTRA_SAFETY_CHECKS=no set CONDA_PROTECT_FROZEN_ENVS=0 -set "CONDA_ROOT_PREFIX=%BASE_PATH%" set CONDA_SAFETY_CHECKS=disabled -set CONDA_EXTRA_SAFETY_CHECKS=no +set "CONDA_ROOT_PREFIX=%BASE_PATH%" set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" -"%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" +{%- if add_debug %} +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +set "LOG=%TEMP%\%APPNAME%-postinstall.log" + +echo ==== run_installation start ==== >> "%LOG%" +echo SCRIPT=%~f0 >> "%LOG%" +echo CWD=%CD% >> "%LOG%" +echo INSTDIR=%INSTDIR% >> "%LOG%" +echo BASE_PATH=%BASE_PATH% >> "%LOG%" +echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" +echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" +{%- endif %} + +{%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} +{%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} + +rem Sanity checks +if not exist "%CONDA_EXE%" ( + {% if add_debug %}echo [ERROR] CONDA_EXE not found: "%CONDA_EXE%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 10 +) +if not exist "%PAYLOAD_TAR%" ( + {% if add_debug %}echo [ERROR] PAYLOAD_TAR not found: "%PAYLOAD_TAR%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 11 +) + +echo Unpacking payload... +rem "%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%"{{ redir }} +"%CONDA_EXE%" constructor --prefix "%INSTDIR%" --extract-tarball < "%PAYLOAD_TAR%"{{ redir }} +if errorlevel 1 ( {{ dump_and_exit }} ) + +rem "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }} +"%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }} +if errorlevel 1 ( {{ dump_and_exit }} ) + +if not exist "%BASE_PATH%" ( + {% if add_debug %}echo [ERROR] "%BASE_PATH%" not found! >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 12 +) + +"%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"{{ redir }} +if errorlevel 1 ( {{ dump_and_exit }} ) rem Delete the payload to save disk space. rem A truncated placeholder of 0 bytes is recreated during uninstall diff --git a/tests/test_examples.py b/tests/test_examples.py index a8315eb5c..dd4ea8c4a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -339,10 +339,11 @@ def _sentinel_file_checks(example_path, install_dir): def is_admin() -> bool: - try: - return ctypes.windll.shell32.IsUserAnAdmin() - except Exception: - return False + #try: + # return ctypes.windll.shell32.IsUserAnAdmin() + #except Exception: + # return False + return False def calculate_msi_install_path(installer: Path) -> Path: @@ -355,6 +356,7 @@ def calculate_msi_install_path(installer: Path) -> Path: else: local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) root_dir = Path(local_dir) / "Programs" + root_dir.mkdir(parents=True, exist_ok=True) assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors return Path(root_dir) / dir_name @@ -388,8 +390,13 @@ def _run_installer_msi( "/qn", ] - log_path = Path(os.environ.get("TEMP")) / (install_dir.name + ".log") + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") cmd.extend(["/L*V", str(log_path)]) + + post_install_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-postinstall.log") + if post_install_log.exists(): + os.remove(post_install_log) + try: process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) except subprocess.CalledProcessError as e: @@ -401,9 +408,23 @@ def _run_installer_msi( log_path.read_text(encoding="utf-16", errors="replace")[-15000:] ) # last 15k chars print(f"\n=== MSI LOG {log_path} END ===") + if post_install_log.exists(): + print(f"\n=== MSI POST INSTALL LOG {post_install_log} START ===") + print(post_install_log.read_text(encoding="utf-8", errors="replace")) + print(f"\n=== MSI POST INSTALL LOG {log_path} END ===") + else: + print(f"\n(post-install log not found at {post_install_log})\n") raise e if check: print("A check for MSI Installers not yet implemented") + + # Sanity check the installation directory + expected_items = [install_dir / "base", install_dir / "base" / "conda-meta", install_dir / "_conda.exe"] + missing_items = [item for item in expected_items if not item.exists()] + if missing_items: + missing_items_string = "\n".join(missing_items) + raise Exception(f"Sanity check failed, unable to find expected paths: \n{missing_items_string}") + return process @@ -419,7 +440,45 @@ def _run_uninstaller_msi( str(installer), "/qn", ] - process = _execute(cmd, timeout=timeout, check=check) + + # Temporary debug + print("base exists:", (install_dir / "base").exists()) + print("conda-meta exists:", (install_dir / "base" / "conda-meta").exists()) + print("conda-meta history exists:", (install_dir / "base" / "conda-meta" / "history").exists()) + + print(f"\n=== Top-level contents of {install_dir} ===") + for p in sorted(install_dir.iterdir()): + kind = "DIR " if p.is_dir() else "FILE" + print(f"{kind:4} {p.name}") + + # Add MSI verbose log file + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") + cmd.extend(["/L*V", str(log_path)]) + + # Add log file for pre_uninstall.bat + pre_uninstall_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-preuninstall.log") + if pre_uninstall_log.exists(): + os.remove(pre_uninstall_log) + try: + process = _execute(cmd, installer_input = None, timeout=timeout, check=check) + except subprocess.CalledProcessError: + # Dump pre-uninstall log + if pre_uninstall_log.exists(): + print(f"\n=== PRE-UNINSTALL LOG {pre_uninstall_log} START ===") + print(pre_uninstall_log.read_text(encoding="utf-8", errors="replace")[-15000:]) + print(f"=== PRE-UNINSTALL LOG {pre_uninstall_log} END ===\n") + else: + print(f"\n(pre-uninstall log not found at {pre_uninstall_log})\n") + + # Dump MSI uninstall log (often UTF-16) + if log_path.exists(): + print(f"\n=== MSI UNINSTALL LOG {log_path} START ===") + print(log_path.read_text(encoding="utf-16", errors="replace")[-15000:]) # last 15k chars + print(f"=== MSI UNINSTALL LOG {log_path} END ===\n") + else: + print(f"\n(msi uninstall log not found at {log_path})\n") + raise + if check: # TODO: # Check log and if there are remaining files, similar to the exe installers From a813e17cd293414e46a1d5184d5fd2c2ff24dce9 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 09:50:52 -0500 Subject: [PATCH 04/12] Improve log handling for msi tests --- tests/test_examples.py | 196 ++++++++++++++++++++++++++--------------- 1 file changed, 123 insertions(+), 73 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index dd4ea8c4a..bc5ec7246 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,6 +1,5 @@ from __future__ import annotations -import ctypes import getpass import json import os @@ -10,6 +9,7 @@ import time import warnings import xml.etree.ElementTree as ET +from dataclasses import dataclass from datetime import timedelta from functools import cache from pathlib import Path @@ -338,30 +338,99 @@ def _sentinel_file_checks(example_path, install_dir): ) -def is_admin() -> bool: - #try: - # return ctypes.windll.shell32.IsUserAnAdmin() - #except Exception: - # return False - return False - - def calculate_msi_install_path(installer: Path) -> Path: """This is a temporary solution for now since we cannot choose the install location ourselves. Installers are named --Windows-x86_64.msi. """ dir_name = installer.name.replace("-Windows-x86_64.msi", "").replace("-", " ") - if is_admin(): - root_dir = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) - else: - local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) - root_dir = Path(local_dir) / "Programs" - root_dir.mkdir(parents=True, exist_ok=True) + local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) + root_dir = Path(local_dir) / "Programs" + root_dir.mkdir(parents=True, exist_ok=True) assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors return Path(root_dir) / dir_name +def handle_exception_and_error_out( + failure: InstallationFailure | UninstallationFailure, original_exception: BaseException +) -> None: + """Print failure context (including logs) and re-raise with exception chaining.""" + print(failure.read_text()) + raise failure from original_exception + + +def _read_briefcase_log_tail(path: Path, last_digits: int) -> str: + """Helper function to read logs from installers created with briefcase. + The encoding can vary between the different logs. + """ + if not path or not path.exists(): + return f"(log not found: {path})" + + # Try UTF-16 first (MSI logs), fallback to UTF-8 + try: + text = path.read_text(encoding="utf-16", errors="replace") + except UnicodeError: + text = path.read_text(encoding="utf-8", errors="replace") + + return text[last_digits:] + + +@dataclass +class InstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + post_install_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.post_install_log: + parts.append( + f"\n=== MSI LOG POST INSTALL: {self.post_install_log} ===\n" + + _read_briefcase_log_tail(self.post_install_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + +@dataclass +class UninstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + pre_uninstall_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.pre_uninstall_log: + parts.append( + f"\n=== MSI LOG PRE UNINSTALL: {self.pre_uninstall_log} ===\n" + + _read_briefcase_log_tail(self.pre_uninstall_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + def _run_installer_msi( installer: Path, install_dir: Path, @@ -390,40 +459,40 @@ def _run_installer_msi( "/qn", ] + # Prepare logging + post_install_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-postinstall.log") log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") + for log_file in [post_install_log, log_path]: + if log_file.exists(): + log_file.remove() cmd.extend(["/L*V", str(log_path)]) - post_install_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-postinstall.log") - if post_install_log.exists(): - os.remove(post_install_log) - + # Run installer and handle errors/logs if necessary try: process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) except subprocess.CalledProcessError as e: - if log_path.exists(): - # When running on the CI system, it tries to decode a UTF-16 log file as UTF-8, - # therefore we need to specify encoding before printing. - print(f"\n=== MSI LOG {log_path} START ===") - print( - log_path.read_text(encoding="utf-16", errors="replace")[-15000:] - ) # last 15k chars - print(f"\n=== MSI LOG {log_path} END ===") - if post_install_log.exists(): - print(f"\n=== MSI POST INSTALL LOG {post_install_log} START ===") - print(post_install_log.read_text(encoding="utf-8", errors="replace")) - print(f"\n=== MSI POST INSTALL LOG {log_path} END ===") - else: - print(f"\n(post-install log not found at {post_install_log})\n") - raise e - if check: - print("A check for MSI Installers not yet implemented") + handle_exception_and_error_out( + InstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + post_install_log=post_install_log, + ), + original_exception=e, + ) # Sanity check the installation directory - expected_items = [install_dir / "base", install_dir / "base" / "conda-meta", install_dir / "_conda.exe"] + expected_items = [ + install_dir / "base", + install_dir / "base" / "conda-meta", + install_dir / "_conda.exe", + ] missing_items = [item for item in expected_items if not item.exists()] if missing_items: missing_items_string = "\n".join(missing_items) - raise Exception(f"Sanity check failed, unable to find expected paths: \n{missing_items_string}") + raise Exception( + f"Sanity check failed, unable to find expected paths: \n{missing_items_string}" + ) return process @@ -441,50 +510,31 @@ def _run_uninstaller_msi( "/qn", ] - # Temporary debug - print("base exists:", (install_dir / "base").exists()) - print("conda-meta exists:", (install_dir / "base" / "conda-meta").exists()) - print("conda-meta history exists:", (install_dir / "base" / "conda-meta" / "history").exists()) - - print(f"\n=== Top-level contents of {install_dir} ===") - for p in sorted(install_dir.iterdir()): - kind = "DIR " if p.is_dir() else "FILE" - print(f"{kind:4} {p.name}") - - # Add MSI verbose log file + # Prepare logging + pre_uninstall_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-preuninstall.log") log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") + for log_file in [pre_uninstall_log, log_path]: + if log_file.exists(): + log_file.remove() cmd.extend(["/L*V", str(log_path)]) - # Add log file for pre_uninstall.bat - pre_uninstall_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-preuninstall.log") - if pre_uninstall_log.exists(): - os.remove(pre_uninstall_log) try: - process = _execute(cmd, installer_input = None, timeout=timeout, check=check) - except subprocess.CalledProcessError: - # Dump pre-uninstall log - if pre_uninstall_log.exists(): - print(f"\n=== PRE-UNINSTALL LOG {pre_uninstall_log} START ===") - print(pre_uninstall_log.read_text(encoding="utf-8", errors="replace")[-15000:]) - print(f"=== PRE-UNINSTALL LOG {pre_uninstall_log} END ===\n") - else: - print(f"\n(pre-uninstall log not found at {pre_uninstall_log})\n") - - # Dump MSI uninstall log (often UTF-16) - if log_path.exists(): - print(f"\n=== MSI UNINSTALL LOG {log_path} START ===") - print(log_path.read_text(encoding="utf-16", errors="replace")[-15000:]) # last 15k chars - print(f"=== MSI UNINSTALL LOG {log_path} END ===\n") - else: - print(f"\n(msi uninstall log not found at {log_path})\n") - raise + process = _execute(cmd, installer_input=None, timeout=timeout, check=check) + except subprocess.CalledProcessError as e: + handle_exception_and_error_out( + UninstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + post_install_log=pre_uninstall_log, + ), + original_exception=e, + ) if check: # TODO: # Check log and if there are remaining files, similar to the exe installers pass - # This is temporary until uninstallation works fine - shutil.rmtree(str(install_dir), ignore_errors=True) return process From d64e6ab02b20d53bfab20ef15bb2d498ff988504 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 11:58:19 -0500 Subject: [PATCH 05/12] Add register_envs --- constructor/briefcase.py | 1 + constructor/briefcase/run_installation.bat | 1 + examples/register_envs/construct.yaml | 2 +- tests/test_examples.py | 2 -- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 433f86197..bedb2b5be 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -338,6 +338,7 @@ def render_templates(self) -> list[str: TemplateFile]: "archive_name": self.archive_name, "conda_exe_name": self.conda_exe_name, "add_debug": self.add_debug_logging, + "register_envs": str(self.info.get("register_envs", True)).lower(), } # Render the templates now using jinja and the defined context diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 3bb15a100..4ee39d577 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -12,6 +12,7 @@ set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" set CONDA_EXTRA_SAFETY_CHECKS=no set CONDA_PROTECT_FROZEN_ENVS=0 +set CONDA_REGISTER_ENVS={{ register_envs }} set CONDA_SAFETY_CHECKS=disabled set "CONDA_ROOT_PREFIX=%BASE_PATH%" set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" diff --git a/examples/register_envs/construct.yaml b/examples/register_envs/construct.yaml index 31d72c9f6..760757a05 100644 --- a/examples/register_envs/construct.yaml +++ b/examples/register_envs/construct.yaml @@ -3,7 +3,7 @@ name: RegisterEnvs version: 1.0.0 -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ specs: diff --git a/tests/test_examples.py b/tests/test_examples.py index bc5ec7246..c23335fc2 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1160,8 +1160,6 @@ def test_register_envs(tmp_path, request): """Verify that 'register_envs: False' results in the environment not being registered.""" input_path = _example_path("register_envs") for installer, install_dir in create_installer(input_path, tmp_path): - if installer.suffix == ".msi": - raise NotImplementedError("Test for 'register_envs' not yet implemented for MSI") _run_installer(input_path, installer, install_dir, request=request) environments_txt = Path("~/.conda/environments.txt").expanduser().read_text() assert str(install_dir) not in environments_txt From 0f4d68db60ae3fca42cf5ad92c8de0a52bd9f572 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 12:50:50 -0500 Subject: [PATCH 06/12] Fix syntax error with remove --- tests/test_examples.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index c23335fc2..5e4e602e5 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -464,7 +464,7 @@ def _run_installer_msi( log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") for log_file in [post_install_log, log_path]: if log_file.exists(): - log_file.remove() + os.remove(log_file) cmd.extend(["/L*V", str(log_path)]) # Run installer and handle errors/logs if necessary @@ -515,7 +515,7 @@ def _run_uninstaller_msi( log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") for log_file in [pre_uninstall_log, log_path]: if log_file.exists(): - log_file.remove() + os.remove(log_file) cmd.extend(["/L*V", str(log_path)]) try: From d64a807f2df81a7e16724dcd4e053848a4b3ddc2 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 13:51:19 -0500 Subject: [PATCH 07/12] Ensure .exe test not running for MSI --- tests/test_examples.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 5e4e602e5..52894c7ec 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1729,6 +1729,9 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): input_path = _example_path("register_envs") # The specific example we use here is not important options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + # This test is intended for .exe installers only + pass _run_installer( input_path, installer, From c3d51fd583fab231784f6a2d0de7d0cc0dc254a8 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 14:44:21 -0500 Subject: [PATCH 08/12] Properly disable test for MSI --- tests/test_examples.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 52894c7ec..da5b2aee6 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1730,8 +1730,7 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): if installer.suffix == ".msi": - # This test is intended for .exe installers only - pass + pytest.skip("Test is only applicable for NSIS based installers.") _run_installer( input_path, installer, From d9fa4f8ce93230d3cb53d094afe45353ab4d15c4 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 17 Feb 2026 08:56:46 -0500 Subject: [PATCH 09/12] Add more tests and another check errorlevel --- constructor/briefcase/run_installation.bat | 3 +++ tests/test_briefcase.py | 27 +++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 4ee39d577..a059c1121 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -62,3 +62,6 @@ rem Delete the payload to save disk space. rem A truncated placeholder of 0 bytes is recreated during uninstall rem because MSI expects the file to be there to clean the registry. del "%PAYLOAD_TAR%" +if errorlevel 1 ( {{ dump_and_exit }} ) + +exit /b 0 diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index e57911b43..5143d5f80 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -238,10 +238,12 @@ def test_payload_conda_exe(): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") -def test_payload_templates_are_rendered(): +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_payload_templates_are_rendered(debug_logging): """Test that templates are rendered when the payload is prepared.""" info = mock_info.copy() payload = Payload(info) + payload.add_debug_logging = debug_logging rendered_templates = payload.render_templates() assert len(rendered_templates) == 2 # There should be at least two files for f in rendered_templates: @@ -250,3 +252,26 @@ def test_payload_templates_are_rendered(): assert "{{" not in text and "}}" not in text assert "{%" not in text and "%}" not in text assert "{#" not in text and "#}" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_templates_debug_mode(debug_logging): + """Test that debug logging affects template generation.""" + info = mock_info.copy() + payload = Payload(info) + payload.add_debug_logging = debug_logging + rendered_templates = payload.render_templates() + assert len(rendered_templates) == 2 # There should be at least two files + + for f in rendered_templates: + assert f.dst.is_file() + + with open(f.dst) as open_file: + lines = open_file.readlines() + + # Check the first line. + expected = "@echo on\n" if debug_logging else "@echo off\n" + assert lines[0] == expected + # If debug_logging is True, we expect to find %LOG%, otherwise not. + assert debug_logging == any("%LOG%" in line for line in lines) From 1d40e95e29461dfc1b47723ab39e99e72f8297fd Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Feb 2026 09:38:43 -0500 Subject: [PATCH 10/12] Make logging more neat --- constructor/briefcase/pre_uninstall.bat | 15 +++++++++++---- constructor/briefcase/run_installation.bat | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index e596bf8d7..900d12a41 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -1,6 +1,14 @@ @echo {{ 'on' if add_debug else 'off' }} setlocal +{%- macro error_block(message, code) -%} +echo [ERROR] {{ message }} +{%- if add_debug %} +>> "%LOG%" echo [ERROR] {{ message }} +{%- endif %} +exit /b {{ code }} +{%- endmacro -%} + rem Assign INSTDIR and normalize the path set "INSTDIR=%~dp0.." for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" @@ -28,16 +36,15 @@ echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" {%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} {%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} -rem Sanity checks +rem Consistency checks if not exist "%CONDA_EXE%" ( - {% if add_debug %}echo [ERROR] CONDA_EXE not found: "%CONDA_EXE%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 10 + {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} ) - rem Recreate an empty payload tar. This file was deleted during installation but the rem MSI installer expects it to exist. type nul > "%PAYLOAD_TAR%" if errorlevel 1 ( - {% if add_debug %}echo [ERROR] Failed to create "%PAYLOAD_TAR%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b %errorlevel% + {{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }} ) "%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"{{ redir }} diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index a059c1121..85e8bbd4f 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,6 +1,14 @@ @echo {{ 'on' if add_debug else 'off' }} setlocal +{%- macro error_block(message, code) -%} +echo [ERROR] {{ message }} +{%- if add_debug %} +>> "%LOG%" echo [ERROR] {{ message }} +{%- endif %} +exit /b {{ code }} +{%- endmacro -%} + rem Assign INSTDIR and normalize the path set "INSTDIR=%~dp0.." for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" @@ -34,12 +42,12 @@ echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" {%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} {%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} -rem Sanity checks +rem Consistency checks if not exist "%CONDA_EXE%" ( - {% if add_debug %}echo [ERROR] CONDA_EXE not found: "%CONDA_EXE%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 10 + {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} ) if not exist "%PAYLOAD_TAR%" ( - {% if add_debug %}echo [ERROR] PAYLOAD_TAR not found: "%PAYLOAD_TAR%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 11 + {{ error_block('PAYLOAD_TAR not found: "%PAYLOAD_TAR%"', 11) }} ) echo Unpacking payload... @@ -52,7 +60,7 @@ rem "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redi if errorlevel 1 ( {{ dump_and_exit }} ) if not exist "%BASE_PATH%" ( - {% if add_debug %}echo [ERROR] "%BASE_PATH%" not found! >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 12 + {{ error_block('"%BASE_PATH%" not found!', 12) }} ) "%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"{{ redir }} From f39a787ddf829d484108408d18a8ee14a62266ab Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 19 Feb 2026 08:16:45 -0500 Subject: [PATCH 11/12] Removed all use of 'sanity' --- tests/test_examples.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index da5b2aee6..f3e5ed30d 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -347,7 +347,7 @@ def calculate_msi_install_path(installer: Path) -> Path: root_dir = Path(local_dir) / "Programs" root_dir.mkdir(parents=True, exist_ok=True) - assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors + assert root_dir.is_dir() # Consistency check to avoid strange unexpected errors return Path(root_dir) / dir_name @@ -481,19 +481,6 @@ def _run_installer_msi( original_exception=e, ) - # Sanity check the installation directory - expected_items = [ - install_dir / "base", - install_dir / "base" / "conda-meta", - install_dir / "_conda.exe", - ] - missing_items = [item for item in expected_items if not item.exists()] - if missing_items: - missing_items_string = "\n".join(missing_items) - raise Exception( - f"Sanity check failed, unable to find expected paths: \n{missing_items_string}" - ) - return process From 60f85bb3f3034b5739f18f8fff53627d7bd86fc0 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 19 Feb 2026 10:34:32 -0500 Subject: [PATCH 12/12] Updated test for MSI (remove pytest.skip) --- tests/test_examples.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index f3e5ed30d..d12f4602d 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1717,7 +1717,7 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): if installer.suffix == ".msi": - pytest.skip("Test is only applicable for NSIS based installers.") + continue _run_installer( input_path, installer, @@ -1728,17 +1728,17 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): options=options, ) - # Use the installer file name for the registry search - installer_file_name_parts = Path(installer).name.split("-") - name = installer_file_name_parts[0] - version = installer_file_name_parts[1] - partial_name = f"{name} {version}" + # Use the installer file name for the registry search + installer_file_name_parts = Path(installer).name.split("-") + name = installer_file_name_parts[0] + version = installer_file_name_parts[1] + partial_name = f"{name} {version}" - is_in_installed_apps_menu = _is_program_installed(partial_name) - _run_uninstaller_exe(install_dir) + 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" + )