Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
109693f
Add jinja templating, payload as tar, tests
lrandersson Feb 5, 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
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
144 changes: 107 additions & 37 deletions constructor/briefcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import shutil
import sys
import sysconfig
import tarfile
import tempfile
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -19,6 +20,7 @@
tomli_w = None # This file is only intended for Windows use

from . import preconda
from .template_file import TemplateFile, render_template_files
from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist

BRIEFCASE_DIR = Path(__file__).parent / "briefcase"
Expand Down Expand Up @@ -219,37 +221,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):
Comment thread
marcoesters marked this conversation as resolved.
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."""
Expand All @@ -268,23 +239,122 @@ class Payload:

info: dict
root: Path | None = None
archive_name: str = "payload.tar.gz"
conda_exe_name: str = "_conda.exe"
rendered_templates: list[TemplateFile] | None = None
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't know if we want to make that distinction. Rendered templates are like any other file we add to the installer payload.


def prepare(self) -> PayloadLayout:
def prepare(self, as_archive: bool = True) -> PayloadLayout:
"""Prepares the payload. Toggle 'as_archive' (default True) to convert the
payload directory 'base' and its contents into an archive.
"""
root = self._ensure_root()
self._write_pyproject(root)
layout = self._create_layout(root)
Comment thread
marcoesters marked this conversation as resolved.
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)

if as_archive:
self._convert_into_archive(layout.base, layout.external)
return layout

def remove(self) -> None:
shutil.rmtree(self.root)
if self.root:
shutil.rmtree(self.root)

def make_tar_gz(self, src: Path, dst: Path) -> Path:
"""Create a .tar.gz of the directory 'src'.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think it would overall be good see a benchmark of tar.gz vs. tar.bz2 in terms of space and extraction speed, especially compared to the native MSI compression. While registry handling becomes much easier with one file, it would be good to say what the cost is.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agree, will do that separately.

The inputs 'src' and 'dst' must both be existing directories.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do they need to exist already?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The directory src must exist since that is what we are trying to archive, but I guess dst does not explicitly need to exist, but since it's a public method I requested that as a defensive measure to avoid confusing tarfile or shutil errors, since I thought current implementation will at least raise a clear error message:

        if not dst.is_dir():
            raise NotADirectoryError(dst)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would generally not require that a destination directory exists. It seems like a fragile design.

Copy link
Copy Markdown
Owner Author

@lrandersson lrandersson Feb 24, 2026

Choose a reason for hiding this comment

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

26ebcb3 (changed inputs to input in docstring in later commit)

Returns the path to the .tar.gz.

Example:
payload = Payload(...)
foo = Path('foo')
bar = Path('bar')
targz = payload.make_tar_gz(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)
if not dst.is_dir():
raise NotADirectoryError(dst)

archive_path = dst / self.archive_name

with tarfile.open(archive_path, mode="w:gz", compresslevel=1) as tar:
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

I chose tar.gz here over tar.bz2, the reason is that MSI already compresses the contents, therefor the format of the compression does not do much of a difference in terms of disk space. However, .tar.gz is in general a little bit faster over .tar.bz2, so I have intentionally chose speed here over disk space.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you add some benchmarks to this?

tar.add(src, arcname=src.name)

return archive_path

def _convert_into_archive(self, src: Path, dst: Path) -> Path:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

That function could be folded into make_tar_gz or vice versa

"""Create a .tar.gz of 'src' in 'dst' and remove 'src' after successful creation."""
archive_path = self.make_tar_gz(src, dst)

if not archive_path.exists():
raise RuntimeError(f"Unexpected error, failed to create archive: {archive_path}")

shutil.rmtree(src)
return archive_path

def render_templates(self) -> list[TemplateFile]:
"""Render all configured Jinja templates into the payload root directory.
The set of successfully rendered templates is recorded on the instance and returned to the caller.
"""
root = self._ensure_root()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is what I meant about either using __init__ to create the directory or not allowing a None value/non-existing directory. We have to manually add self._ensure_root() in all appropriate places.

templates = [
TemplateFile(
name="post_install_script",
src=BRIEFCASE_DIR / "run_installation.bat",
dst=root / "run_installation.bat",
),
TemplateFile(
name="pre_uninstall_script",
src=BRIEFCASE_DIR / "pre_uninstall.bat",
dst=root / "pre_uninstall.bat",
),
]
context = {
"archive_name": self.archive_name,
"conda_exe_name": self.conda_exe_name,
}
render_template_files(templates, context)
self.rendered_templates = templates
return self.rendered_templates

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),
}
},
}
# Render the template files and add them to the necessary config field
rendered_templates = self.render_templates()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this should go into prepare like with any other payload preparation. Since we will always have pre_uninstall and pre_install scripts for our installers, we can add those hard-coded into the config object above.

