Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 126 additions & 41 deletions constructor/briefcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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):
Copy link
Owner Author

Choose a reason for hiding this comment

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

This was not deleted, but moved into class Payload

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)
Expand All @@ -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\\<payload.archive_name> containing 'foo' and all its contents.

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

archive_path = dst / self.archive_name

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:
Expand All @@ -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):
Expand Down
9 changes: 9 additions & 0 deletions constructor/briefcase/pre_uninstall.bat
Original file line number Diff line number Diff line change
@@ -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%"
14 changes: 11 additions & 3 deletions constructor/briefcase/run_installation.bat
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
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%"
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%"
53 changes: 50 additions & 3 deletions tests/test_briefcase.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import sys
import tarfile
from pathlib import Path

import pytest
Expand Down Expand Up @@ -154,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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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
Loading