-
Notifications
You must be signed in to change notification settings - Fork 0
Add jinja templating, payload as tar, tests #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 28 commits
109693f
5e97190
2019d4f
b80642e
8aebd7c
e4bc141
313f7f2
e642b75
839e290
3327439
3df7843
2566f73
938e027
b2d2025
81a75b4
3300636
a813e17
d64e6ab
0f4d68d
d64a807
c3d51fd
d9fa4f8
1d40e95
f39a787
60f85bb
2ad0d23
8a32292
84b0136
b501aab
c5237a3
2c40ad5
6c542cd
26ebcb3
8c8cc7b
621a545
6312aa7
3c91055
556705a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,11 +2,14 @@ | |
| Logic to build installers using Briefcase. | ||
| """ | ||
|
|
||
| import functools | ||
| import logging | ||
| import os | ||
| import re | ||
| import shutil | ||
| import sys | ||
| import sysconfig | ||
| import tarfile | ||
| import tempfile | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
|
|
@@ -19,6 +22,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 +222,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 +247,141 @@ class Payload: | |
| """ | ||
|
|
||
| info: dict | ||
| root: Path | None = None | ||
| 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" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't it be a tedious design if you have to run the tests once to see if there is an error, and then enable this flag and rerun the tests again to see what the underlying issue is? Especially considering that testing times are already very long. Note also that the "additional logging" is only visible to us upon a test failure.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should then improve our error reporting to make sure that in general, the underlying issue is clear from the error message. If the information is important, it shouldn't just be part of the debugging output.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That I fully agree with but this comes back to if we want to log to file by default or not. Perhaps the variable name is not serving its purpose here but this flag enables all the file logging during
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd propose we keep the implementation but rename the variable to
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| @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. | ||
| """ | ||
|
marcoesters marked this conversation as resolved.
|
||
| 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. | ||
| """ | ||
|
marcoesters marked this conversation as resolved.
Outdated
|
||
| root = self.root | ||
| layout = self._create_layout(root) | ||
|
marcoesters marked this conversation as resolved.
|
||
| # 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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do they need to exist already?
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The directory
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would generally not require that a destination directory exists. It seems like a fragile design.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 26ebcb3 (changed |
||
| 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", | ||
| ), | ||
| ] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure we need that information since the file names are all hard-coded. I don't know that we need a separate dataclass for it either since it just contains source and destination.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "I'm not sure we need that information" - are you referring to the return format of the function or something else? And I agree that it is optional, the purpose of the current implementation is to be able to verify easily during testing that the templates exist and to avoid hardcoding any file-names more than in this function. I like to think of it as having an ability to check from the Ideally I'm not 100% with the current design, there are many ways to do this. I'd either actually use the return value somewhere meaningful perhaps in Note also that
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If you want to return something to facilitate testing, I suggest returning a dictionary with the key being pre-/post-install and the value being the file location.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 8c8cc7b. I returned simply a list, I thought it might be the simplest because we just want to confirm that they have been rendered as expected. |
||
|
|
||
| context: dict[str, str] = { | ||
| "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 | ||
| 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 +407,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): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| @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" | ||
|
|
||
| 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" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Has this been tested with
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, all of the tests actually contain spaces because the install directory is set as "name versionnumber-buildnumber" |
||
| 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 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%" | ||
| 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 }} ) | ||
|
|
||
| exit /b 0 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,73 @@ | ||
| set "INSTDIR=%cd%" | ||
| @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" | ||
|
|
||
| set "BASE_PATH=%INSTDIR%\base" | ||
| set "PREFIX=%BASE_PATH%" | ||
| set "CONDA_EXE=%INSTDIR%\_conda.exe" | ||
|
|
||
| "%INSTDIR%\_conda.exe" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs | ||
| set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" | ||
| set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" | ||
|
|
||
| set CONDA_EXTRA_SAFETY_CHECKS=no | ||
| set CONDA_PROTECT_FROZEN_ENVS=0 | ||
| set "CONDA_ROOT_PREFIX=%BASE_PATH%" | ||
| set CONDA_REGISTER_ENVS={{ register_envs }} | ||
| set CONDA_SAFETY_CHECKS=disabled | ||
| set CONDA_EXTRA_SAFETY_CHECKS=no | ||
| set "CONDA_ROOT_PREFIX=%BASE_PATH%" | ||
| set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" | ||
|
|
||
| "%INSTDIR%\_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 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) }} | ||
| ) | ||
| if not exist "%PAYLOAD_TAR%" ( | ||
| {{ error_block('PAYLOAD_TAR not found: "%PAYLOAD_TAR%"', 11) }} | ||
| ) | ||
|
|
||
| echo Unpacking payload... | ||
| "%CONDA_EXE%"{{ conda_log }} constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%" | ||
| if errorlevel 1 ( {{ dump_and_exit }} ) | ||
|
|
||
| "%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%"{{ 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. | ||
| 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 |
Uh oh!
There was an error while loading. Please reload this page.