Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
13c2c05
Reorganize payload into a class and add tests
lrandersson Feb 4, 2026
8619a37
Ensure new tests are Windows only
lrandersson Feb 4, 2026
109693f
Add jinja templating, payload as tar, tests
lrandersson Feb 5, 2026
c4b11e8
Update docstring
lrandersson Feb 6, 2026
5e97190
Update briefcase.py
lrandersson Feb 6, 2026
2019d4f
Inline test utility function
lrandersson Feb 6, 2026
b80642e
Remove payload tar, update pre_uninstall.bat
lrandersson Feb 6, 2026
8aebd7c
Use conda-standalone for extracting tar
lrandersson Feb 6, 2026
e4bc141
Merge archive functions, update root as cached property
lrandersson Feb 6, 2026
313f7f2
Remove template_file.py improve handling of templates
lrandersson Feb 6, 2026
e642b75
Add missing .dst
lrandersson Feb 6, 2026
839e290
Remove compresslevel arg
lrandersson Feb 6, 2026
3327439
Review fixes
lrandersson Feb 6, 2026
3df7843
Fix typo in file name causing build errors
lrandersson Feb 9, 2026
2566f73
Dynamically set archive type from file name
lrandersson Feb 9, 2026
938e027
Rename class function and update docstring
lrandersson Feb 9, 2026
b2d2025
Update uninstallation scripts
lrandersson Feb 10, 2026
81a75b4
Update pre_uninstall.bat
lrandersson Feb 10, 2026
3300636
Add logging
lrandersson Feb 16, 2026
a813e17
Improve log handling for msi tests
lrandersson Feb 16, 2026
d64e6ab
Add register_envs
lrandersson Feb 16, 2026
0f4d68d
Fix syntax error with remove
lrandersson Feb 16, 2026
d64a807
Ensure .exe test not running for MSI
lrandersson Feb 16, 2026
c3d51fd
Properly disable test for MSI
lrandersson Feb 16, 2026
d9fa4f8
Add more tests and another check errorlevel
lrandersson Feb 17, 2026
1d40e95
Make logging more neat
lrandersson Feb 18, 2026
f39a787
Removed all use of 'sanity'
lrandersson Feb 19, 2026
60f85bb
Updated test for MSI (remove pytest.skip)
lrandersson Feb 19, 2026
2ad0d23
Merge pull request #3 from lrandersson/dev-ra-753
lrandersson Feb 19, 2026
8a32292
Update to use CLI for newer conda-standalone
lrandersson Feb 20, 2026
84b0136
Fix missing quote and properly use --log-file
lrandersson Feb 20, 2026
b501aab
Docstring formatting
lrandersson Feb 23, 2026
c5237a3
pre-commit fix
lrandersson Feb 23, 2026
2c40ad5
Hopefully fix issue with conda-standalone canary
lrandersson Feb 23, 2026
6c542cd
Fix typo in workflow
lrandersson Feb 23, 2026
26ebcb3
Automatically create 'dst'
lrandersson Feb 24, 2026
8c8cc7b
Remove TemplateFile
lrandersson Feb 24, 2026
621a545
Fix docstring
lrandersson Feb 24, 2026
6312aa7
FIx pre-commit
lrandersson Feb 24, 2026
3c91055
Always log to file
lrandersson Feb 24, 2026
556705a
Generalize install/uninstall logs and move into INSTDIR
lrandersson Feb 24, 2026
68a527e
Merge pull request #2 from lrandersson/dev-ra-798-2
lrandersson Feb 26, 2026
8a9be10
pre-commit fix
lrandersson Feb 26, 2026
6af0910
Remove PayloadLayout
lrandersson Feb 26, 2026
56aa4a4
Fix remaining syntax error
lrandersson Feb 26, 2026
1ed9fe0
Review fixes
lrandersson Mar 3, 2026
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
6 changes: 4 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
219 changes: 170 additions & 49 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.
"""

import functools
import logging
import re
import shutil
import sys
import sysconfig
import tarfile
import tempfile
from dataclasses import dataclass
from pathlib import Path
from subprocess import run

Expand All @@ -18,6 +21,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"
Expand Down Expand Up @@ -218,75 +222,192 @@ 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),
}
},
}
@dataclass
class Payload:
"""
This class manages and prepares a payload with a temporary directory.
"""

info: dict
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 directory 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
Copy link
Contributor

Choose a reason for hiding this comment

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

For example? And why is passing the appropriate course of action?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There are a few ways to look at it:

  1. If this fails, the temporary directory is already removed which is the primary goal.
  2. The payload.remove() function here is intended to be called as the final step (currently inside create(...))
    It is likely that we could skip the entire delattr shenanigan, but to me it looks more complete to avoid a reference to a directory that might not exist.
    I was also thinking of writing Payload to be used as a context-manager to achieve automatic clean-up but I thought it might be a bit overkill right now.

pass

def prepare(self) -> tuple:
"""Prepares the payload.

