Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9286bb4
Dummy commit
lrandersson Nov 12, 2025
92735f0
Temporarily disable verbosity
lrandersson Nov 12, 2025
efe3ef3
Set appropriate version, raise error on missing executable
lrandersson Nov 12, 2025
0d0063b
Update github workflow and briefcase test integration
lrandersson Nov 12, 2025
a32f560
Fixed typo in os-function
lrandersson Nov 12, 2025
6a23b55
Add missing argument
lrandersson Nov 12, 2025
3d11d7f
Change version field to 1.0.0 instead of X
lrandersson Nov 13, 2025
fc95186
Add initial test changes
lrandersson Nov 13, 2025
2aeddae
More fixes
lrandersson Nov 13, 2025
946f89a
Add dependency and env variable for github workflow
lrandersson Nov 14, 2025
9be1852
Some more fixes
lrandersson Nov 14, 2025
6b94025
Fix spelling error
lrandersson Nov 14, 2025
5d3c3cd
Undo earlier change sine it wasn't the correct way
lrandersson Nov 14, 2025
50a47b9
Initial work for pre_uninstall
lrandersson Nov 19, 2025
9663143
Add tests for initial setup
lrandersson Nov 20, 2025
aad10ac
A few fixes
lrandersson Nov 20, 2025
13e2cbe
Add batfile as a docstring right now
lrandersson Nov 20, 2025
b7898b5
Pre-commit and add script
lrandersson Nov 20, 2025
9a69231
Add working uninstallation
lrandersson Nov 21, 2025
3dc6e48
Update test and add missing comma-sign
lrandersson Nov 21, 2025
4e6606a
Improve documentation and fixing language
lrandersson Nov 24, 2025
702f8ef
Fix code clarity and removal of 'envs' dir
lrandersson Nov 24, 2025
80851eb
Add separate clean up of pkgs
lrandersson Nov 25, 2025
ef398ec
Fix formatting
lrandersson Nov 25, 2025
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
13 changes: 13 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ jobs:
run: conda list
- name: conda config
run: conda config --show-sources

- name: Checkout Briefcase (pinned)
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: beeware/briefcase
ref: efb0e08c2a9ce72dcebd0a62c12f5662fd5eb0e0 # this is a commit with latest necessary changes on the main branch
path: briefcase
fetch-depth: 1

- name: Install Briefcase (editable)
run: |
pip install -e briefcase
- name: Run unit tests
run: |
pytest -vv --cov=constructor --cov-branch tests/ -m "not examples"
Expand All @@ -153,6 +165,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
run: |
rm -rf coverage.json
pytest -vv --cov=constructor --cov-branch tests/test_examples.py
Expand Down
213 changes: 208 additions & 5 deletions constructor/briefcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
Logic to build installers using Briefcase.
"""

from __future__ import annotations

import logging
import re
import shutil
import sys
import sysconfig
import tempfile
from functools import cached_property
from pathlib import Path
from subprocess import run

Expand All @@ -22,8 +26,7 @@


def get_name_version(info):
name = info["name"]
if not name:
if not (name := info.get("name")):
raise ValueError("Name is empty")

# Briefcase requires version numbers to be in the canonical Python format, and some
Expand Down Expand Up @@ -87,17 +90,205 @@ 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."""

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:
Copy link
Collaborator Author

@lrandersson lrandersson Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach is a bit different from how we currently handle run_installation.bat. I'd like to hear some feedback/thoughts on what you (the reviewer) thinks.

I went with this class-based approach because I think it's better in terms of future maintenance and also makes unit testing very convenient, and easy to read. All the code for it is in one place and easy to follow. I think most of constructor is written in a "scripted" approach and I think that works for small projects, but for larger projects it can quickly become cumbersome. I would argue the project could benefit from a more object-oriented approach in general.
Moreover, simplifying the testing would be a good win for the project. One of the major pain points in constructor is the difficulty of running the tests locally, and to do it fast and "as close to production" as possible.

