Skip to content

Commit 68a527e

Browse files
authored
Merge pull request #2 from lrandersson/dev-ra-798-2
Add jinja templating, payload as tar, tests
2 parents c4b11e8 + 556705a commit 68a527e

7 files changed

Lines changed: 453 additions & 99 deletions

File tree

.github/workflows/main.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ jobs:
121121
conda create -yqp "${{ runner.temp }}/conda-standalone-nightly" -c conda-canary/label/dev "conda-standalone=*=*single*"
122122
echo "CONSTRUCTOR_CONDA_EXE=${{ runner.temp }}/conda-standalone-nightly/standalone_conda/conda.exe" >> $GITHUB_ENV
123123
elif [[ "${{ matrix.conda-standalone }}" == "conda-standalone-onedir" ]]; then
124-
conda create -yqp "${{ runner.temp }}/conda-standalone-onedir" -c conda-canary/label/dev "conda-standalone=*=*onedir*"
124+
# Request a version newer than 25.1.1 due to an issue with newer versions getting deprioritized
125+
# because they are built with 'track_features'
126+
conda create -yqp "${{ runner.temp }}/conda-standalone-onedir" -c conda-canary/label/dev "conda-standalone>25.1.1=*onedir*"
125127
echo "CONSTRUCTOR_CONDA_EXE=${{ runner.temp }}/conda-standalone-onedir/standalone_conda/conda.exe" >> $GITHUB_ENV
126128
else
127129
conda activate constructor-dev
@@ -153,7 +155,7 @@ jobs:
153155
AZURE_SIGNTOOL_KEY_VAULT_URL: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_URL }}
154156
CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS: "${{ runner.temp }}/examples_artifacts"
155157
CONSTRUCTOR_SIGNTOOL_PATH: "C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x86/signtool.exe"
156-
CONSTRUCTOR_VERBOSE: 1
158+
CONSTRUCTOR_VERBOSE: 0
157159
run: |
158160
rm -rf coverage.json
159161
pytest -vv --cov=constructor --cov-branch tests/test_examples.py

constructor/briefcase.py

Lines changed: 122 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
Logic to build installers using Briefcase.
33
"""
44

5+
import functools
56
import logging
7+
import os
68
import re
79
import shutil
810
import sys
911
import sysconfig
12+
import tarfile
1013
import tempfile
1114
from dataclasses import dataclass
1215
from pathlib import Path
@@ -19,6 +22,7 @@
1922
tomli_w = None # This file is only intended for Windows use
2023

2124
from . import preconda
25+
from .jinja import render_template
2226
from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist
2327

2428
BRIEFCASE_DIR = Path(__file__).parent / "briefcase"
@@ -219,37 +223,6 @@ def create_install_options_list(info: dict) -> list[dict]:
219223
return options
220224

221225

222-
# Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja
223-
# template allows us to avoid escaping strings everywhere.
224-
def write_pyproject_toml(tmp_dir, info):
225-
name, version = get_name_version(info)
226-
bundle, app_name = get_bundle_app_name(info, name)
227-
228-
config = {
229-
"project_name": name,
230-
"bundle": bundle,
231-
"version": version,
232-
"license": get_license(info),
233-
"app": {
234-
app_name: {
235-
"formal_name": f"{info['name']} {info['version']}",
236-
"description": "", # Required, but not used in the installer.
237-
"external_package_path": EXTERNAL_PACKAGE_PATH,
238-
"use_full_install_path": False,
239-
"install_launcher": False,
240-
"post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"),
241-
"install_option": create_install_options_list(info),
242-
}
243-
},
244-
}
245-
246-
if "company" in info:
247-
config["author"] = info["company"]
248-
249-
(tmp_dir / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}}))
250-
logger.debug(f"Created TOML file at: {tmp_dir}")
251-
252-
253226
@dataclass(frozen=True)
254227
class PayloadLayout:
255228
"""A data class with purpose to contain the payload layout."""
@@ -267,29 +240,135 @@ class Payload:
267240
"""
268241