config["app"][app_name].update({t.name: str(t.dst) for t in rendered_templates})

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

def _write_pyproject(self, root: Path) -> None:
write_pyproject_toml(root, self.info)
# Finalize
(layout.root / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}}))
logger.debug(f"Created TOML file at: {layout.root}")

def _ensure_root(self) -> Path:
if self.root is None:
Expand Down Expand Up @@ -315,7 +385,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):
Expand Down
Empty file.
12 changes: 9 additions & 3 deletions constructor/briefcase/run_installation.bat
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
set "INSTDIR=%cd%"
set "BASE_PATH=%INSTDIR%\base"
set "PREFIX=%BASE_PATH%"
set "CONDA_EXE=%INSTDIR%\_conda.exe"
set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}"
set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}"

"%INSTDIR%\_conda.exe" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs
tar -xzf "%PAYLOAD_TAR%" -C "%INSTDIR%"
Comment thread
marcoesters marked this conversation as resolved.
Outdated

"%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs

set CONDA_PROTECT_FROZEN_ENVS=0
set "CONDA_ROOT_PREFIX=%BASE_PATH%"
set CONDA_SAFETY_CHECKS=disabled
set CONDA_EXTRA_SAFETY_CHECKS=no
set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs"

"%INSTDIR%\_conda.exe" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"
"%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"

rem Truncates the payload to 0 bytes
type nul > "%PAYLOAD_TAR%"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why not delete it? That zero-byte file doesn't need to be there until the uninstallation.

There should probably also be a comment about why this is needed.

26 changes: 26 additions & 0 deletions constructor/template_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from collections.abc import Mapping
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's merge the two template functions into one. There doesn't seem to be enough here to justify a separate module.

from dataclasses import dataclass
from pathlib import Path
from typing import Any

from .jinja import render_template


@dataclass(frozen=True)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

We can utilize this later also for the other installers.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This seems to overly abstract. The name property appears to be too tailor-made (which other format needs it besides MSI) and then we just have a source and destination path. With all my other comments, I'm not sure we need this class.

The render_template_files function could then just handle one file and deal with all the file handling operations (which I think is nice).

class TemplateFile:
"""A specification for a single Jinja template to an output file."""

name: str
src: Path
dst: Path


def render_template_files(
files: list[TemplateFile], context: Mapping[str, Any], line_ending="\r\n"
) -> None:
for f in files:
if not f.src.exists():
raise FileNotFoundError(f.src)
rendered = render_template(f.src.read_text(encoding="utf-8"), **context)
f.dst.parent.mkdir(parents=True, exist_ok=True)
f.dst.write_text(rendered, encoding="utf-8", newline=line_ending)
68 changes: 63 additions & 5 deletions tests/test_briefcase.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import sys
import tarfile
from pathlib import Path

import pytest
Expand Down Expand Up @@ -154,30 +155,65 @@ 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()
assert payload.root.is_dir()


@pytest.mark.parametrize("as_archive", [True, False])
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
def test_payload_layout():
def test_payload_layout(as_archive):
"""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()
prepared_payload = payload.prepare(as_archive=as_archive)

external_dir = prepared_payload.root / "external"
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
if as_archive:
# Since archiving removes the directory 'base_dir' and its contents
assert not base_dir.exists()
assert not pkgs_dir.exists()
assert archive_path.exists()
else:
assert base_dir.is_dir() and base_dir == prepared_payload.base
assert pkgs_dir.is_dir() and pkgs_dir == prepared_payload.pkgs
assert not 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_tar_gz(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()
Expand All @@ -189,6 +225,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()
Expand All @@ -198,8 +235,29 @@ 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")
def test_payload_templates_are_rendered():
"""Test that templates are rendered when the payload is prepared."""

def assert_no_jinja_markers(path: Path) -> None:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

That function looks simple enough to be inlined.

"""Dummy check to verify we have rendered everything as expected."""
text = path.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

info = mock_info.copy()
payload = Payload(info)
payload.prepare()
assert len(payload.rendered_templates) >= 2 # There should be at least two files
for f in payload.rendered_templates:
assert f.dst.is_file()
assert_no_jinja_markers(f.dst)
Loading