diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 53acfecc..fa43187c 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -2,11 +2,13 @@ Logic to build installers using Briefcase. """ +import functools import logging import re import shutil import sys import sysconfig +import tarfile import tempfile from dataclasses import dataclass from pathlib import Path @@ -19,6 +21,7 @@ tomli_w = None # This file is only intended for Windows use from . import preconda +from .jinja import render_template from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist BRIEFCASE_DIR = Path(__file__).parent / "briefcase" @@ -218,36 +221,12 @@ 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.""" -# 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}") + src: Path + dst: Path @dataclass(frozen=True) @@ -267,29 +246,135 @@ class Payload: """ info: dict - root: Path | None = None + archive_name: str = "payload.tar.gz" + conda_exe_name: str = "_conda.exe" + + @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: - root = self._ensure_root() - self._write_pyproject(root) + """Prepares the payload. + """ + root = self.root layout = self._create_layout(root) + # Render the template files and add them to the necessary config field + self.render_templates() + 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) + + 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 remove(self) -> None: - shutil.rmtree(self.root) + 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 directory specified via 'src' is removed after successful creation. + Returns the path to the archive. + + Example: + payload = Payload(...) + foo = Path('foo') + bar = Path('bar') + targz = payload.make_archive(foo, bar) + This will create the file bar\\ 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 + + 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]: + """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", + ), + ] + + context: dict[str, str] = { + "archive_name": self.archive_name, + "conda_exe_name": self.conda_exe_name, + } + + # 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 + + 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), + "post_install_script": str(layout.root / "run_installation.bat"), + "pre_uninstall_script": str(layout.root / "pre_uninstall.bat"), + } + }, + } + - def _write_pyproject(self, root: Path) -> None: - write_pyproject_toml(root, self.info) + # Add optional content + if "company" in self.info: + config["author"] = self.info["company"] - def _ensure_root(self) -> Path: - if self.root is None: - self.root = Path(tempfile.mkdtemp()) - return self.root + # Finalize + (layout.root / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}})) + logger.debug(f"Created TOML file at: {layout.root}") def _create_layout(self, root: Path) -> PayloadLayout: """The layout is created as: @@ -315,7 +400,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 00000000..eb0bd983 --- /dev/null +++ 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 ec8cc35b..2eca7b7f 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 +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_PROTECT_FROZEN_ENVS=0 set "CONDA_ROOT_PREFIX=%BASE_PATH%" @@ -11,4 +14,9 @@ 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 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 dfb73423..e57911b4 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,6 +155,7 @@ 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() @@ -162,6 +164,9 @@ def test_prepare_payload(): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") 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() @@ -170,14 +175,39 @@ def test_payload_layout(): 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 + # 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") +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_archive(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 +219,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 +229,24 @@ 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.""" + info = mock_info.copy() + payload = Payload(info) + 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 + assert "{%" not in text and "%}" not in text + assert "{#" not in text and "#}" not in text