269242
info: dict
270-
root: Path | None = None
243+
archive_name: str = "payload.tar.gz"
244+
conda_exe_name: str = "_conda.exe"
245+
246+
# Enable additional log output during pre/post uninstall/install.
247+
add_debug_logging: bool = False
248+
249+
@functools.cached_property
250+
def root(self) -> Path:
251+
"""Create root upon first access and cache it."""
252+
return Path(tempfile.mkdtemp(prefix="payload-"))
253+
254+
def remove(self, *, ignore_errors: bool = True) -> None:
255+
"""Remove the root of the payload.
256+
257+
This function requires some extra care due to the root being a cached property.
258+
"""
259+
root = getattr(self, "root", None)
260+
if root is None:
261+
return
262+
shutil.rmtree(root, ignore_errors=ignore_errors)
263+
# Now we drop the cached value so next access will recreate if desired
264+
try:
265+
delattr(self, "root")
266+
except Exception:
267+
# delattr on a cached_property may raise on some versions / edge cases
268+
pass
271269

272270
def prepare(self) -> PayloadLayout:
273-
root = self._ensure_root()
274-
self._write_pyproject(root)
271+
"""Prepares the payload."""
272+
root = self.root
275273
layout = self._create_layout(root)
274+
# Render the template files and add them to the necessary config field
275+
self.render_templates()
276+
self.write_pyproject_toml(layout)
276277

277278
preconda.write_files(self.info, layout.base)
278279
preconda.copy_extra_files(self.info.get("extra_files", []), layout.external)
279280
self._stage_dists(layout)
280281
self._stage_conda(layout)
282+
283+
archive_path = self.make_archive(layout.base, layout.external)
284+
if not archive_path.exists():
285+
raise RuntimeError(f"Unexpected error, failed to create archive: {archive_path}")
281286
return layout
282287

283-
def remove(self) -> None:
284-
shutil.rmtree(self.root)
288+
def make_archive(self, src: Path, dst: Path) -> Path:
289+
"""Create an archive of the directory 'src'.
290+
The input 'src' must be an existing directory.
291+
If 'dst' does not exist, this function will create it.
292+
The directory specified via 'src' is removed after successful creation.
293+
Returns the path to the archive.
294+
295+
Example:
296+
payload = Payload(...)
297+
foo = Path('foo')
298+
bar = Path('bar')
299+
targz = payload.make_archive(foo, bar)
300+
This will create the file bar\\<payload.archive_name> containing 'foo' and all its contents.
301+
302+
"""
303+
if not src.is_dir():
304+
raise NotADirectoryError(src)
305+
dst.mkdir(parents=True, exist_ok=True)
306+
307+
archive_path = dst / self.archive_name
308+
309+
archive_type = archive_path.suffix[1:] # since suffix starts with '.'
310+
with tarfile.open(archive_path, mode=f"w:{archive_type}", compresslevel=1) as tar:
311+
tar.add(src, arcname=src.name)
312+
313+
shutil.rmtree(src)
314+
return archive_path
315+
316+
def render_templates(self) -> list[Path]:
317+
"""Render the configured templates under the payload root,
318+
returns a list of Paths to the rendered templates.
319+
"""
320+
templates = {
321+
Path(BRIEFCASE_DIR / "run_installation.bat"): Path(self.root / "run_installation.bat"),
322+
Path(BRIEFCASE_DIR / "pre_uninstall.bat"): Path(self.root / "pre_uninstall.bat"),
323+
}
324+
325+
context: dict[str, str] = {
326+
"archive_name": self.archive_name,
327+
"conda_exe_name": self.conda_exe_name,
328+
"add_debug": self.add_debug_logging,
329+
"register_envs": str(self.info.get("register_envs", True)).lower(),
330+
}
331+
332+
# Render the templates now using jinja and the defined context
333+
for src, dst in templates.items():
334+
if not src.exists():
335+
raise FileNotFoundError(src)
336+
rendered = render_template(src.read_text(encoding="utf-8"), **context)
337+
dst.parent.mkdir(parents=True, exist_ok=True)
338+
dst.write_text(rendered, encoding="utf-8", newline="\r\n")
339+
340+
return list(templates.values())
341+
342+
def write_pyproject_toml(self, layout: PayloadLayout) -> None:
343+
name, version = get_name_version(self.info)
344+
bundle, app_name = get_bundle_app_name(self.info, name)
345+
346+
config = {
347+
"project_name": name,
348+
"bundle": bundle,
349+
"version": version,
350+
"license": get_license(self.info),
351+
"app": {
352+
app_name: {
353+
"formal_name": f"{self.info['name']} {self.info['version']}",
354+
"description": "", # Required, but not used in the installer.
355+
"external_package_path": str(layout.external),
356+
"use_full_install_path": False,
357+
"install_launcher": False,
358+
"install_option": create_install_options_list(self.info),
359+
"post_install_script": str(layout.root / "run_installation.bat"),
360+
"pre_uninstall_script": str(layout.root / "pre_uninstall.bat"),
361+
}
362+
},
363+
}
285364

