From 109693faeb21672877c7b04aeb1463284faa359c Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 5 Feb 2026 11:09:36 -0500 Subject: [PATCH 01/37] Add jinja templating, payload as tar, tests --- constructor/briefcase.py | 144 +++++++++++++++------ constructor/briefcase/pre_uninstall.bat | 0 constructor/briefcase/run_installation.bat | 12 +- constructor/template_file.py | 26 ++++ tests/test_briefcase.py | 68 +++++++++- 5 files changed, 205 insertions(+), 45 deletions(-) create mode 100644 constructor/briefcase/pre_uninstall.bat create mode 100644 constructor/template_file.py diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 53acfecc1..6f1fc0c7e 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -7,6 +7,7 @@ import shutil import sys import sysconfig +import tarfile import tempfile from dataclasses import dataclass from pathlib import Path @@ -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" @@ -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): - 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.""" @@ -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 - 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) + 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'. + The inputs 'src' and 'dst' must both be existing directories. + 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\\ 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: + tar.add(src, arcname=src.name) + + return archive_path + + def _convert_into_archive(self, src: Path, dst: Path) -> Path: + """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() + 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() + 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: @@ -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): diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat new file mode 100644 index 000000000..e69de29bb diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index ec8cc35b9..ced78df11 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,9 +1,12 @@ 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%" + +"%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs set CONDA_PROTECT_FROZEN_ENVS=0 set "CONDA_ROOT_PREFIX=%BASE_PATH%" @@ -11,4 +14,7 @@ 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%" diff --git a/constructor/template_file.py b/constructor/template_file.py new file mode 100644 index 000000000..1490db7f3 --- /dev/null +++ b/constructor/template_file.py @@ -0,0 +1,26 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .jinja import render_template + + +@dataclass(frozen=True) +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) diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index dfb734231..11f8df615 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -1,4 +1,5 @@ import sys +import tarfile from pathlib import Path import pytest @@ -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() @@ -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() @@ -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: + """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) From 5e971907ff8f5c829e56d26253f9a575432c1a68 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 08:46:51 -0500 Subject: [PATCH 02/37] Update briefcase.py --- constructor/briefcase.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 6f1fc0c7e..2aa0bef6f 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -261,8 +261,8 @@ def prepare(self, as_archive: bool = True) -> PayloadLayout: return layout def remove(self) -> None: - if self.root: - shutil.rmtree(self.root) + # TODO discuss if we should ignore errors or similar here etc + shutil.rmtree(self.root) def make_tar_gz(self, src: Path, dst: Path) -> Path: """Create a .tar.gz of the directory 'src'. From 2019d4f31b4e6b1390a79487d132a0cb0678929f Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 13:07:19 -0500 Subject: [PATCH 03/37] Inline test utility function --- tests/test_briefcase.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 11f8df615..14f8f7ef7 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -246,18 +246,13 @@ def test_payload_conda_exe(): @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: - """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) + text = f.dst.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 From b80642e3486840ff69141ebf8ed929f0ad9bcf81 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 13:12:43 -0500 Subject: [PATCH 04/37] Remove payload tar, update pre_uninstall.bat --- constructor/briefcase/pre_uninstall.bat | 9 +++++++++ constructor/briefcase/run_installation.bat | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index e69de29bb..eb0bd983e 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -0,0 +1,9 @@ +set "INSTDIR=%cd%" +set "BASE_PATH=%INSTDIR%\base" +set "PREFIX=%BASE_PATH%" +set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" +set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" + +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%" diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index ced78df11..de85ed053 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -16,5 +16,5 @@ set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" "%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%" +rem Delete the payload to save disk space, a truncated placeholder of 0 bytes is recreated during uninstall +del "%PAYLOAD_TAR%" From 8aebd7c90c83f99a2015427459e2b4241467642d Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 13:28:04 -0500 Subject: [PATCH 05/37] Use conda-standalone for extracting tar --- constructor/briefcase/run_installation.bat | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index de85ed053..04d0cab75 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -4,8 +4,7 @@ set "PREFIX=%BASE_PATH%" set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" -tar -xzf "%PAYLOAD_TAR%" -C "%INSTDIR%" - +"%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs set CONDA_PROTECT_FROZEN_ENVS=0 From e4bc1418aedbd02d658fc4657821b5fff45d8843 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 13:39:09 -0500 Subject: [PATCH 06/37] Merge archive functions, update root as cached property --- constructor/briefcase.py | 58 +++++++++++++++++++++------------------- tests/test_briefcase.py | 18 +++++-------- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 2aa0bef6f..919288a7a 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -2,6 +2,7 @@ Logic to build installers using Briefcase. """ +import functools import logging import re import shutil @@ -238,16 +239,34 @@ 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 - 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. + @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 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 + pass + + def prepare(self) -> PayloadLayout: + """Prepares the payload. """ - root = self._ensure_root() + root = self.root layout = self._create_layout(root) self.write_pyproject_toml(layout) @@ -256,18 +275,16 @@ def prepare(self, as_archive: bool = True) -> PayloadLayout: self._stage_dists(layout) self._stage_conda(layout) - if as_archive: - self._convert_into_archive(layout.base, layout.external) + archive_path = self.make_tar_gz(layout.base, layout.external) + if not archive_path.exists(): + raise RuntimeError(f"Unexpected error, failed to create archive: {archive_path}") return layout - def remove(self) -> None: - # TODO discuss if we should ignore errors or similar here etc - shutil.rmtree(self.root) - def make_tar_gz(self, src: Path, dst: Path) -> Path: """Create a .tar.gz of the directory 'src'. The inputs 'src' and 'dst' must both be existing directories. Returns the path to the .tar.gz. + The directory specified via 'src' is removed after successful creation. Example: payload = Payload(...) @@ -287,15 +304,6 @@ def make_tar_gz(self, src: Path, dst: Path) -> Path: with tarfile.open(archive_path, mode="w:gz", compresslevel=1) as tar: tar.add(src, arcname=src.name) - return archive_path - - def _convert_into_archive(self, src: Path, dst: Path) -> Path: - """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 @@ -303,17 +311,16 @@ 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() templates = [ TemplateFile( name="post_install_script", src=BRIEFCASE_DIR / "run_installation.bat", - dst=root / "run_installation.bat", + dst=self.root / "run_installation.bat", ), TemplateFile( name="pre_uninstall_script", src=BRIEFCASE_DIR / "pre_uninstall.bat", - dst=root / "pre_uninstall.bat", + dst=self.root / "pre_uninstall.bat", ), ] context = { @@ -356,11 +363,6 @@ def write_pyproject_toml(self, layout: PayloadLayout) -> None: (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: - self.root = Path(tempfile.mkdtemp()) - return self.root - def _create_layout(self, root: Path) -> PayloadLayout: """The layout is created as: root/ diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 14f8f7ef7..e7696878b 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -162,15 +162,14 @@ def test_prepare_payload(): 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(as_archive): +def test_payload_layout(): """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(as_archive=as_archive) + prepared_payload = payload.prepare() external_dir = prepared_payload.root / "external" assert external_dir.is_dir() and external_dir == prepared_payload.external @@ -178,15 +177,10 @@ def test_payload_layout(as_archive): base_dir = prepared_payload.root / "external" / "base" pkgs_dir = prepared_payload.root / "external" / "base" / "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() + # Since archiving removes the directory 'base_dir' and its contents + assert not base_dir.exists() + assert not pkgs_dir.exists() + assert archive_path.exists() @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") From 313f7f2ae24341e217be5df00c31c8c1d56e38b4 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 13:54:33 -0500 Subject: [PATCH 07/37] Remove template_file.py improve handling of templates --- constructor/briefcase.py | 53 ++++++++++++++++++++++++------------ constructor/template_file.py | 26 ------------------ tests/test_briefcase.py | 2 +- 3 files changed, 36 insertions(+), 45 deletions(-) delete mode 100644 constructor/template_file.py diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 919288a7a..b2ba26d19 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -21,7 +21,7 @@ tomli_w = None # This file is only intended for Windows use from . import preconda -from .template_file import TemplateFile, render_template_files +from .jinja import render_template from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist BRIEFCASE_DIR = Path(__file__).parent / "briefcase" @@ -221,6 +221,13 @@ def create_install_options_list(info: dict) -> list[dict]: return options +@dataclass(frozen=True) +class TemplateFile: + """A specification for a single Jinja template to an output file.""" + + src: Path + dst: Path + @dataclass(frozen=True) class PayloadLayout: @@ -241,7 +248,6 @@ class Payload: info: dict archive_name: str = "payload.tar.gz" conda_exe_name: str = "_conda.exe" - rendered_templates: list[TemplateFile] | None = None @functools.cached_property def root(self) -> Path: @@ -268,6 +274,8 @@ def prepare(self) -> PayloadLayout: """ root = self.root layout = self._create_layout(root) + # Render the template files and add them to the necessary config field + self.rendered_templates = self.render_templates() self.write_pyproject_toml(layout) preconda.write_files(self.info, layout.base) @@ -307,28 +315,37 @@ def make_tar_gz(self, src: Path, dst: Path) -> 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. - """ - templates = [ - TemplateFile( - name="post_install_script", + @functools.cached_property + def rendered_templates(self) -> dict[str: TemplateFile]: + """Render and cache the configured templates under the payload root.""" + templates = { + "post_install_script": TemplateFile( src=BRIEFCASE_DIR / "run_installation.bat", dst=self.root / "run_installation.bat", ), - TemplateFile( - name="pre_uninstall_script", + "pre_uninstall_script": TemplateFile( src=BRIEFCASE_DIR / "pre_uninstall.bat", dst=self.root / "pre_uninstall.bat", ), - ] - context = { + } + + context: dict[str, str] = { "archive_name": self.archive_name, "conda_exe_name": self.conda_exe_name, } - render_template_files(templates, context) - self.rendered_templates = templates + + # Render the templates now using jinja and the defined context + for f in templates.values(): + 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="\r\n") + + return templates + + def render_templates(self) -> dict[str: TemplateFile]: + """Render templates if necessary and return the cached result.""" return self.rendered_templates def write_pyproject_toml(self, layout: PayloadLayout) -> None: @@ -348,12 +365,12 @@ def write_pyproject_toml(self, layout: PayloadLayout) -> None: "use_full_install_path": False, "install_launcher": False, "install_option": create_install_options_list(self.info), + "post_install_script": str(self.rendered_templates["post_install_script"]), + "pre_uninstall_script": str(self.rendered_templates["pre_uninstall_script"]), } }, } - # Render the template files and add them to the necessary config field - rendered_templates = self.render_templates() - config["app"][app_name].update({t.name: str(t.dst) for t in rendered_templates}) + # Add optional content if "company" in self.info: diff --git a/constructor/template_file.py b/constructor/template_file.py deleted file mode 100644 index 1490db7f3..000000000 --- a/constructor/template_file.py +++ /dev/null @@ -1,26 +0,0 @@ -from collections.abc import Mapping -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from .jinja import render_template - - -@dataclass(frozen=True) -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) diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index e7696878b..df4163899 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -244,7 +244,7 @@ def test_payload_templates_are_rendered(): payload = Payload(info) payload.prepare() assert len(payload.rendered_templates) >= 2 # There should be at least two files - for f in payload.rendered_templates: + for f in payload.rendered_templates.values(): assert f.dst.is_file() text = f.dst.read_text(encoding="utf-8") assert "{{" not in text and "}}" not in text From e642b751d9f425c21fcb5dc5d2ee9e0c85764b6e Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 14:17:50 -0500 Subject: [PATCH 08/37] Add missing .dst --- constructor/briefcase.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index b2ba26d19..c7ea30469 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -365,8 +365,8 @@ def write_pyproject_toml(self, layout: PayloadLayout) -> None: "use_full_install_path": False, "install_launcher": False, "install_option": create_install_options_list(self.info), - "post_install_script": str(self.rendered_templates["post_install_script"]), - "pre_uninstall_script": str(self.rendered_templates["pre_uninstall_script"]), + "post_install_script": str(self.rendered_templates["post_install_script"].dst), + "pre_uninstall_script": str(self.rendered_templates["pre_uninstall_script"].dst), } }, } From 839e2905aa96acd7537c923ac35c24fe39b96604 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 14:42:17 -0500 Subject: [PATCH 09/37] Remove compresslevel arg --- constructor/briefcase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index c7ea30469..c9f7c8456 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -309,7 +309,7 @@ def make_tar_gz(self, src: Path, dst: Path) -> Path: archive_path = dst / self.archive_name - with tarfile.open(archive_path, mode="w:gz", compresslevel=1) as tar: + with tarfile.open(archive_path, mode="w:gz") as tar: tar.add(src, arcname=src.name) shutil.rmtree(src) From 3327439d328ae3e7add2bd065219e96c4301fb02 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 16:33:17 -0500 Subject: [PATCH 10/37] Review fixes --- constructor/briefcase.py | 25 +++++++++------------- constructor/briefcase/run_installation.bat | 4 +++- tests/test_briefcase.py | 6 +++--- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index c9f7c8456..16e170a69 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -275,7 +275,7 @@ def prepare(self) -> PayloadLayout: root = self.root layout = self._create_layout(root) # Render the template files and add them to the necessary config field - self.rendered_templates = self.render_templates() + self.render_templates() self.write_pyproject_toml(layout) preconda.write_files(self.info, layout.base) @@ -315,19 +315,18 @@ def make_tar_gz(self, src: Path, dst: Path) -> Path: shutil.rmtree(src) return archive_path - @functools.cached_property - def rendered_templates(self) -> dict[str: TemplateFile]: - """Render and cache the configured templates under the payload root.""" - templates = { - "post_install_script": TemplateFile( + def render_templates(self) -> list[str: TemplateFile]: + """Render the configured templates under the payload root.""" + templates = [ + TemplateFile( src=BRIEFCASE_DIR / "run_installation.bat", dst=self.root / "run_installation.bat", ), - "pre_uninstall_script": TemplateFile( + TemplateFile( src=BRIEFCASE_DIR / "pre_uninstall.bat", dst=self.root / "pre_uninstall.bat", ), - } + ] context: dict[str, str] = { "archive_name": self.archive_name, @@ -335,7 +334,7 @@ def rendered_templates(self) -> dict[str: TemplateFile]: } # Render the templates now using jinja and the defined context - for f in templates.values(): + for f in templates: if not f.src.exists(): raise FileNotFoundError(f.src) rendered = render_template(f.src.read_text(encoding="utf-8"), **context) @@ -344,10 +343,6 @@ def rendered_templates(self) -> dict[str: TemplateFile]: return templates - def render_templates(self) -> dict[str: TemplateFile]: - """Render templates if necessary and return the cached result.""" - 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) @@ -365,8 +360,8 @@ def write_pyproject_toml(self, layout: PayloadLayout) -> None: "use_full_install_path": False, "install_launcher": False, "install_option": create_install_options_list(self.info), - "post_install_script": str(self.rendered_templates["post_install_script"].dst), - "pre_uninstall_script": str(self.rendered_templates["pre_uninstall_script"].dst), + "post_install_script": str(layout.root / "post_install.bat"), + "pre_uninstall_script": str(layout.root / "pre_uninstall.bat"), } }, } diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 04d0cab75..805123ce0 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -15,5 +15,7 @@ set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" "%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" -rem Delete the payload to save disk space, a truncated placeholder of 0 bytes is recreated during uninstall +rem Delete the payload to save disk space. +rem A truncated placeholder of 0 bytes is recreated during uninstall +rem because MSI expects the file to be there to clean the registry. del "%PAYLOAD_TAR%" diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index df4163899..8aaf1a285 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -242,9 +242,9 @@ def test_payload_templates_are_rendered(): """Test that templates are rendered when the payload is prepared.""" 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.values(): + rendered_templates = payload.render_templates() + assert len(rendered_templates) == 2 # There should be at least two files + for f in rendered_templates: assert f.dst.is_file() text = f.dst.read_text(encoding="utf-8") assert "{{" not in text and "}}" not in text From 3df78437cce11c4faba45eff57ddc6e41938ad9f Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 9 Feb 2026 08:15:57 -0500 Subject: [PATCH 11/37] Fix typo in file name causing build errors --- constructor/briefcase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 16e170a69..eb6132fa3 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -360,7 +360,7 @@ def write_pyproject_toml(self, layout: PayloadLayout) -> None: "use_full_install_path": False, "install_launcher": False, "install_option": create_install_options_list(self.info), - "post_install_script": str(layout.root / "post_install.bat"), + "post_install_script": str(layout.root / "run_installation.bat"), "pre_uninstall_script": str(layout.root / "pre_uninstall.bat"), } }, From 2566f73ca092da236aca857412c619c6e65d4427 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 9 Feb 2026 12:29:52 -0500 Subject: [PATCH 12/37] Dynamically set archive type from file name --- constructor/briefcase.py | 3 ++- constructor/briefcase/run_installation.bat | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index eb6132fa3..36c1d93ac 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -309,7 +309,8 @@ def make_tar_gz(self, src: Path, dst: Path) -> Path: archive_path = dst / self.archive_name - with tarfile.open(archive_path, mode="w:gz") as tar: + 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) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 805123ce0..2eca7b7f7 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -4,6 +4,7 @@ set "PREFIX=%BASE_PATH%" set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" +echo "Unpacking payload..." "%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs From 938e027110fe4f8d54ff831867e9ecf6851254d5 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 9 Feb 2026 14:02:47 -0500 Subject: [PATCH 13/37] Rename class function and update docstring --- constructor/briefcase.py | 10 +++++----- tests/test_briefcase.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 36c1d93ac..fa43187c1 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -283,22 +283,22 @@ def prepare(self) -> PayloadLayout: self._stage_dists(layout) self._stage_conda(layout) - archive_path = self.make_tar_gz(layout.base, layout.external) + archive_path = self.make_archive(layout.base, layout.external) if not archive_path.exists(): raise RuntimeError(f"Unexpected error, failed to create archive: {archive_path}") return layout - def make_tar_gz(self, src: Path, dst: Path) -> Path: - """Create a .tar.gz of the directory 'src'. + def make_archive(self, src: Path, dst: Path) -> Path: + """Create an archive of the directory 'src'. The inputs 'src' and 'dst' must both be existing directories. - Returns the path to the .tar.gz. 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_tar_gz(foo, bar) + targz = payload.make_archive(foo, bar) This will create the file bar\\ containing 'foo' and all its contents. """ diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 8aaf1a285..e57911b43 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -196,7 +196,7 @@ def test_payload_archive(tmp_path: Path): 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) + archive_path = payload.make_archive(foo_dir, tmp_path) with tarfile.open(archive_path, mode="r:gz") as tar: member = tar.getmember("foo/hello.txt") From b2d20254f98ab1cc33b37832b52c20ba8f733005 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 10 Feb 2026 10:33:19 -0500 Subject: [PATCH 14/37] Update uninstallation scripts --- constructor/briefcase/pre_uninstall.bat | 11 +++++++++++ constructor/briefcase/run_installation.bat | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index eb0bd983e..39bdc6105 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -7,3 +7,14 @@ set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" 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%" + +"%CONDA_EXE%" menuinst --prefix "%BASE_PATH%" --remove +if errorlevel 1 ( + echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. + exit /b %errorlevel% +) +"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%" +if errorlevel 1 ( + echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. + exit /b %errorlevel% +) \ No newline at end of file diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 2eca7b7f7..1c8306829 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -4,7 +4,7 @@ set "PREFIX=%BASE_PATH%" set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" -echo "Unpacking payload..." +echo Unpacking payload... "%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs From 81a75b486a3004d5eb7b09311760cd0b6be01a8f Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 10 Feb 2026 10:33:29 -0500 Subject: [PATCH 15/37] Update pre_uninstall.bat --- constructor/briefcase/pre_uninstall.bat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 39bdc6105..1468b53cd 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -17,4 +17,4 @@ if errorlevel 1 ( if errorlevel 1 ( echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. exit /b %errorlevel% -) \ No newline at end of file +) From 33006367af8555999bc5c7737713e6c9df9c6d1c Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 08:25:00 -0500 Subject: [PATCH 16/37] Add logging --- .github/workflows/main.yml | 2 +- constructor/briefcase.py | 6 ++ constructor/briefcase/pre_uninstall.bat | 46 +++++++++++--- constructor/briefcase/run_installation.bat | 57 ++++++++++++++--- tests/test_examples.py | 71 ++++++++++++++++++++-- 5 files changed, 157 insertions(+), 25 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e9203347a..fc203ceec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -153,7 +153,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 diff --git a/constructor/briefcase.py b/constructor/briefcase.py index fa43187c1..433f86197 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -4,6 +4,7 @@ import functools import logging +import os import re import shutil import sys @@ -249,6 +250,10 @@ class Payload: archive_name: str = "payload.tar.gz" conda_exe_name: str = "_conda.exe" + # There might be other ways we want to enable `add_debug_logging`, but it has proven + # very useful at least for the CI environment. + add_debug_logging: bool = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" + @functools.cached_property def root(self) -> Path: """Create root upon first access and cache it.""" @@ -332,6 +337,7 @@ def render_templates(self) -> list[str: TemplateFile]: context: dict[str, str] = { "archive_name": self.archive_name, "conda_exe_name": self.conda_exe_name, + "add_debug": self.add_debug_logging, } # Render the templates now using jinja and the defined context diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 1468b53cd..e596bf8d7 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -1,20 +1,46 @@ -set "INSTDIR=%cd%" +@echo {{ 'on' if add_debug else 'off' }} +setlocal + +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 }}" +{%- if add_debug %} +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +set "LOG=%TEMP%\%APPNAME%-preuninstall.log" + +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 %} + +{%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} +{%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} + +rem Sanity checks +if not exist "%CONDA_EXE%" ( + {% if add_debug %}echo [ERROR] CONDA_EXE not found: "%CONDA_EXE%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 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%" - -"%CONDA_EXE%" menuinst --prefix "%BASE_PATH%" --remove -if errorlevel 1 ( - echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. - exit /b %errorlevel% -) -"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%" if errorlevel 1 ( - echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. - exit /b %errorlevel% + {% if add_debug %}echo [ERROR] Failed to create "%PAYLOAD_TAR%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b %errorlevel% ) + +"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"{{ redir }} +if errorlevel 1 ( {{ dump_and_exit }} ) + +exit /b 0 diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 1c8306829..3bb15a100 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,20 +1,61 @@ -set "INSTDIR=%cd%" +@echo {{ 'on' if add_debug else 'off' }} +setlocal + +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 }}" -echo Unpacking payload... -"%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" -"%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs - +set CONDA_EXTRA_SAFETY_CHECKS=no 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_ROOT_PREFIX=%BASE_PATH%" set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" -"%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" +{%- if add_debug %} +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +set "LOG=%TEMP%\%APPNAME%-postinstall.log" + +echo ==== run_installation 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%" +{%- endif %} + +{%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} +{%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} + +rem Sanity checks +if not exist "%CONDA_EXE%" ( + {% if add_debug %}echo [ERROR] CONDA_EXE not found: "%CONDA_EXE%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 10 +) +if not exist "%PAYLOAD_TAR%" ( + {% if add_debug %}echo [ERROR] PAYLOAD_TAR not found: "%PAYLOAD_TAR%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 11 +) + +echo Unpacking payload... +rem "%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%"{{ redir }} +"%CONDA_EXE%" constructor --prefix "%INSTDIR%" --extract-tarball < "%PAYLOAD_TAR%"{{ redir }} +if errorlevel 1 ( {{ dump_and_exit }} ) + +rem "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }} +"%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }} +if errorlevel 1 ( {{ dump_and_exit }} ) + +if not exist "%BASE_PATH%" ( + {% if add_debug %}echo [ERROR] "%BASE_PATH%" not found! >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 12 +) + +"%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"{{ redir }} +if errorlevel 1 ( {{ dump_and_exit }} ) rem Delete the payload to save disk space. rem A truncated placeholder of 0 bytes is recreated during uninstall diff --git a/tests/test_examples.py b/tests/test_examples.py index a8315eb5c..dd4ea8c4a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -339,10 +339,11 @@ def _sentinel_file_checks(example_path, install_dir): def is_admin() -> bool: - try: - return ctypes.windll.shell32.IsUserAnAdmin() - except Exception: - return False + #try: + # return ctypes.windll.shell32.IsUserAnAdmin() + #except Exception: + # return False + return False def calculate_msi_install_path(installer: Path) -> Path: @@ -355,6 +356,7 @@ def calculate_msi_install_path(installer: Path) -> Path: else: local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) root_dir = Path(local_dir) / "Programs" + root_dir.mkdir(parents=True, exist_ok=True) assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors return Path(root_dir) / dir_name @@ -388,8 +390,13 @@ def _run_installer_msi( "/qn", ] - log_path = Path(os.environ.get("TEMP")) / (install_dir.name + ".log") + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") cmd.extend(["/L*V", str(log_path)]) + + post_install_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-postinstall.log") + if post_install_log.exists(): + os.remove(post_install_log) + try: process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) except subprocess.CalledProcessError as e: @@ -401,9 +408,23 @@ def _run_installer_msi( log_path.read_text(encoding="utf-16", errors="replace")[-15000:] ) # last 15k chars print(f"\n=== MSI LOG {log_path} END ===") + if post_install_log.exists(): + print(f"\n=== MSI POST INSTALL LOG {post_install_log} START ===") + print(post_install_log.read_text(encoding="utf-8", errors="replace")) + print(f"\n=== MSI POST INSTALL LOG {log_path} END ===") + else: + print(f"\n(post-install log not found at {post_install_log})\n") raise e if check: print("A check for MSI Installers not yet implemented") + + # Sanity check the installation directory + expected_items = [install_dir / "base", install_dir / "base" / "conda-meta", install_dir / "_conda.exe"] + missing_items = [item for item in expected_items if not item.exists()] + if missing_items: + missing_items_string = "\n".join(missing_items) + raise Exception(f"Sanity check failed, unable to find expected paths: \n{missing_items_string}") + return process @@ -419,7 +440,45 @@ def _run_uninstaller_msi( str(installer), "/qn", ] - process = _execute(cmd, timeout=timeout, check=check) + + # Temporary debug + print("base exists:", (install_dir / "base").exists()) + print("conda-meta exists:", (install_dir / "base" / "conda-meta").exists()) + print("conda-meta history exists:", (install_dir / "base" / "conda-meta" / "history").exists()) + + print(f"\n=== Top-level contents of {install_dir} ===") + for p in sorted(install_dir.iterdir()): + kind = "DIR " if p.is_dir() else "FILE" + print(f"{kind:4} {p.name}") + + # Add MSI verbose log file + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") + cmd.extend(["/L*V", str(log_path)]) + + # Add log file for pre_uninstall.bat + pre_uninstall_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-preuninstall.log") + if pre_uninstall_log.exists(): + os.remove(pre_uninstall_log) + try: + process = _execute(cmd, installer_input = None, timeout=timeout, check=check) + except subprocess.CalledProcessError: + # Dump pre-uninstall log + if pre_uninstall_log.exists(): + print(f"\n=== PRE-UNINSTALL LOG {pre_uninstall_log} START ===") + print(pre_uninstall_log.read_text(encoding="utf-8", errors="replace")[-15000:]) + print(f"=== PRE-UNINSTALL LOG {pre_uninstall_log} END ===\n") + else: + print(f"\n(pre-uninstall log not found at {pre_uninstall_log})\n") + + # Dump MSI uninstall log (often UTF-16) + if log_path.exists(): + print(f"\n=== MSI UNINSTALL LOG {log_path} START ===") + print(log_path.read_text(encoding="utf-16", errors="replace")[-15000:]) # last 15k chars + print(f"=== MSI UNINSTALL LOG {log_path} END ===\n") + else: + print(f"\n(msi uninstall log not found at {log_path})\n") + raise + if check: # TODO: # Check log and if there are remaining files, similar to the exe installers From a813e17cd293414e46a1d5184d5fd2c2ff24dce9 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 09:50:52 -0500 Subject: [PATCH 17/37] Improve log handling for msi tests --- tests/test_examples.py | 196 ++++++++++++++++++++++++++--------------- 1 file changed, 123 insertions(+), 73 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index dd4ea8c4a..bc5ec7246 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,6 +1,5 @@ from __future__ import annotations -import ctypes import getpass import json import os @@ -10,6 +9,7 @@ import time import warnings import xml.etree.ElementTree as ET +from dataclasses import dataclass from datetime import timedelta from functools import cache from pathlib import Path @@ -338,30 +338,99 @@ def _sentinel_file_checks(example_path, install_dir): ) -def is_admin() -> bool: - #try: - # return ctypes.windll.shell32.IsUserAnAdmin() - #except Exception: - # return False - return False - - def calculate_msi_install_path(installer: Path) -> Path: """This is a temporary solution for now since we cannot choose the install location ourselves. Installers are named --Windows-x86_64.msi. """ dir_name = installer.name.replace("-Windows-x86_64.msi", "").replace("-", " ") - if is_admin(): - root_dir = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) - else: - local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) - root_dir = Path(local_dir) / "Programs" - root_dir.mkdir(parents=True, exist_ok=True) + local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) + root_dir = Path(local_dir) / "Programs" + root_dir.mkdir(parents=True, exist_ok=True) assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors return Path(root_dir) / dir_name +def handle_exception_and_error_out( + failure: InstallationFailure | UninstallationFailure, original_exception: BaseException +) -> None: + """Print failure context (including logs) and re-raise with exception chaining.""" + print(failure.read_text()) + raise failure from original_exception + + +def _read_briefcase_log_tail(path: Path, last_digits: int) -> str: + """Helper function to read logs from installers created with briefcase. + The encoding can vary between the different logs. + """ + if not path or not path.exists(): + return f"(log not found: {path})" + + # Try UTF-16 first (MSI logs), fallback to UTF-8 + try: + text = path.read_text(encoding="utf-16", errors="replace") + except UnicodeError: + text = path.read_text(encoding="utf-8", errors="replace") + + return text[last_digits:] + + +@dataclass +class InstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + post_install_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.post_install_log: + parts.append( + f"\n=== MSI LOG POST INSTALL: {self.post_install_log} ===\n" + + _read_briefcase_log_tail(self.post_install_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + +@dataclass +class UninstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + pre_uninstall_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.pre_uninstall_log: + parts.append( + f"\n=== MSI LOG PRE UNINSTALL: {self.pre_uninstall_log} ===\n" + + _read_briefcase_log_tail(self.pre_uninstall_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + def _run_installer_msi( installer: Path, install_dir: Path, @@ -390,40 +459,40 @@ def _run_installer_msi( "/qn", ] + # Prepare logging + post_install_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-postinstall.log") log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") + for log_file in [post_install_log, log_path]: + if log_file.exists(): + log_file.remove() cmd.extend(["/L*V", str(log_path)]) - post_install_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-postinstall.log") - if post_install_log.exists(): - os.remove(post_install_log) - + # Run installer and handle errors/logs if necessary try: process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) except subprocess.CalledProcessError as e: - if log_path.exists(): - # When running on the CI system, it tries to decode a UTF-16 log file as UTF-8, - # therefore we need to specify encoding before printing. - print(f"\n=== MSI LOG {log_path} START ===") - print( - log_path.read_text(encoding="utf-16", errors="replace")[-15000:] - ) # last 15k chars - print(f"\n=== MSI LOG {log_path} END ===") - if post_install_log.exists(): - print(f"\n=== MSI POST INSTALL LOG {post_install_log} START ===") - print(post_install_log.read_text(encoding="utf-8", errors="replace")) - print(f"\n=== MSI POST INSTALL LOG {log_path} END ===") - else: - print(f"\n(post-install log not found at {post_install_log})\n") - raise e - if check: - print("A check for MSI Installers not yet implemented") + handle_exception_and_error_out( + InstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + post_install_log=post_install_log, + ), + original_exception=e, + ) # Sanity check the installation directory - expected_items = [install_dir / "base", install_dir / "base" / "conda-meta", install_dir / "_conda.exe"] + expected_items = [ + install_dir / "base", + install_dir / "base" / "conda-meta", + install_dir / "_conda.exe", + ] missing_items = [item for item in expected_items if not item.exists()] if missing_items: missing_items_string = "\n".join(missing_items) - raise Exception(f"Sanity check failed, unable to find expected paths: \n{missing_items_string}") + raise Exception( + f"Sanity check failed, unable to find expected paths: \n{missing_items_string}" + ) return process @@ -441,50 +510,31 @@ def _run_uninstaller_msi( "/qn", ] - # Temporary debug - print("base exists:", (install_dir / "base").exists()) - print("conda-meta exists:", (install_dir / "base" / "conda-meta").exists()) - print("conda-meta history exists:", (install_dir / "base" / "conda-meta" / "history").exists()) - - print(f"\n=== Top-level contents of {install_dir} ===") - for p in sorted(install_dir.iterdir()): - kind = "DIR " if p.is_dir() else "FILE" - print(f"{kind:4} {p.name}") - - # Add MSI verbose log file + # Prepare logging + pre_uninstall_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-preuninstall.log") log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") + for log_file in [pre_uninstall_log, log_path]: + if log_file.exists(): + log_file.remove() cmd.extend(["/L*V", str(log_path)]) - # Add log file for pre_uninstall.bat - pre_uninstall_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-preuninstall.log") - if pre_uninstall_log.exists(): - os.remove(pre_uninstall_log) try: - process = _execute(cmd, installer_input = None, timeout=timeout, check=check) - except subprocess.CalledProcessError: - # Dump pre-uninstall log - if pre_uninstall_log.exists(): - print(f"\n=== PRE-UNINSTALL LOG {pre_uninstall_log} START ===") - print(pre_uninstall_log.read_text(encoding="utf-8", errors="replace")[-15000:]) - print(f"=== PRE-UNINSTALL LOG {pre_uninstall_log} END ===\n") - else: - print(f"\n(pre-uninstall log not found at {pre_uninstall_log})\n") - - # Dump MSI uninstall log (often UTF-16) - if log_path.exists(): - print(f"\n=== MSI UNINSTALL LOG {log_path} START ===") - print(log_path.read_text(encoding="utf-16", errors="replace")[-15000:]) # last 15k chars - print(f"=== MSI UNINSTALL LOG {log_path} END ===\n") - else: - print(f"\n(msi uninstall log not found at {log_path})\n") - raise + process = _execute(cmd, installer_input=None, timeout=timeout, check=check) + except subprocess.CalledProcessError as e: + handle_exception_and_error_out( + UninstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + post_install_log=pre_uninstall_log, + ), + original_exception=e, + ) if check: # TODO: # Check log and if there are remaining files, similar to the exe installers pass - # This is temporary until uninstallation works fine - shutil.rmtree(str(install_dir), ignore_errors=True) return process From d64e6ab02b20d53bfab20ef15bb2d498ff988504 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 11:58:19 -0500 Subject: [PATCH 18/37] Add register_envs --- constructor/briefcase.py | 1 + constructor/briefcase/run_installation.bat | 1 + examples/register_envs/construct.yaml | 2 +- tests/test_examples.py | 2 -- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 433f86197..bedb2b5be 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -338,6 +338,7 @@ def render_templates(self) -> list[str: TemplateFile]: "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(), } # Render the templates now using jinja and the defined context diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 3bb15a100..4ee39d577 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -12,6 +12,7 @@ set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" set CONDA_EXTRA_SAFETY_CHECKS=no set CONDA_PROTECT_FROZEN_ENVS=0 +set CONDA_REGISTER_ENVS={{ register_envs }} set CONDA_SAFETY_CHECKS=disabled set "CONDA_ROOT_PREFIX=%BASE_PATH%" set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" diff --git a/examples/register_envs/construct.yaml b/examples/register_envs/construct.yaml index 31d72c9f6..760757a05 100644 --- a/examples/register_envs/construct.yaml +++ b/examples/register_envs/construct.yaml @@ -3,7 +3,7 @@ name: RegisterEnvs version: 1.0.0 -installer_type: {{ "exe" if os.name == "nt" else "all" }} +installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ specs: diff --git a/tests/test_examples.py b/tests/test_examples.py index bc5ec7246..c23335fc2 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1160,8 +1160,6 @@ def test_register_envs(tmp_path, request): """Verify that 'register_envs: False' results in the environment not being registered.""" input_path = _example_path("register_envs") for installer, install_dir in create_installer(input_path, tmp_path): - if installer.suffix == ".msi": - raise NotImplementedError("Test for 'register_envs' not yet implemented for MSI") _run_installer(input_path, installer, install_dir, request=request) environments_txt = Path("~/.conda/environments.txt").expanduser().read_text() assert str(install_dir) not in environments_txt From 0f4d68db60ae3fca42cf5ad92c8de0a52bd9f572 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 12:50:50 -0500 Subject: [PATCH 19/37] Fix syntax error with remove --- tests/test_examples.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index c23335fc2..5e4e602e5 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -464,7 +464,7 @@ def _run_installer_msi( log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") for log_file in [post_install_log, log_path]: if log_file.exists(): - log_file.remove() + os.remove(log_file) cmd.extend(["/L*V", str(log_path)]) # Run installer and handle errors/logs if necessary @@ -515,7 +515,7 @@ def _run_uninstaller_msi( log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") for log_file in [pre_uninstall_log, log_path]: if log_file.exists(): - log_file.remove() + os.remove(log_file) cmd.extend(["/L*V", str(log_path)]) try: From d64a807f2df81a7e16724dcd4e053848a4b3ddc2 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 13:51:19 -0500 Subject: [PATCH 20/37] Ensure .exe test not running for MSI --- tests/test_examples.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 5e4e602e5..52894c7ec 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1729,6 +1729,9 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): input_path = _example_path("register_envs") # The specific example we use here is not important options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + # This test is intended for .exe installers only + pass _run_installer( input_path, installer, From c3d51fd583fab231784f6a2d0de7d0cc0dc254a8 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Feb 2026 14:44:21 -0500 Subject: [PATCH 21/37] Properly disable test for MSI --- tests/test_examples.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 52894c7ec..da5b2aee6 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1730,8 +1730,7 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): if installer.suffix == ".msi": - # This test is intended for .exe installers only - pass + pytest.skip("Test is only applicable for NSIS based installers.") _run_installer( input_path, installer, From d9fa4f8ce93230d3cb53d094afe45353ab4d15c4 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 17 Feb 2026 08:56:46 -0500 Subject: [PATCH 22/37] Add more tests and another check errorlevel --- constructor/briefcase/run_installation.bat | 3 +++ tests/test_briefcase.py | 27 +++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 4ee39d577..a059c1121 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -62,3 +62,6 @@ rem Delete the payload to save disk space. rem A truncated placeholder of 0 bytes is recreated during uninstall rem because MSI expects the file to be there to clean the registry. del "%PAYLOAD_TAR%" +if errorlevel 1 ( {{ dump_and_exit }} ) + +exit /b 0 diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index e57911b43..5143d5f80 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -238,10 +238,12 @@ def test_payload_conda_exe(): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") -def test_payload_templates_are_rendered(): +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_payload_templates_are_rendered(debug_logging): """Test that templates are rendered when the payload is prepared.""" info = mock_info.copy() payload = Payload(info) + payload.add_debug_logging = debug_logging rendered_templates = payload.render_templates() assert len(rendered_templates) == 2 # There should be at least two files for f in rendered_templates: @@ -250,3 +252,26 @@ def test_payload_templates_are_rendered(): assert "{{" not in text and "}}" not in text assert "{%" not in text and "%}" not in text assert "{#" not in text and "#}" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_templates_debug_mode(debug_logging): + """Test that debug logging affects template generation.""" + info = mock_info.copy() + payload = Payload(info) + payload.add_debug_logging = debug_logging + rendered_templates = payload.render_templates() + assert len(rendered_templates) == 2 # There should be at least two files + + for f in rendered_templates: + assert f.dst.is_file() + + with open(f.dst) as open_file: + lines = open_file.readlines() + + # Check the first line. + expected = "@echo on\n" if debug_logging else "@echo off\n" + assert lines[0] == expected + # If debug_logging is True, we expect to find %LOG%, otherwise not. + assert debug_logging == any("%LOG%" in line for line in lines) From 1d40e95e29461dfc1b47723ab39e99e72f8297fd Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Feb 2026 09:38:43 -0500 Subject: [PATCH 23/37] Make logging more neat --- constructor/briefcase/pre_uninstall.bat | 15 +++++++++++---- constructor/briefcase/run_installation.bat | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index e596bf8d7..900d12a41 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -1,6 +1,14 @@ @echo {{ 'on' if add_debug else 'off' }} setlocal +{%- macro error_block(message, code) -%} +echo [ERROR] {{ message }} +{%- if add_debug %} +>> "%LOG%" echo [ERROR] {{ message }} +{%- endif %} +exit /b {{ code }} +{%- endmacro -%} + rem Assign INSTDIR and normalize the path set "INSTDIR=%~dp0.." for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" @@ -28,16 +36,15 @@ echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" {%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} {%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} -rem Sanity checks +rem Consistency checks if not exist "%CONDA_EXE%" ( - {% if add_debug %}echo [ERROR] CONDA_EXE not found: "%CONDA_EXE%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 10 + {{ 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 ( - {% if add_debug %}echo [ERROR] Failed to create "%PAYLOAD_TAR%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b %errorlevel% + {{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }} ) "%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"{{ redir }} diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index a059c1121..85e8bbd4f 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,6 +1,14 @@ @echo {{ 'on' if add_debug else 'off' }} setlocal +{%- macro error_block(message, code) -%} +echo [ERROR] {{ message }} +{%- if add_debug %} +>> "%LOG%" echo [ERROR] {{ message }} +{%- endif %} +exit /b {{ code }} +{%- endmacro -%} + rem Assign INSTDIR and normalize the path set "INSTDIR=%~dp0.." for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" @@ -34,12 +42,12 @@ echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" {%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} {%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} -rem Sanity checks +rem Consistency checks if not exist "%CONDA_EXE%" ( - {% if add_debug %}echo [ERROR] CONDA_EXE not found: "%CONDA_EXE%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 10 + {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} ) if not exist "%PAYLOAD_TAR%" ( - {% if add_debug %}echo [ERROR] PAYLOAD_TAR not found: "%PAYLOAD_TAR%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 11 + {{ error_block('PAYLOAD_TAR not found: "%PAYLOAD_TAR%"', 11) }} ) echo Unpacking payload... @@ -52,7 +60,7 @@ rem "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redi if errorlevel 1 ( {{ dump_and_exit }} ) if not exist "%BASE_PATH%" ( - {% if add_debug %}echo [ERROR] "%BASE_PATH%" not found! >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 12 + {{ error_block('"%BASE_PATH%" not found!', 12) }} ) "%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"{{ redir }} From f39a787ddf829d484108408d18a8ee14a62266ab Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 19 Feb 2026 08:16:45 -0500 Subject: [PATCH 24/37] Removed all use of 'sanity' --- tests/test_examples.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index da5b2aee6..f3e5ed30d 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -347,7 +347,7 @@ def calculate_msi_install_path(installer: Path) -> Path: root_dir = Path(local_dir) / "Programs" root_dir.mkdir(parents=True, exist_ok=True) - assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors + assert root_dir.is_dir() # Consistency check to avoid strange unexpected errors return Path(root_dir) / dir_name @@ -481,19 +481,6 @@ def _run_installer_msi( original_exception=e, ) - # Sanity check the installation directory - expected_items = [ - install_dir / "base", - install_dir / "base" / "conda-meta", - install_dir / "_conda.exe", - ] - missing_items = [item for item in expected_items if not item.exists()] - if missing_items: - missing_items_string = "\n".join(missing_items) - raise Exception( - f"Sanity check failed, unable to find expected paths: \n{missing_items_string}" - ) - return process From 60f85bb3f3034b5739f18f8fff53627d7bd86fc0 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 19 Feb 2026 10:34:32 -0500 Subject: [PATCH 25/37] Updated test for MSI (remove pytest.skip) --- tests/test_examples.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index f3e5ed30d..d12f4602d 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1717,7 +1717,7 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): if installer.suffix == ".msi": - pytest.skip("Test is only applicable for NSIS based installers.") + continue _run_installer( input_path, installer, @@ -1728,17 +1728,17 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): options=options, ) - # Use the installer file name for the registry search - installer_file_name_parts = Path(installer).name.split("-") - name = installer_file_name_parts[0] - version = installer_file_name_parts[1] - partial_name = f"{name} {version}" + # Use the installer file name for the registry search + installer_file_name_parts = Path(installer).name.split("-") + name = installer_file_name_parts[0] + version = installer_file_name_parts[1] + partial_name = f"{name} {version}" - is_in_installed_apps_menu = _is_program_installed(partial_name) - _run_uninstaller_exe(install_dir) + is_in_installed_apps_menu = _is_program_installed(partial_name) + _run_uninstaller_exe(install_dir) - # If no_registry=0 we expect is_in_installed_apps_menu=True - # If no_registry=1 we expect is_in_installed_apps_menu=False - assert is_in_installed_apps_menu == (no_registry == 0), ( - f"Unable to find program '{partial_name}' in the 'Installed apps' menu" - ) + # If no_registry=0 we expect is_in_installed_apps_menu=True + # If no_registry=1 we expect is_in_installed_apps_menu=False + assert is_in_installed_apps_menu == (no_registry == 0), ( + f"Unable to find program '{partial_name}' in the 'Installed apps' menu" + ) From 8a32292eaa090381f414b5d448e6141386ccff7e Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 20 Feb 2026 08:59:20 -0500 Subject: [PATCH 26/37] Update to use CLI for newer conda-standalone --- constructor/briefcase/run_installation.bat | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 85e8bbd4f..c99ac36bf 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -51,12 +51,12 @@ if not exist "%PAYLOAD_TAR%" ( ) echo Unpacking payload... -rem "%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%"{{ redir }} -"%CONDA_EXE%" constructor --prefix "%INSTDIR%" --extract-tarball < "%PAYLOAD_TAR%"{{ redir }} +%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%"{{ redir }} +rem "%CONDA_EXE%" constructor --prefix "%INSTDIR%" --extract-tarball < "%PAYLOAD_TAR%"{{ redir }} if errorlevel 1 ( {{ dump_and_exit }} ) -rem "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }} "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }} +rem "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }} if errorlevel 1 ( {{ dump_and_exit }} ) if not exist "%BASE_PATH%" ( From 84b0136e0d84ae3da377024b6892854798dd778b Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 20 Feb 2026 10:30:21 -0500 Subject: [PATCH 27/37] Fix missing quote and properly use --log-file --- constructor/briefcase/pre_uninstall.bat | 9 +++++---- constructor/briefcase/run_installation.bat | 14 ++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 900d12a41..4ba8ef34c 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -1,13 +1,13 @@ @echo {{ 'on' if add_debug else 'off' }} setlocal -{%- macro error_block(message, code) -%} +{% macro error_block(message, code) %} echo [ERROR] {{ message }} {%- if add_debug %} >> "%LOG%" echo [ERROR] {{ message }} {%- endif %} exit /b {{ code }} -{%- endmacro -%} +{% endmacro %} rem Assign INSTDIR and normalize the path set "INSTDIR=%~dp0.." @@ -33,13 +33,14 @@ echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" "%CONDA_EXE%" --version >> "%LOG%" 2>&1 {%- endif %} -{%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} +{%- set conda_log = ' --log-file "%LOG%"' if add_debug else '' %} {%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} 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%" @@ -47,7 +48,7 @@ if errorlevel 1 ( {{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }} ) -"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"{{ redir }} +"%CONDA_EXE%"{{ conda_log }} constructor uninstall --prefix "%BASE_PATH%" if errorlevel 1 ( {{ dump_and_exit }} ) exit /b 0 diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index c99ac36bf..f22f8bccb 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,13 +1,13 @@ @echo {{ 'on' if add_debug else 'off' }} setlocal -{%- macro error_block(message, code) -%} +{% macro error_block(message, code) %} echo [ERROR] {{ message }} {%- if add_debug %} >> "%LOG%" echo [ERROR] {{ message }} {%- endif %} exit /b {{ code }} -{%- endmacro -%} +{% endmacro %} rem Assign INSTDIR and normalize the path set "INSTDIR=%~dp0.." @@ -39,7 +39,7 @@ echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" {%- endif %} -{%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} +{%- set conda_log = ' --log-file "%LOG%"' if add_debug else '' %} {%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} rem Consistency checks @@ -51,19 +51,17 @@ if not exist "%PAYLOAD_TAR%" ( ) echo Unpacking payload... -%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%"{{ redir }} -rem "%CONDA_EXE%" constructor --prefix "%INSTDIR%" --extract-tarball < "%PAYLOAD_TAR%"{{ redir }} +"%CONDA_EXE%"{{ conda_log }} constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" if errorlevel 1 ( {{ dump_and_exit }} ) -"%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }} -rem "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }} +"%CONDA_EXE%"{{ conda_log }} constructor extract --prefix "%BASE_PATH%" --conda-pkgs if errorlevel 1 ( {{ dump_and_exit }} ) if not exist "%BASE_PATH%" ( {{ error_block('"%BASE_PATH%" not found!', 12) }} ) -"%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"{{ redir }} +"%CONDA_EXE%"{{ conda_log }} install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" if errorlevel 1 ( {{ dump_and_exit }} ) rem Delete the payload to save disk space. From b501aab6ae1279541c7063e231616846316e35af Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 23 Feb 2026 12:27:30 -0500 Subject: [PATCH 28/37] Docstring formatting --- constructor/briefcase.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index bedb2b5be..44551147c 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -261,6 +261,7 @@ def root(self) -> Path: def remove(self, *, ignore_errors: bool = True) -> None: """Remove the root of the payload. + This function requires some extra care due to the root being a cached property. """ root = getattr(self, "root", None) @@ -275,8 +276,7 @@ def remove(self, *, ignore_errors: bool = True) -> None: pass def prepare(self) -> PayloadLayout: - """Prepares the payload. - """ + """Prepares the payload.""" root = self.root layout = self._create_layout(root) # Render the template files and add them to the necessary config field From c5237a345e7e67bb0c4a0cdd65be399ec85937e8 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 23 Feb 2026 12:28:12 -0500 Subject: [PATCH 29/37] pre-commit fix --- constructor/briefcase.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 44551147c..bd6d39cd5 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -222,6 +222,7 @@ def create_install_options_list(info: dict) -> list[dict]: return options + @dataclass(frozen=True) class TemplateFile: """A specification for a single Jinja template to an output file.""" @@ -314,14 +315,14 @@ def make_archive(self, src: Path, dst: Path) -> Path: archive_path = dst / self.archive_name - archive_type = archive_path.suffix[1:] # since suffix starts with '.' + 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[str: TemplateFile]: + def render_templates(self) -> list[str:TemplateFile]: """Render the configured templates under the payload root.""" templates = [ TemplateFile( @@ -374,7 +375,6 @@ def write_pyproject_toml(self, layout: PayloadLayout) -> None: }, } - # Add optional content if "company" in self.info: config["author"] = self.info["company"] From 2c40ad5f2dfa55598a6fa210ceea13756c75125e Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 23 Feb 2026 13:26:42 -0500 Subject: [PATCH 30/37] Hopefully fix issue with conda-standalone canary --- .github/workflows/main.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fc203ceec..5f7e8ff93 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 From 6c542cd4907859ea7abd2b9958514a64362b015c Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 23 Feb 2026 13:33:25 -0500 Subject: [PATCH 31/37] Fix typo in workflow --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5f7e8ff93..2af2cd200 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -123,7 +123,7 @@ jobs: elif [[ "${{ matrix.conda-standalone }}" == "conda-standalone-onedir" ]]; then # 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*" + 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 From 26ebcb308d6f26a0426af5ee4e7ef696b7957852 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 24 Feb 2026 08:38:15 -0500 Subject: [PATCH 32/37] Automatically create 'dst' --- constructor/briefcase.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index bd6d39cd5..60cf55721 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -296,7 +296,8 @@ def prepare(self) -> PayloadLayout: def make_archive(self, src: Path, dst: Path) -> Path: """Create an archive of the directory 'src'. - The inputs 'src' and 'dst' must both be existing directories. + The inputs '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. @@ -310,8 +311,7 @@ def make_archive(self, src: Path, dst: Path) -> Path: """ if not src.is_dir(): raise NotADirectoryError(src) - if not dst.is_dir(): - raise NotADirectoryError(dst) + dst.mkdir(parents=True, exist_ok=True) archive_path = dst / self.archive_name From 8c8cc7b456fd39ece7359f751c9d2c066d5fb092 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 24 Feb 2026 08:48:11 -0500 Subject: [PATCH 33/37] Remove TemplateFile --- constructor/briefcase.py | 45 ++++++++++++++-------------------------- tests/test_briefcase.py | 8 +++---- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 60cf55721..36b5b86a5 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -222,15 +222,6 @@ def create_install_options_list(info: dict) -> list[dict]: return options - -@dataclass(frozen=True) -class TemplateFile: - """A specification for a single Jinja template to an output file.""" - - src: Path - dst: Path - - @dataclass(frozen=True) class PayloadLayout: """A data class with purpose to contain the payload layout.""" @@ -322,18 +313,14 @@ def make_archive(self, src: Path, dst: Path) -> Path: shutil.rmtree(src) return archive_path - def render_templates(self) -> list[str:TemplateFile]: - """Render the configured templates under the payload root.""" - templates = [ - TemplateFile( - src=BRIEFCASE_DIR / "run_installation.bat", - dst=self.root / "run_installation.bat", - ), - TemplateFile( - src=BRIEFCASE_DIR / "pre_uninstall.bat", - dst=self.root / "pre_uninstall.bat", - ), - ] + 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"), + } context: dict[str, str] = { "archive_name": self.archive_name, @@ -343,14 +330,14 @@ def render_templates(self) -> list[str:TemplateFile]: } # Render the templates now using jinja and the defined context - for f in templates: - 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="\r\n") - - return templates + 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, layout: PayloadLayout) -> None: name, version = get_name_version(self.info) diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 5143d5f80..c4667cc33 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -247,8 +247,8 @@ def test_payload_templates_are_rendered(debug_logging): rendered_templates = payload.render_templates() assert len(rendered_templates) == 2 # There should be at least two files for f in rendered_templates: - assert f.dst.is_file() - text = f.dst.read_text(encoding="utf-8") + assert f.is_file() + text = f.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 @@ -265,9 +265,9 @@ def test_templates_debug_mode(debug_logging): assert len(rendered_templates) == 2 # There should be at least two files for f in rendered_templates: - assert f.dst.is_file() + assert f.is_file() - with open(f.dst) as open_file: + with open(f) as open_file: lines = open_file.readlines() # Check the first line. From 621a5458550c6aed51b01eff15e8901f33b74df7 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 24 Feb 2026 08:49:15 -0500 Subject: [PATCH 34/37] Fix docstring --- constructor/briefcase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 36b5b86a5..cc2fc3179 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -287,7 +287,7 @@ def prepare(self) -> PayloadLayout: def make_archive(self, src: Path, dst: Path) -> Path: """Create an archive of the directory 'src'. - The inputs 'src' must be an existing directory. + 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. From 6312aa7f2bc7189be214d344e9ae6f1f14fa4daa Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 24 Feb 2026 08:49:52 -0500 Subject: [PATCH 35/37] FIx pre-commit --- constructor/briefcase.py | 1 + 1 file changed, 1 insertion(+) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index cc2fc3179..caa7dc0fc 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -222,6 +222,7 @@ def create_install_options_list(info: dict) -> list[dict]: return options + @dataclass(frozen=True) class PayloadLayout: """A data class with purpose to contain the payload layout.""" From 3c910556a824f67da336722d3a90dbf0a54a2152 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 24 Feb 2026 13:48:47 -0500 Subject: [PATCH 36/37] Always log to file --- constructor/briefcase.py | 5 ++--- constructor/briefcase/pre_uninstall.bat | 11 +++-------- constructor/briefcase/run_installation.bat | 21 ++++++++------------- tests/test_briefcase.py | 2 -- 4 files changed, 13 insertions(+), 26 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index caa7dc0fc..9e3086b8c 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -243,9 +243,8 @@ class Payload: archive_name: str = "payload.tar.gz" conda_exe_name: str = "_conda.exe" - # There might be other ways we want to enable `add_debug_logging`, but it has proven - # very useful at least for the CI environment. - add_debug_logging: bool = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" + # Enable additional log output during pre/post uninstall/install. + add_debug_logging: bool = False @functools.cached_property def root(self) -> Path: diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index 4ba8ef34c..b295c5553 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -3,9 +3,7 @@ setlocal {% macro error_block(message, code) %} echo [ERROR] {{ message }} -{%- if add_debug %} >> "%LOG%" echo [ERROR] {{ message }} -{%- endif %} exit /b {{ code }} {% endmacro %} @@ -18,11 +16,11 @@ set "PREFIX=%BASE_PATH%" set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" -{%- if add_debug %} rem Get the name of the install directory for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" set "LOG=%TEMP%\%APPNAME%-preuninstall.log" +{%- if add_debug %} echo ==== pre_uninstall start ==== >> "%LOG%" echo SCRIPT=%~f0 >> "%LOG%" echo CWD=%CD% >> "%LOG%" @@ -33,9 +31,6 @@ echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" "%CONDA_EXE%" --version >> "%LOG%" 2>&1 {%- endif %} -{%- set conda_log = ' --log-file "%LOG%"' if add_debug else '' %} -{%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} - rem Consistency checks if not exist "%CONDA_EXE%" ( {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} @@ -48,7 +43,7 @@ if errorlevel 1 ( {{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }} ) -"%CONDA_EXE%"{{ conda_log }} constructor uninstall --prefix "%BASE_PATH%" -if errorlevel 1 ( {{ dump_and_exit }} ) +"%CONDA_EXE%" --log-file "%LOG%" constructor uninstall --prefix "%BASE_PATH%" +if errorlevel 1 ( exit /b %errorlevel% ) exit /b 0 diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index f22f8bccb..ed6b85382 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -3,9 +3,7 @@ setlocal {% macro error_block(message, code) %} echo [ERROR] {{ message }} -{%- if add_debug %} >> "%LOG%" echo [ERROR] {{ message }} -{%- endif %} exit /b {{ code }} {% endmacro %} @@ -25,11 +23,11 @@ set CONDA_SAFETY_CHECKS=disabled set "CONDA_ROOT_PREFIX=%BASE_PATH%" set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" -{%- if add_debug %} rem Get the name of the install directory for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" set "LOG=%TEMP%\%APPNAME%-postinstall.log" +{%- if add_debug %} echo ==== run_installation start ==== >> "%LOG%" echo SCRIPT=%~f0 >> "%LOG%" echo CWD=%CD% >> "%LOG%" @@ -39,9 +37,6 @@ echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" {%- endif %} -{%- set conda_log = ' --log-file "%LOG%"' if add_debug else '' %} -{%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} - rem Consistency checks if not exist "%CONDA_EXE%" ( {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} @@ -51,23 +46,23 @@ if not exist "%PAYLOAD_TAR%" ( ) echo Unpacking payload... -"%CONDA_EXE%"{{ conda_log }} constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" -if errorlevel 1 ( {{ dump_and_exit }} ) +"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" +if errorlevel 1 ( exit /b %errorlevel% ) -"%CONDA_EXE%"{{ conda_log }} constructor extract --prefix "%BASE_PATH%" --conda-pkgs -if errorlevel 1 ( {{ dump_and_exit }} ) +"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs +if errorlevel 1 ( exit /b %errorlevel% ) if not exist "%BASE_PATH%" ( {{ error_block('"%BASE_PATH%" not found!', 12) }} ) -"%CONDA_EXE%"{{ conda_log }} install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" -if errorlevel 1 ( {{ dump_and_exit }} ) +"%CONDA_EXE%" --log-file "%LOG%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%" +if errorlevel 1 ( exit /b %errorlevel% ) rem Delete the payload to save disk space. rem A truncated placeholder of 0 bytes is recreated during uninstall rem because MSI expects the file to be there to clean the registry. del "%PAYLOAD_TAR%" -if errorlevel 1 ( {{ dump_and_exit }} ) +if errorlevel 1 ( exit /b %errorlevel% ) exit /b 0 diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index c4667cc33..c521cdf33 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -273,5 +273,3 @@ def test_templates_debug_mode(debug_logging): # Check the first line. expected = "@echo on\n" if debug_logging else "@echo off\n" assert lines[0] == expected - # If debug_logging is True, we expect to find %LOG%, otherwise not. - assert debug_logging == any("%LOG%" in line for line in lines) From 556705a7590900ef6edb2166cff3991c52520bd6 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 24 Feb 2026 14:05:28 -0500 Subject: [PATCH 37/37] Generalize install/uninstall logs and move into INSTDIR --- constructor/briefcase/pre_uninstall.bat | 6 +++++- constructor/briefcase/run_installation.bat | 2 +- tests/test_examples.py | 16 ++++++++-------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat index b295c5553..0e2eb3889 100644 --- a/constructor/briefcase/pre_uninstall.bat +++ b/constructor/briefcase/pre_uninstall.bat @@ -18,7 +18,7 @@ set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" rem Get the name of the install directory for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" -set "LOG=%TEMP%\%APPNAME%-preuninstall.log" +set "LOG=%INSTDIR%\uninstall.log" {%- if add_debug %} echo ==== pre_uninstall start ==== >> "%LOG%" @@ -46,4 +46,8 @@ if errorlevel 1 ( "%CONDA_EXE%" --log-file "%LOG%" constructor uninstall --prefix "%BASE_PATH%" 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 diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index ed6b85382..11710108d 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -25,7 +25,7 @@ set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" rem Get the name of the install directory for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" -set "LOG=%TEMP%\%APPNAME%-postinstall.log" +set "LOG=%INSTDIR%\install.log" {%- if add_debug %} echo ==== run_installation start ==== >> "%LOG%" diff --git a/tests/test_examples.py b/tests/test_examples.py index d12f4602d..a3d87b115 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -460,11 +460,11 @@ def _run_installer_msi( ] # Prepare logging - post_install_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-postinstall.log") + post_install_log = install_dir / "install.log" + # Logging from MSI engine is handled separately log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") - for log_file in [post_install_log, log_path]: - if log_file.exists(): - os.remove(log_file) + if log_path.exists(): + os.remove(log_path) cmd.extend(["/L*V", str(log_path)]) # Run installer and handle errors/logs if necessary @@ -498,11 +498,11 @@ def _run_uninstaller_msi( ] # Prepare logging - pre_uninstall_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-preuninstall.log") + pre_uninstall_log = install_dir / "uninstall.log" + # Logging from MSI engine is handled separately log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") - for log_file in [pre_uninstall_log, log_path]: - if log_file.exists(): - os.remove(log_file) + if log_path.exists(): + os.remove(log_path) cmd.extend(["/L*V", str(log_path)]) try: