Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 134 additions & 7 deletions constructor/briefcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand All @@ -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),
}
},
}
Expand All @@ -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()
Expand All @@ -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(
Expand Down
106 changes: 105 additions & 1 deletion tests/test_briefcase.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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