-
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 1 commit
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 |
|---|---|---|
|
|
@@ -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 | ||
|
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 don't know if we want to make that distinction. Rendered templates are like any other file we add to the installer payload. |
||
|
|
||
| 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) | ||
|
marcoesters marked this conversation as resolved.
|
||
| 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'. | ||
|
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 think it would overall be good see a benchmark of
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. Agree, will do that separately. |
||
| 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 |
||
| 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\\<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 | ||
|
|
||
| with tarfile.open(archive_path, mode="w:gz", compresslevel=1) as tar: | ||
|
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 chose
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. Could you add some benchmarks to this? |
||
| tar.add(src, arcname=src.name) | ||
|
|
||
| return archive_path | ||
|
|
||
| def _convert_into_archive(self, src: Path, dst: Path) -> Path: | ||
|
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. That function could be folded into |
||
| """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() | ||
|
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. This is what I meant about either using |
||
| 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() | ||
|
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 think this should go into |
||
| 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): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,20 @@ | ||
| 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%" | ||
|
marcoesters marked this conversation as resolved.
Outdated
|
||
|
|
||
| "%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 Truncates the payload to 0 bytes | ||
| type nul > "%PAYLOAD_TAR%" | ||
|
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 not delete it? That zero-byte file doesn't need to be there until the uninstallation. There should probably also be a comment about why this is needed. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| from collections.abc import Mapping | ||
|
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. Let's merge the two template functions into one. There doesn't seem to be enough here to justify a separate module. |
||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| from .jinja import render_template | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
|
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. We can utilize this later also for the other installers.
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. This seems to overly abstract. The The |
||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: | ||
|
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. That function looks simple enough to be inlined. |
||
| """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) | ||
Uh oh!
There was an error while loading. Please reload this page.