If we don't wanna go with this approach, it wouldn't take a lot of effort to simply add a pre_uninstall.bat and change the implementation, but I would propose we add something similar also for run_installation.bat (I'm happy to do that update if we go with it).

"""Represents a pre-uninstall batch file handler for the MSI installers.
This class handles both an optional user script together with a default uininstallation,
by creating one, merged batch file.
The created file is designed for the briefcase specific config entry 'pre_uninstall_script'.
"""

def __init__(self, dst: Path, user_script: str | None):
"""
Parameters
----------
dst : Path
Destination directory where the file `pre_uninstall.bat` will be written.
user_script : str | None
Optional path (string) to an existing user-provided batch file.
If provided, the file must adhere to the schema, in particular,
as 'pre_uninstall' is defined.
"""
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 into a list."""
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 batch file.
"""
return ["exit /b" if line.strip().lower() == "exit" else line for line in input_list]

def _clean_up_pkgs_dir(self) -> list[str]:
"""This method returns a list of strings that represents the code
necessary to clean up the 'pkgs' directory. Unfortunately we have to follow
a very strict set of instructions in order to ensure that we clean up the directory
such that the MSI installer can continue by removing related Windows Registry Entries.
The current behavior is that the MSI installer registers each directory from `pkgs`
if it contains the subdirectory 'info'. Therefore we need to clean up the 'pkgs' directory
to match that.
"""
return [
r'set "PKGS=%INSTDIR%\pkgs"',
# Sanity check
'if not exist "%PKGS%" (',
' echo [WARNING] "%PKGS%" does not exist.',
" exit /b 0",
")",
"",
# Delete plain files directly under pkgs
'pushd "%PKGS%" || (echo [ERROR] cannot enter "%PKGS%" & exit /b 1)',
"del /f /q * >nul 2>&1",
"",
# For each subdirectory, delete everything except "info"
"for /d %%D in (*) do (",
# Try enter the child directory; if it fails, skip it
' pushd "%%~fD" >nul 2>&1',
" if errorlevel 1 (",
' echo [WARNING] Could not enter "%%~fD"',
" ) else (",
# Delete files in this child dir
" del /f /q * >nul 2>&1",
# Delete all directories except "info"
# If we encounter "info", delete its contents
" for /d %%S in (*) do (",
' if /i "%%S"=="info" (',
' pushd "%%~fS" >nul 2>&1',
" if not errorlevel 1 (",
" del /f /q * >nul 2>&1",
' for /d %%I in (*) do rmdir /s /q "%%I"',
" popd",
" ) else (",
' echo [WARNING] Could not enter "%%~fS"',
" )",
" ) else (",
' rmdir /s /q "%%S"',
" )",
" )",
" popd",
" )",
")",
"",
"popd",
"",
]

def create(self) -> None:
"""Create the batch file. If a `user_script` was defined at class instantiation, the batch file
will also include the contents from that file.
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 files such as the
directory named '_installer' which the MSI installer expects to exist when
it performs the uninstallation.
"""
main_bat = [ # Prep
"echo Preparing uninstallation...",
r'set "INSTDIR=%_HERE%\.."',
'set "CONDA_EXE=_conda.exe"',
]
main_bat += [ # Removal of 'envs' directory
r'set "ENVS_DIR=%INSTDIR%\envs"',
'if exist "%ENVS_DIR%" (',
' echo [INFO] Removing "%ENVS_DIR%" ...',
r' "%INSTDIR%\%CONDA_EXE%" constructor uninstall --prefix "%INSTDIR%\envs"',
" echo [INFO] Done.",
")",
]
main_bat += [ # Removal of Start Menus and base env
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%",
")",
"",
]
# Removal of pkgs
main_bat += self._clean_up_pkgs_dir()

main_bat += [ # Removal of .nonadmin
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)

config = {
"project_name": name,
"bundle": bundle,
"version": version,
"license": ({"file": info["license_file"]} if "license_file" in info else {"text": ""}),
"license": get_license(info),
"app": {
app_name: {
"formal_name": f"{info['name']} {info['version']}",
Expand All @@ -106,6 +297,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 @@ -117,8 +309,15 @@ def write_pyproject_toml(tmp_dir, info):


def create(info, verbose=False):
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 @@ -133,6 +332,10 @@ def create(info, verbose=False):
copy_conda_exe(external_dir, "_conda.exe", info["_conda_exe"])

briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe"
if not briefcase.exists():
raise FileNotFoundError(
f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}"
)
logger.info("Building installer")
run(
[briefcase, "package"] + (["-v"] if verbose else []),
Expand Down
2 changes: 1 addition & 1 deletion examples/azure_signtool/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../../constructor/data/construct.schema.json"

name: Signed_AzureSignTool
version: X
version: 1.0.0
installer_type: exe
channels:
- http://repo.anaconda.com/pkgs/main/
Expand Down
2 changes: 1 addition & 1 deletion examples/custom_nsis_template/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../../constructor/data/construct.schema.json"

name: custom
version: X
version: 1.0.0
ignore_duplicate_files: True
installer_filename: {{ name }}-installer.exe
installer_type: exe
Expand Down
2 changes: 1 addition & 1 deletion examples/customize_controls/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../../constructor/data/construct.schema.json"

name: NoCondaOptions
version: X
version: 1.0.0
installer_type: all

channels:
Expand Down
2 changes: 1 addition & 1 deletion examples/customized_welcome_conclusion/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../../constructor/data/construct.schema.json"

name: CustomizedWelcomeConclusion
version: X
version: 1.0.0
installer_type: all
channels:
- http://repo.anaconda.com/pkgs/main/
Expand Down
2 changes: 1 addition & 1 deletion examples/exe_extra_pages/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{% set name = "extraPageSingle" %}
{% endif %}
name: {{ name }}
version: X
version: 1.0.0
installer_type: all
channels:
- http://repo.anaconda.com/pkgs/main/
Expand Down
2 changes: 1 addition & 1 deletion examples/extra_envs/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../../constructor/data/construct.schema.json"

name: ExtraEnvs
version: X
version: 1.0.0
installer_type: all
channels:
- https://conda.anaconda.org/conda-forge
Expand Down
2 changes: 1 addition & 1 deletion examples/extra_files/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../../constructor/data/construct.schema.json"

name: ExtraFiles
version: X
version: 1.0.0
installer_type: all
check_path_spaces: False
check_path_length: False
Expand Down
2 changes: 1 addition & 1 deletion examples/from_env_txt/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../../constructor/data/construct.schema.json"

name: EnvironmentTXT
version: X
version: 1.0.0
installer_type: all
environment_file: env.txt
initialize_by_default: false
Expand Down
2 changes: 1 addition & 1 deletion examples/from_env_yaml/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../../constructor/data/construct.schema.json"

name: EnvironmentYAML
version: X
version: 1.0.0
installer_type: all
environment_file: env.yaml
initialize_by_default: false
Expand Down
2 changes: 1 addition & 1 deletion examples/from_existing_env/construct.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# yaml-language-server: $schema=../../constructor/data/construct.schema.json
"$schema": "../../constructor/data/construct.schema.json"
name: Existing
version: X
version: 1.0.0
installer_type: all
environment: {{ os.environ.get("CONSTRUCTOR_TEST_EXISTING_ENV", os.environ["CONDA_PREFIX"]) }}
channels:
Expand Down
2 changes: 1 addition & 1 deletion examples/from_explicit/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../../constructor/data/construct.schema.json"

name: Explicit
version: X
version: 1.0.0
installer_type: all
environment_file: explicit_linux-64.txt
initialize_by_default: false
Expand Down
Loading