diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e9203347a..2af2cd200 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -121,7 +121,9 @@ jobs: conda create -yqp "${{ runner.temp }}/conda-standalone-nightly" -c conda-canary/label/dev "conda-standalone=*=*single*" echo "CONSTRUCTOR_CONDA_EXE=${{ runner.temp }}/conda-standalone-nightly/standalone_conda/conda.exe" >> $GITHUB_ENV elif [[ "${{ matrix.conda-standalone }}" == "conda-standalone-onedir" ]]; then - conda create -yqp "${{ runner.temp }}/conda-standalone-onedir" -c conda-canary/label/dev "conda-standalone=*=*onedir*" + # Request a version newer than 25.1.1 due to an issue with newer versions getting deprioritized + # because they are built with 'track_features' + conda create -yqp "${{ runner.temp }}/conda-standalone-onedir" -c conda-canary/label/dev "conda-standalone>25.1.1=*onedir*" echo "CONSTRUCTOR_CONDA_EXE=${{ runner.temp }}/conda-standalone-onedir/standalone_conda/conda.exe" >> $GITHUB_ENV else conda activate constructor-dev @@ -153,7 +155,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 53acfecc1..9e3086b8c 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -2,11 +2,14 @@ Logic to build installers using Briefcase. """ +import functools import logging +import os import re import shutil import sys import sysconfig +import tarfile import tempfile from dataclasses import dataclass from pathlib import Path @@ -19,6 +22,7 @@ tomli_w = None # This file is only intended for Windows use from . import preconda +from .jinja import render_template from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist BRIEFCASE_DIR = Path(__file__).parent / "briefcase" @@ -219,37 +223,6 @@ def create_install_options_list(info: dict) -> list[dict]: return options -# Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja -# template allows us to avoid escaping strings everywhere. -def write_pyproject_toml(tmp_dir, info): - name, version = get_name_version(info) - bundle, app_name = get_bundle_app_name(info, name) - - config = { - "project_name": name, - "bundle": bundle, - "version": version, - "license": get_license(info), - "app": { - app_name: { - "formal_name": f"{info['name']} {info['version']}", - "description": "", # Required, but not used in the installer. - "external_package_path": EXTERNAL_PACKAGE_PATH, - "use_full_install_path": False, - "install_launcher": False, - "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), - "install_option": create_install_options_list(info), - } - }, - } - - if "company" in info: - config["author"] = info["company"] - - (tmp_dir / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}})) - logger.debug(f"Created TOML file at: {tmp_dir}") - - @dataclass(frozen=True) class PayloadLayout: """A data class with purpose to contain the payload layout.""" @@ -267,29 +240,135 @@ class Payload: """ info: dict - root: Path | None = None + archive_name: str = "payload.tar.gz" + conda_exe_name: str = "_conda.exe" + + # Enable additional log output during pre/post uninstall/install. + add_debug_logging: bool = False + + @functools.cached_property + def root(self) -> Path: + """Create root upon first access and cache it.""" + return Path(tempfile.mkdtemp(prefix="payload-")) + + def remove(self, *, ignore_errors: bool = True) -> None: + """Remove the root of the payload. + + This function requires some extra care due to the root being a cached property. + """ + root = getattr(self, "root", None) + if root is None: + return + shutil.rmtree(root, ignore_errors=ignore_errors) + # Now we drop the cached value so next access will recreate if desired + try: + delattr(self, "root") + except Exception: + # delattr on a cached_property may raise on some versions / edge cases + pass def prepare(self) -> PayloadLayout: - root = self._ensure_root() - self._write_pyproject(root) + """Prepares the payload.""" + root = self.root layout = self._create_layout(root) + # Render the template files and add them to the necessary config field + self.render_templates() + self.write_pyproject_toml(layout) preconda.write_files(self.info, layout.base) preconda.copy_extra_files(self.info.get("extra_files", []), layout.external) self._stage_dists(layout) self._stage_conda(layout) + + archive_path = self.make_archive(layout.base, layout.external) + if not archive_path.exists(): + raise RuntimeError(f"Unexpected error, failed to create archive: {archive_path}") return layout - def remove(self) -> None: - shutil.rmtree(self.root) + def make_archive(self, src: Path, dst: Path) -> Path: + """Create an archive of the directory 'src'. + The input 'src' must be an existing directory. + If 'dst' does not exist, this function will create it. + The directory specified via 'src' is removed after successful creation. + Returns the path to the archive. + + Example: + payload = Payload(...) + foo = Path('foo') + bar = Path('bar') + targz = payload.make_archive(foo, bar) + This will create the file bar\\ containing 'foo' and all its contents. + + """ + if not src.is_dir(): + raise NotADirectoryError(src) + dst.mkdir(parents=True, exist_ok=True) + + archive_path = dst / self.archive_name + + archive_type = archive_path.suffix[1:] # since suffix starts with '.' + with tarfile.open(archive_path, mode=f"w:{archive_type}", compresslevel=1) as tar: + tar.add(src, arcname=src.name) + + shutil.rmtree(src) + return archive_path + + def render_templates(self) -> list[Path]: + """Render the configured templates under the payload root, + returns a list of Paths to the rendered templates. + """ + templates = { + Path(BRIEFCASE_DIR / "run_installation.bat"): Path(self.root / "run_installation.bat"), + Path(BRIEFCASE_DIR / "pre_uninstall.bat"): Path(self.root / "pre_uninstall.bat"), + } + + context: dict[str, str] = { + "archive_name": self.archive_name, + "conda_exe_name": self.conda_exe_name, + "add_debug": self.add_debug_logging, + "register_envs": str(self.info.get("register_envs", True)).lower(), + } + + # Render the templates now using jinja and the defined context + for src, dst in templates.items(): + if not src.exists(): + raise FileNotFoundError(src) + rendered = render_template(src.read_text(encoding="utf-8"), **context) + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(rendered, encoding="utf-8", newline="\r\n") + + return list(templates.values()) + + def write_pyproject_toml(self, layout: PayloadLayout) -> None: + name, version = get_name_version(self.info) + bundle, app_name = get_bundle_app_name(self.info, name) + + config = { + "project_name": name, + "bundle": bundle, + "version": version, + "license": get_license(self.info), + "app": { + app_name: { + "formal_name": f"{self.info['name']} {self.info['version']}", + "description": "", # Required, but not used in the installer. + "external_package_path": str(layout.external), + "use_full_install_path": False, + "install_launcher": False, + "install_option": create_install_options_list(self.info), + "post_install_script": str(layout.root / "run_installation.bat"), + "pre_uninstall_script": str(layout.root / "pre_uninstall.bat"), + } + }, + } - def _write_pyproject(self, root: Path) -> None: - write_pyproject_toml(root, self.info) + # Add optional content + if "company" in self.info: + config["author"] = self.info["company"] - def _ensure_root(self) -> Path: - if self.root is None: - self.root = Path(tempfile.mkdtemp()) - return self.root + # Finalize + (layout.root / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}})) + logger.debug(f"Created TOML file at: {layout.root}") def _create_layout(self, root: Path) -> PayloadLayout: """The layout is created as: @@ -315,7 +394,7 @@ def _stage_dists(self, layout: PayloadLayout) -> None: shutil.copy(download_dir / filename_dist(dist), layout.pkgs) def _stage_conda(self, layout: PayloadLayout) -> None: - copy_conda_exe(layout.external, "_conda.exe", self.info["_conda_exe"]) + copy_conda_exe(layout.external, self.conda_exe_name, self.info["_conda_exe"]) def create(info, verbose=False): diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat new file mode 100644 index 000000000..0e2eb3889 --- /dev/null +++ b/constructor/briefcase/pre_uninstall.bat @@ -0,0 +1,53 @@ +@echo {{ 'on' if add_debug else 'off' }} +setlocal + +{% macro error_block(message, code) %} +echo [ERROR] {{ message }} +>> "%LOG%" echo [ERROR] {{ message }} +exit /b {{ code }} +{% endmacro %} + +rem Assign INSTDIR and normalize the path +set "INSTDIR=%~dp0.." +for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" + +set "BASE_PATH=%INSTDIR%\base" +set "PREFIX=%BASE_PATH%" +set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" +set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" + +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +set "LOG=%INSTDIR%\uninstall.log" + +{%- if add_debug %} +echo ==== pre_uninstall start ==== >> "%LOG%" +echo SCRIPT=%~f0 >> "%LOG%" +echo CWD=%CD% >> "%LOG%" +echo INSTDIR=%INSTDIR% >> "%LOG%" +echo BASE_PATH=%BASE_PATH% >> "%LOG%" +echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" +echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" +"%CONDA_EXE%" --version >> "%LOG%" 2>&1 +{%- endif %} + +rem Consistency checks +if not exist "%CONDA_EXE%" ( + {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} +) + +rem Recreate an empty payload tar. This file was deleted during installation but the +rem MSI installer expects it to exist. +type nul > "%PAYLOAD_TAR%" +if errorlevel 1 ( + {{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }} +) + +"%CONDA_EXE%" --log-file "%LOG%" constructor uninstall --prefix "%BASE_PATH%" +if errorlevel 1 ( exit /b %errorlevel% ) + +rem If we reached this far without any errors, remove any log-files. +if exist "%INSTDIR%\install.log" del "%INSTDIR%\install.log" +if exist "%INSTDIR%\uninstall.log" del "%INSTDIR%\uninstall.log" + +exit /b 0 diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index ec8cc35b9..11710108d 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,14 +1,68 @@ -set "INSTDIR=%cd%" +@echo {{ 'on' if add_debug else 'off' }} +setlocal + +{% macro error_block(message, code) %} +echo [ERROR] {{ message }} +>> "%LOG%" echo [ERROR] {{ message }} +exit /b {{ code }} +{% endmacro %} + +rem Assign INSTDIR and normalize the path +set "INSTDIR=%~dp0.." +for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" + set "BASE_PATH=%INSTDIR%\base" set "PREFIX=%BASE_PATH%" -set "CONDA_EXE=%INSTDIR%\_conda.exe" - -"%INSTDIR%\_conda.exe" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs +set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" +set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" +set CONDA_EXTRA_SAFETY_CHECKS=no set CONDA_PROTECT_FROZEN_ENVS=0 -set "CONDA_ROOT_PREFIX=%BASE_PATH%" +set CONDA_REGISTER_ENVS={{ register_envs }} set CONDA_SAFETY_CHECKS=disabled -set CONDA_EXTRA_SAFETY_CHECKS=no +set "CONDA_ROOT_PREFIX=%BASE_PATH%" set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" -"%INSTDIR%\_conda.exe" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +set "LOG=%INSTDIR%\install.log" + +{%- if add_debug %} +echo ==== run_installation start ==== >> "%LOG%" +echo SCRIPT=%~f0 >> "%LOG%" +echo CWD=%CD% >> "%LOG%" +echo INSTDIR=%INSTDIR% >> "%LOG%" +echo BASE_PATH=%BASE_PATH% >> "%LOG%" +echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" +echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" +{%- endif %} + +rem Consistency checks +if not exist "%CONDA_EXE%" ( + {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} +) +if not exist "%PAYLOAD_TAR%" ( + {{ error_block('PAYLOAD_TAR not found: "%PAYLOAD_TAR%"', 11) }} +) + +echo Unpacking payload... +"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" +if errorlevel 1 ( exit /b %errorlevel% ) + +"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs +if errorlevel 1 ( exit /b %errorlevel% ) + +if not exist "%BASE_PATH%" ( + {{ error_block('"%BASE_PATH%" not found!', 12) }} +) + +"%CONDA_EXE%" --log-file "%LOG%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" +if errorlevel 1 ( exit /b %errorlevel% ) + +rem Delete the payload to save disk space. +rem A truncated placeholder of 0 bytes is recreated during uninstall +rem because MSI expects the file to be there to clean the registry. +del "%PAYLOAD_TAR%" +if errorlevel 1 ( exit /b %errorlevel% ) + +exit /b 0 diff --git a/examples/register_envs/construct.yaml b/examples/register_envs/construct.yaml index 31d72c9f6..760757a05 100644 --- a/examples/register_envs/construct.yaml +++ b/examples/register_envs/construct.yaml @@ -3,7 +3,7 @@ name: RegisterEnvs version: 1.0.0 -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ specs: diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index dfb734231..c521cdf33 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -1,4 +1,5 @@ import sys +import tarfile from pathlib import Path import pytest @@ -154,6 +155,7 @@ def test_name_no_alphanumeric(name): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_prepare_payload(): + """Test preparing the payload.""" info = mock_info.copy() payload = Payload(info) payload.prepare() @@ -162,6 +164,9 @@ def test_prepare_payload(): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_payload_layout(): + """Test the layout of the payload and verify that archiving + parts of the payload works as expected. + """ info = mock_info.copy() payload = Payload(info) prepared_payload = payload.prepare() @@ -170,14 +175,39 @@ def test_payload_layout(): assert external_dir.is_dir() and external_dir == prepared_payload.external base_dir = prepared_payload.root / "external" / "base" - assert base_dir.is_dir() and base_dir == prepared_payload.base - pkgs_dir = prepared_payload.root / "external" / "base" / "pkgs" - assert pkgs_dir.is_dir() and pkgs_dir == prepared_payload.pkgs + archive_path = external_dir / payload.archive_name + # Since archiving removes the directory 'base_dir' and its contents + assert not base_dir.exists() + assert not pkgs_dir.exists() + assert archive_path.exists() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_archive(tmp_path: Path): + """Test that the payload archive function works as expected.""" + info = mock_info.copy() + payload = Payload(info) + + foo_dir = tmp_path / "foo" + foo_dir.mkdir() + + expected_text = "some test text" + hello_file = foo_dir / "hello.txt" + hello_file.write_text(expected_text, encoding="utf-8") + + archive_path = payload.make_archive(foo_dir, tmp_path) + + with tarfile.open(archive_path, mode="r:gz") as tar: + member = tar.getmember("foo/hello.txt") + f = tar.extractfile(member) + assert f is not None + assert f.read().decode("utf-8") == expected_text @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_payload_remove(): + """Test removing the payload.""" info = mock_info.copy() payload = Payload(info) prepared_payload = payload.prepare() @@ -189,6 +219,7 @@ def test_payload_remove(): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_payload_pyproject_toml(): + """Test that the pyproject.toml file is created when the payload is prepared.""" info = mock_info.copy() payload = Payload(info) prepared_payload = payload.prepare() @@ -198,8 +229,47 @@ def test_payload_pyproject_toml(): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_payload_conda_exe(): + """Test that conda-standalone is prepared.""" info = mock_info.copy() payload = Payload(info) prepared_payload = payload.prepare() conda_exe = prepared_payload.external / "_conda.exe" assert conda_exe.is_file() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_payload_templates_are_rendered(debug_logging): + """Test that templates are rendered when the payload is prepared.""" + info = mock_info.copy() + payload = Payload(info) + payload.add_debug_logging = debug_logging + rendered_templates = payload.render_templates() + assert len(rendered_templates) == 2 # There should be at least two files + for f in rendered_templates: + assert f.is_file() + text = f.read_text(encoding="utf-8") + assert "{{" not in text and "}}" not in text + assert "{%" not in text and "%}" not in text + assert "{#" not in text and "#}" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_templates_debug_mode(debug_logging): + """Test that debug logging affects template generation.""" + info = mock_info.copy() + payload = Payload(info) + payload.add_debug_logging = debug_logging + rendered_templates = payload.render_templates() + assert len(rendered_templates) == 2 # There should be at least two files + + for f in rendered_templates: + assert f.is_file() + + with open(f) as open_file: + lines = open_file.readlines() + + # Check the first line. + expected = "@echo on\n" if debug_logging else "@echo off\n" + assert lines[0] == expected diff --git a/tests/test_examples.py b/tests/test_examples.py index a8315eb5c..a3d87b115 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,28 +338,99 @@ def _sentinel_file_checks(example_path, install_dir): ) -def is_admin() -> bool: - try: - return ctypes.windll.shell32.IsUserAnAdmin() - except Exception: - return False - - def calculate_msi_install_path(installer: Path) -> Path: """This is a temporary solution for now since we cannot choose the install location ourselves. Installers are named --Windows-x86_64.msi. """ dir_name = installer.name.replace("-Windows-x86_64.msi", "").replace("-", " ") - if is_admin(): - root_dir = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) - else: - local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) - root_dir = Path(local_dir) / "Programs" + local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) + root_dir = Path(local_dir) / "Programs" + root_dir.mkdir(parents=True, exist_ok=True) - assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors + assert root_dir.is_dir() # Consistency check to avoid strange unexpected errors return Path(root_dir) / dir_name +def handle_exception_and_error_out( + failure: InstallationFailure | UninstallationFailure, original_exception: BaseException +) -> None: + """Print failure context (including logs) and re-raise with exception chaining.""" + print(failure.read_text()) + raise failure from original_exception + + +def _read_briefcase_log_tail(path: Path, last_digits: int) -> str: + """Helper function to read logs from installers created with briefcase. + The encoding can vary between the different logs. + """ + if not path or not path.exists(): + return f"(log not found: {path})" + + # Try UTF-16 first (MSI logs), fallback to UTF-8 + try: + text = path.read_text(encoding="utf-16", errors="replace") + except UnicodeError: + text = path.read_text(encoding="utf-8", errors="replace") + + return text[last_digits:] + + +@dataclass +class InstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + post_install_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.post_install_log: + parts.append( + f"\n=== MSI LOG POST INSTALL: {self.post_install_log} ===\n" + + _read_briefcase_log_tail(self.post_install_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + +@dataclass +class UninstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + pre_uninstall_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.pre_uninstall_log: + parts.append( + f"\n=== MSI LOG PRE UNINSTALL: {self.pre_uninstall_log} ===\n" + + _read_briefcase_log_tail(self.pre_uninstall_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + def _run_installer_msi( installer: Path, install_dir: Path, @@ -388,22 +459,28 @@ def _run_installer_msi( "/qn", ] - log_path = Path(os.environ.get("TEMP")) / (install_dir.name + ".log") + # Prepare logging + post_install_log = install_dir / "install.log" + # Logging from MSI engine is handled separately + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") + if log_path.exists(): + os.remove(log_path) cmd.extend(["/L*V", str(log_path)]) + + # Run installer and handle errors/logs if necessary try: process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) except subprocess.CalledProcessError as e: - if log_path.exists(): - # When running on the CI system, it tries to decode a UTF-16 log file as UTF-8, - # therefore we need to specify encoding before printing. - print(f"\n=== MSI LOG {log_path} START ===") - print( - log_path.read_text(encoding="utf-16", errors="replace")[-15000:] - ) # last 15k chars - print(f"\n=== MSI LOG {log_path} END ===") - raise e - if check: - print("A check for MSI Installers not yet implemented") + handle_exception_and_error_out( + InstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + post_install_log=post_install_log, + ), + original_exception=e, + ) + return process @@ -419,13 +496,32 @@ def _run_uninstaller_msi( str(installer), "/qn", ] - process = _execute(cmd, timeout=timeout, check=check) + + # Prepare logging + pre_uninstall_log = install_dir / "uninstall.log" + # Logging from MSI engine is handled separately + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") + if log_path.exists(): + os.remove(log_path) + cmd.extend(["/L*V", str(log_path)]) + + try: + process = _execute(cmd, installer_input=None, timeout=timeout, check=check) + except subprocess.CalledProcessError as e: + handle_exception_and_error_out( + UninstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + post_install_log=pre_uninstall_log, + ), + original_exception=e, + ) + if check: # TODO: # Check log and if there are remaining files, similar to the exe installers pass - # This is temporary until uninstallation works fine - shutil.rmtree(str(install_dir), ignore_errors=True) return process @@ -1051,8 +1147,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 @@ -1622,6 +1716,8 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): input_path = _example_path("register_envs") # The specific example we use here is not important options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + continue _run_installer( input_path, installer, @@ -1632,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" + )