diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 32b5679a..08debe96 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -2,12 +2,15 @@ Logic to build installers using Briefcase. """ +from __future__ import annotations + import logging import re -import sys import shutil +import sys import sysconfig import tempfile +from functools import cached_property from pathlib import Path from subprocess import run @@ -86,17 +89,137 @@ def get_bundle_app_name(info, name): return bundle, app_name + def get_license(info): - """ Retrieve the specified license as a dict or return a placeholder if not set. """ + """Retrieve the specified license as a dict or return a placeholder if not set.""" if "license_file" in info: return {"file": info["license_file"]} # We cannot return an empty string because that results in an exception on the briefcase side. return {"text": "TODO"} + +class UninstallBat: + """Represents a pre-uninstall batch script handler for the MSI installers. + This is intended to handle both the user specified 'pre_uninstall' bat script + and also the 'pre_uninstall_script' passed to briefcase by merging them into one. + """ + + def __init__(self, dst: Path, user_script: str | None): + """ + Parameters + ---------- + dst : Path + Destination directory where the generated `pre_uninstall.bat` file + will be written. + user_script : str | None + Optional path (string) to a user-provided `.bat` file configured + via the `pre_uninstall` setting in the installer configuration. + If provided, the file must adhere to the schema. + """ + self._dst = dst + + self.user_script = None + if user_script: + user_script_path = Path(user_script) + if not self.is_bat_file(user_script_path): + raise ValueError( + f"The entry '{user_script}' configured via 'pre_uninstall' " + "must be a path to an existing .bat file." + ) + self.user_script = user_script_path + self._encoding = "utf-8" # TODO: Do we want to use utf-8-sig? + + def is_bat_file(self, file_path: Path) -> bool: + return file_path.is_file() and file_path.suffix.lower() == ".bat" + + def user_script_as_list(self) -> list[str]: + """Read user script.""" + if not self.user_script: + return [] + with open(self.user_script, encoding=self._encoding, newline=None) as f: + return f.read().splitlines() + + def sanitize_input(self, input_list: list[str]) -> list[str]: + """Sanitizes the input, adds a safe exit if necessary. + Assumes the contents of the input represents the contents of a .bat-file. + """ + return ["exit /b" if line.strip().lower() == "exit" else line for line in input_list] + + def create(self) -> None: + """Create the bat script for uninstallation. The script will also include the contents from the file the user + may have specified in the yaml-file via 'pre_uninstall'. + When this function is called, the directory 'dst' specified at class instantiation must exist. + """ + if not self._dst.exists(): + raise FileNotFoundError( + f"The directory {self._dst} must exist in order to create the file." + ) + + header = [ + "@echo off", + "setlocal enableextensions enabledelayedexpansion", + 'set "_HERE=%~dp0"', + "", + "rem === Pre-uninstall script ===", + ] + + user_bat: list[str] = [] + + if self.user_script: + # user_script: list = self.sanitize(self.user_script_as_list()) + # TODO: Embed user script and run it as a subroutine. + # Add error handling using unique labels with 'goto' + user_bat += [ + "rem User supplied a script", + ] + + """ + The goal is to remove most of the files except for the directory '_installer' where + the bat-files are located. This is because the MSI Installer needs to call these bat-files + after 'pre_uninstall_script' is finished, in order to finish with the uninstallation. + """ + main_bat = [ + 'echo "Preparing uninstallation..."', + r'set "INSTDIR=%_HERE%\.."', + 'set "CONDA_EXE=_conda.exe"', + r'"%INSTDIR%\%CONDA_EXE%" menuinst --prefix "%INSTDIR%" --remove', + r'"%INSTDIR%\%CONDA_EXE%" remove -p "%INSTDIR%" --keep-env --all -y', + "if errorlevel 1 (", + " echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%.", + " exit /b %errorlevel%", + ")", + "", + "echo [INFO] %CONDA_EXE% completed successfully.", + r'set "PKGS=%INSTDIR%\pkgs"', + 'if exist "%PKGS%" (', + ' echo [INFO] Removing "%PKGS%" ...', + ' rmdir /s /q "%PKGS%"', + " echo [INFO] Done.", + ")", + "", + r'set "NONADMIN=%INSTDIR%\.nonadmin"', + 'if exist "%NONADMIN%" (', + ' echo [INFO] Removing file "%NONADMIN%" ...', + ' del /f /q "%NONADMIN%"', + ")", + "", + ] + final_lines = header + [""] + user_bat + [""] + main_bat + + with open(self.file_path, "w", encoding=self._encoding, newline="\r\n") as f: + # Python will write \n as \r\n since we have set the 'newline' argument above. + f.writelines(line + "\n" for line in final_lines) + + @cached_property + def file_path(self) -> Path: + """The absolute path to the generated `pre_uninstall.bat` file.""" + return self._dst / "pre_uninstall.bat" + + # 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): +def write_pyproject_toml(tmp_dir, info, uninstall_bat): name, version = get_name_version(info) bundle, app_name = get_bundle_app_name(info, name) @@ -113,6 +236,7 @@ def write_pyproject_toml(tmp_dir, info): "use_full_install_path": False, "install_launcher": False, "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), + "pre_uninstall_script": str(uninstall_bat.file_path), } }, } @@ -124,11 +248,15 @@ def write_pyproject_toml(tmp_dir, info): def create(info, verbose=False): - if sys.platform != 'win32': + if sys.platform != "win32": raise Exception(f"Invalid platform '{sys.platform}'. Only Windows is supported.") tmp_dir = Path(tempfile.mkdtemp()) - write_pyproject_toml(tmp_dir, info) + + uninstall_bat = UninstallBat(tmp_dir, info.get("pre_uninstall", None)) + uninstall_bat.create() + + write_pyproject_toml(tmp_dir, info, uninstall_bat) external_dir = tmp_dir / EXTERNAL_PACKAGE_PATH external_dir.mkdir() @@ -145,8 +273,7 @@ def create(info, verbose=False): briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe" if not briefcase.exists(): raise FileNotFoundError( - f"Dependency 'briefcase' does not seem to be installed.\n" - f"Tried: {briefcase}" + f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}" ) logger.info("Building installer") run( diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 36cb1ce8..eec158cc 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -1,6 +1,9 @@ import pytest +import re +from pathlib import Path +from constructor.briefcase import get_bundle_app_name, get_name_version, UninstallBat -from constructor.briefcase import get_bundle_app_name, get_name_version +THIS_DIR = Path(__file__).parent @pytest.mark.parametrize( @@ -99,3 +102,104 @@ def test_rdi_invalid_package(rdi): def test_name_no_alphanumeric(name): with pytest.raises(ValueError, match=f"Name '{name}' contains no alphanumeric characters"): get_bundle_app_name({}, name) + +@pytest.mark.parametrize( + "test_path", + [ + Path("foo"), # relative path + THIS_DIR, # absolute path of current test file + THIS_DIR / "subdir", # absolute path to subdirectory + Path.cwd() / "foo", # absolute path relative to working dir + ], +) +def test_uninstall_bat_file_path(test_path): + """Test that various directory inputs work as expected.""" + uninstall_bat = UninstallBat(test_path, user_script=None) + assert uninstall_bat.file_path == test_path / 'pre_uninstall.bat' + +@pytest.mark.parametrize("bat_file_name", ['foo.bat', 'bar.BAT']) +def test_bat_file_works(tmp_path, bat_file_name): + """Test that both .bat and .BAT works and is considered a bat file.""" + uninstall_bat = UninstallBat(tmp_path, user_script=None) + with open(uninstall_bat.file_path, 'w') as f: + f.write("Hello") + uninstall_bat.is_bat_file(uninstall_bat.file_path) + +@pytest.mark.parametrize("bat_file_name", ['foo.bat', 'bar.BAT', 'foo.txt', 'bar']) +def test_invalid_user_script(tmp_path, bat_file_name): + """Verify we get an exception if the user specifies an invalid type of pre_uninstall script.""" + expected = f"The entry '{bat_file_name}' configured via 'pre_uninstall' must be a path to an existing .bat file." + with pytest.raises(ValueError, match=expected): + UninstallBat(tmp_path, user_script = bat_file_name) + +def test_sanitize_input_simple(): + """Test sanitize simple list.""" + items = ['foo', 'txt', 'exit'] + ubat = UninstallBat(Path('foo'), user_script=None) + assert ubat.sanitize_input(items) == ['foo', 'txt', 'exit /b'] + +def test_sanitize_input_from_file(tmp_path): + """Test sanitize input, also add a mix of newlines.""" + bat_file = tmp_path / 'test.bat' + with open(bat_file, 'w') as f: + f.writelines(['echo 1\n', 'exit\r\n', 'echo 2\n\n']) + ubat = UninstallBat(tmp_path, user_script=bat_file) + user_script = ubat.user_script_as_list() + sanitized = ubat.sanitize_input(user_script) + assert sanitized == ['echo 1', 'exit /b', '', 'echo 2', ''] + +def test_create_without_dir(tmp_path): + """Verify we get an exception if the target directory does not exist""" + dir_that_doesnt_exist = tmp_path / 'foo' + ubat = UninstallBat(dir_that_doesnt_exist, user_script = None) + expected = f"The directory {dir_that_doesnt_exist} must exist in order to create the file." + with pytest.raises(FileNotFoundError, match=re.escape(expected)): + ubat.create() + +def test_create(tmp_path): + """Verify the contents of the uninstall script looks as expected.""" + # TODO: Since we don't merge the user script right now, we need to account for this + # when it's been added. + + bat_file = tmp_path / 'test.bat' + with open(bat_file, 'w') as f: + f.writelines(['echo 1\n', 'exit\r\n', 'echo 2\n\n']) + ubat = UninstallBat(tmp_path, user_script=bat_file) + ubat.create() + with open(ubat.file_path) as f: + contents = f.readlines() + expected = [ + '@echo off\n', + 'setlocal enableextensions enabledelayedexpansion\n', + 'set "_HERE=%~dp0"\n', + '\n', + 'rem === Pre-uninstall script ===\n', + '\n', + 'rem User supplied a script\n', + '\n', + 'echo "Preparing uninstallation..."\n', + 'set "INSTDIR=%_HERE%\\.."\n', + 'set "CONDA_EXE=_conda.exe"\n', + '"%INSTDIR%\\%CONDA_EXE%" menuinst --prefix "%INSTDIR%" --remove\n', + '"%INSTDIR%\\%CONDA_EXE%" remove -p "%INSTDIR%" --keep-env --all -y\n', + 'if errorlevel 1 (\n', + ' echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%.\n', + ' exit /b %errorlevel%\n', + ')\n', + '\n', + 'echo [INFO] %CONDA_EXE% completed successfully.\n', + 'set "PKGS=%INSTDIR%\\pkgs"\n', + 'if exist "%PKGS%" (\n', + ' echo [INFO] Removing "%PKGS%" ...\n', + ' rmdir /s /q "%PKGS%"\n', + ' echo [INFO] Done.\n', + ')\n', + '\n', + 'set "NONADMIN=%INSTDIR%\\.nonadmin"\n', + 'if exist "%NONADMIN%" (\n', + ' echo [INFO] Removing file "%NONADMIN%" ...\n', + ' del /f /q "%NONADMIN%"\n', + ')\n', + '\n', + ] + assert contents == expected