Directory structure created during preparation:

<root>/ (temporary directory, see :attr:`root`)
└── <EXTERNAL_PACKAGE_PATH>/ (external_dir: contains the payload archive and conda exe)
└── base/ (base_dir: represents the base conda environment)
└── pkgs/ (pkgs_dir: staging area for conda package distributions)
"""
root = self.root
external_dir = root / EXTERNAL_PACKAGE_PATH
external_dir.mkdir(parents=True, exist_ok=True)

# Note that the directory name "base" is also explicitly defined in `run_installation.bat`
base_dir = external_dir / "base"
base_dir.mkdir()

pkgs_dir = base_dir / "pkgs"
pkgs_dir.mkdir()
# Render the template files and add them to the necessary config field
self.render_templates()
self.write_pyproject_toml(root, external_dir)

preconda.write_files(self.info, base_dir)
preconda.copy_extra_files(self.info.get("extra_files", []), external_dir)
self._stage_dists(pkgs_dir)
self._stage_conda(external_dir)

archive_path = self.make_archive(base_dir, external_dir)
if not archive_path.exists():
raise RuntimeError(f"Unexpected error, failed to create archive: {archive_path}")
return (root, external_dir, base_dir, pkgs_dir)

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\\<payload.archive_name> 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"),
}

if "company" in info:
config["author"] = info["company"]
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(),
}

(tmp_dir / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}}))
# 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, root: Path, external: Path) -> 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(external),
"use_full_install_path": False,
"install_launcher": False,
"install_option": create_install_options_list(self.info),
"post_install_script": str(root / "run_installation.bat"),
"pre_uninstall_script": str(root / "pre_uninstall.bat"),
}
},
}

# Add optional content
if "company" in self.info:
config["author"] = self.info["company"]

def create(info, verbose=False):
if not IS_WINDOWS:
raise Exception(f"Invalid platform '{sys.platform}'. MSI installers require Windows.")
# Finalize
(root / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}}))
logger.debug(f"Created TOML file at: {root}")

tmp_dir = Path(tempfile.mkdtemp())
write_pyproject_toml(tmp_dir, info)
def _stage_dists(self, pkgs_dir: Path) -> None:
download_dir = Path(self.info["_download_dir"])
for dist in self.info["_dists"]:
shutil.copy(download_dir / filename_dist(dist), pkgs_dir)

external_dir = tmp_dir / EXTERNAL_PACKAGE_PATH
external_dir.mkdir()
def _stage_conda(self, external_dir: Path) -> None:
copy_conda_exe(external_dir, self.conda_exe_name, self.info["_conda_exe"])

# Create the sub-directory "base",
# note that the directory name "base" is also explicitly
# defined in `run_installation.bat`
base_dir = external_dir / "base"
base_dir.mkdir()

preconda.write_files(info, base_dir)
preconda.copy_extra_files(info.get("extra_files", []), external_dir)
def create(info, verbose=False):
if not IS_WINDOWS:
raise Exception(f"Invalid platform '{sys.platform}'. MSI installers require Windows.")

download_dir = Path(info["_download_dir"])
pkgs_dir = base_dir / "pkgs"
for dist in info["_dists"]:
shutil.copy(download_dir / filename_dist(dist), pkgs_dir)
if not info.get("_conda_exe_supports_logging"):
raise Exception("MSI installers require conda-standalone with logging support.")

copy_conda_exe(external_dir, "_conda.exe", info["_conda_exe"])
payload = Payload(info)
payload.prepare()

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")

logger.info("Building MSI installer")
run(
[briefcase, "package"] + (["-v"] if verbose else []),
cwd=tmp_dir,
cwd=payload.root,
check=True,
)

dist_dir = tmp_dir / "dist"
dist_dir = payload.root / "dist"
msi_paths = list(dist_dir.glob("*.msi"))
if len(msi_paths) != 1:
raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}, expected 1.")
Expand All @@ -296,4 +417,4 @@ def create(info, verbose=False):
shutil.move(msi_paths[0], outpath)

if not info.get("_debug"):
shutil.rmtree(tmp_dir)
payload.remove()
53 changes: 53 additions & 0 deletions constructor/briefcase/pre_uninstall.bat
Original file line number Diff line number Diff line change
@@ -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%"
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this assumes the --log-file feature, I think we need to add a check to ensure that it exists. That information exists in info, we just need to raise when it's False

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great idea, see 1ed9fe0

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
Loading
Loading