286-
def _write_pyproject(self, root: Path) -> None:
287-
write_pyproject_toml(root, self.info)
365+
# Add optional content
366+
if "company" in self.info:
367+
config["author"] = self.info["company"]
288368

289-
def _ensure_root(self) -> Path:
290-
if self.root is None:
291-
self.root = Path(tempfile.mkdtemp())
292-
return self.root
369+
# Finalize
370+
(layout.root / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}}))
371+
logger.debug(f"Created TOML file at: {layout.root}")
293372

294373
def _create_layout(self, root: Path) -> PayloadLayout:
295374
"""The layout is created as:
@@ -321,7 +400,7 @@ def _stage_dists(self, layout: PayloadLayout) -> None:
321400
shutil.copy(download_dir / filename_dist(dist), layout.pkgs)
322401

323402
def _stage_conda(self, layout: PayloadLayout) -> None:
324-
copy_conda_exe(layout.external, "_conda.exe", self.info["_conda_exe"])
403+
copy_conda_exe(layout.external, self.conda_exe_name, self.info["_conda_exe"])
325404

326405

327406
def create(info, verbose=False):
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
@echo {{ 'on' if add_debug else 'off' }}
2+
setlocal
3+
4+
{% macro error_block(message, code) %}
5+
echo [ERROR] {{ message }}
6+
>> "%LOG%" echo [ERROR] {{ message }}
7+
exit /b {{ code }}
8+
{% endmacro %}
9+
10+
rem Assign INSTDIR and normalize the path
11+
set "INSTDIR=%~dp0.."
12+
for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI"
13+
14+
set "BASE_PATH=%INSTDIR%\base"
15+
set "PREFIX=%BASE_PATH%"
16+
set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}"
17+
set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}"
18+
19+
rem Get the name of the install directory
20+
for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI"
21+
set "LOG=%INSTDIR%\uninstall.log"
22+
23+
{%- if add_debug %}
24+
echo ==== pre_uninstall start ==== >> "%LOG%"
25+
echo SCRIPT=%~f0 >> "%LOG%"
26+
echo CWD=%CD% >> "%LOG%"
27+
echo INSTDIR=%INSTDIR% >> "%LOG%"
28+
echo BASE_PATH=%BASE_PATH% >> "%LOG%"
29+
echo CONDA_EXE=%CONDA_EXE% >> "%LOG%"
30+
echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%"
31+
"%CONDA_EXE%" --version >> "%LOG%" 2>&1
32+
{%- endif %}
33+
34+
rem Consistency checks
35+
if not exist "%CONDA_EXE%" (
36+
{{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }}
37+
)
38+
39+
rem Recreate an empty payload tar. This file was deleted during installation but the
40+
rem MSI installer expects it to exist.
41+
type nul > "%PAYLOAD_TAR%"
42+
if errorlevel 1 (
43+
{{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }}
44+
)
45+
46+
"%CONDA_EXE%" --log-file "%LOG%" constructor uninstall --prefix "%BASE_PATH%"
47+
if errorlevel 1 ( exit /b %errorlevel% )
48+
49+
rem If we reached this far without any errors, remove any log-files.
50+
if exist "%INSTDIR%\install.log" del "%INSTDIR%\install.log"
51+
if exist "%INSTDIR%\uninstall.log" del "%INSTDIR%\uninstall.log"
52+
53+
exit /b 0
Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,68 @@
1-
set "INSTDIR=%cd%"
1+
@echo {{ 'on' if add_debug else 'off' }}
2+
setlocal
3+
4+
{% macro error_block(message, code) %}
5+
echo [ERROR] {{ message }}
6+
>> "%LOG%" echo [ERROR] {{ message }}
7+
exit /b {{ code }}
8+
{% endmacro %}
9+
10+
rem Assign INSTDIR and normalize the path
11+
set "INSTDIR=%~dp0.."
12+
for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI"
13+
214
set "BASE_PATH=%INSTDIR%\base"
315
set "PREFIX=%BASE_PATH%"
4-
set "CONDA_EXE=%INSTDIR%\_conda.exe"
5-
6-
"%INSTDIR%\_conda.exe" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs
16+
set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}"
17+
set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}"
718

19+
set CONDA_EXTRA_SAFETY_CHECKS=no
820
set CONDA_PROTECT_FROZEN_ENVS=0
9-
set "CONDA_ROOT_PREFIX=%BASE_PATH%"
21+
set CONDA_REGISTER_ENVS={{ register_envs }}
1022
set CONDA_SAFETY_CHECKS=disabled
11-
set CONDA_EXTRA_SAFETY_CHECKS=no
23+
set "CONDA_ROOT_PREFIX=%BASE_PATH%"
1224
set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs"
1325

14-
"%INSTDIR%\_conda.exe" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"
26+
rem Get the name of the install directory
27+
for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI"
28+
set "LOG=%INSTDIR%\install.log"
29+
30+
{%- if add_debug %}
31+
echo ==== run_installation start ==== >> "%LOG%"
32+
echo SCRIPT=%~f0 >> "%LOG%"
33+
echo CWD=%CD% >> "%LOG%"
34+
echo INSTDIR=%INSTDIR% >> "%LOG%"
35+
echo BASE_PATH=%BASE_PATH% >> "%LOG%"
36+
echo CONDA_EXE=%CONDA_EXE% >> "%LOG%"
37+
echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%"
38+
{%- endif %}
39+
40+
rem Consistency checks
41+
if not exist "%CONDA_EXE%" (
42+
{{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }}
43+
)
44+
if not exist "%PAYLOAD_TAR%" (
45+
{{ error_block('PAYLOAD_TAR not found: "%PAYLOAD_TAR%"', 11) }}
46+
)
47+
48+
echo Unpacking payload...
49+
"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%"
50+
if errorlevel 1 ( exit /b %errorlevel% )
51+
52+
"%CONDA_EXE%" --log-file "%LOG%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs
53+
if errorlevel 1 ( exit /b %errorlevel% )
54+
55+
if not exist "%BASE_PATH%" (
56+
{{ error_block('"%BASE_PATH%" not found!', 12) }}
57+
)
58+
59+
"%CONDA_EXE%" --log-file "%LOG%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"
60+
if errorlevel 1 ( exit /b %errorlevel% )
61+
62+
rem Delete the payload to save disk space.
63+
rem A truncated placeholder of 0 bytes is recreated during uninstall
64+
rem because MSI expects the file to be there to clean the registry.
65+
del "%PAYLOAD_TAR%"
66+
if errorlevel 1 ( exit /b %errorlevel% )
67+
68+
exit /b 0

examples/register_envs/construct.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
name: RegisterEnvs
55
version: 1.0.0
6-
installer_type: {{ "exe" if os.name == "nt" else "all" }}
6+
installer_type: all
77
channels:
88
- https://repo.anaconda.com/pkgs/main/
99
specs:

0 commit comments

Comments
 (0)