Skip to content

Add jinja templating, payload as tar, tests#2

Merged
lrandersson merged 38 commits intodev-ra-798from
dev-ra-798-2
Feb 26, 2026
Merged

Add jinja templating, payload as tar, tests#2
lrandersson merged 38 commits intodev-ra-798from
dev-ra-798-2

Conversation

@lrandersson
Copy link
Owner

@lrandersson lrandersson commented Feb 5, 2026

Description

This PR is a continuation of conda#1164, but split into a separate PR to hopefully simplify review, summarizing the changes:

  1. Add jinja templating for post_install.bat and pre_uninstall.bat (although this file is currently empty).
  2. Move write_pyproject_toml into class Payload
  3. Add new class TemplateFile to better organize files that are templated using jinja.
  4. Add unit tests

Checklist - did you ...

  • Add a file to the news directory (using the template) for the next release's release notes?
  • Add / update necessary tests?
  • Add / update outdated documentation?


archive_path = dst / self.archive_name

with tarfile.open(archive_path, mode="w:gz", compresslevel=1) as tar:
Copy link
Owner Author

Choose a reason for hiding this comment

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

I chose tar.gz here over tar.bz2, the reason is that MSI already compresses the contents, therefor the format of the compression does not do much of a difference in terms of disk space. However, .tar.gz is in general a little bit faster over .tar.bz2, so I have intentionally chose speed here over disk space.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you add some benchmarks to this?

from .jinja import render_template


@dataclass(frozen=True)
Copy link
Owner Author

Choose a reason for hiding this comment

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

We can utilize this later also for the other installers.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems to overly abstract. The name property appears to be too tailor-made (which other format needs it besides MSI) and then we just have a source and destination path. With all my other comments, I'm not sure we need this class.

The render_template_files function could then just handle one file and deal with all the file handling operations (which I think is nice).

Copy link
Collaborator

@marcoesters marcoesters left a comment

Choose a reason for hiding this comment

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

My initial thoughts on this. Overall, I think we could do with a little less abstraction here.

Comment on lines +18 to +20

rem Truncates the payload to 0 bytes
type nul > "%PAYLOAD_TAR%"
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.


archive_path = dst / self.archive_name

with tarfile.open(archive_path, mode="w:gz", compresslevel=1) as tar:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you add some benchmarks to this?

def test_payload_templates_are_rendered():
"""Test that templates are rendered when the payload is prepared."""

def assert_no_jinja_markers(path: Path) -> None:
Copy link
Collaborator

Choose a reason for hiding this comment

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

That function looks simple enough to be inlined.

root: Path | None = None
archive_name: str = "payload.tar.gz"
conda_exe_name: str = "_conda.exe"
rendered_templates: list[TemplateFile] | None = None
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.


return archive_path

def _convert_into_archive(self, src: Path, dst: Path) -> Path:
Copy link
Collaborator

Choose a reason for hiding this comment

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

That function could be folded into make_tar_gz or vice versa

"""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()
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is what I meant about either using __init__ to create the directory or not allowing a None value/non-existing directory. We have to manually add self._ensure_root() in all appropriate places.

},
}
# Render the template files and add them to the necessary config field
rendered_templates = self.render_templates()
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this should go into prepare like with any other payload preparation. Since we will always have pre_uninstall and pre_install scripts for our installers, we can add those hard-coded into the config object above.

@@ -0,0 +1,26 @@
from collections.abc import Mapping
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 .jinja import render_template


@dataclass(frozen=True)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems to overly abstract. The name property appears to be too tailor-made (which other format needs it besides MSI) and then we just have a source and destination path. With all my other comments, I'm not sure we need this class.

The render_template_files function could then just handle one file and deal with all the file handling operations (which I think is nice).

@lrandersson
Copy link
Owner Author

@marcoesters To answer above comments.
The requested benchmarking, using a dummy tar with 800 files of 32kB each, below are the results for different compression levels:

cl=1: 0.225s, size=209.1 KiB
cl=3: 0.215s, size=210.1 KiB
cl=6: 0.264s, size=116.9 KiB
cl=9: 0.285s, size=108.3 KiB

I just pushed a bunch of commits that did change a lot based on the review, I hope to summarize it:

  1. Removed template_file.py, merged the rendering functions into one
  2. Using cached properties now for both Payload.root and Payload.rendered_templates. Let me know what you think, this also removes the call to _ensure_root(). I think this looks pretty good and we don't get side-effects from class instantiation (as mentioned in the other PR).
  3. Updated the scripts run_installation.bat and pre_uninstall.bat, we now call conda-standalone instead of `tar.
  4. Folded/merged the make_tar_gz functionality.
  5. Inlined the test utility function to verify we are free from unrendered jinja tokens.

Copy link
Collaborator

@marcoesters marcoesters left a comment

Choose a reason for hiding this comment

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

Thank you! I think it's much closer to being ready now.

Comment on lines +368 to +369
"post_install_script": str(self.rendered_templates["post_install_script"].dst),
"pre_uninstall_script": str(self.rendered_templates["pre_uninstall_script"].dst),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
"post_install_script": str(self.rendered_templates["post_install_script"].dst),
"pre_uninstall_script": str(self.rendered_templates["pre_uninstall_script"].dst),
"post_install_script": layout.root / "post_install.bat",
"pre_uninstall_script": layout.root / "pre_uninstall.bat",

We know where these files go and that location is not configurable, so we might as well hard-code them here. That also gives layout even more of a purpose and makes the TemplateParameter dataclass obsolete and you don't need to store the template files in the class (the fact that they are templates isn't that important, I think).

Copy link
Owner Author

Choose a reason for hiding this comment

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

Applied here 3327439

Comment on lines +291 to +292
def make_tar_gz(self, src: Path, dst: Path) -> Path:
"""Create a .tar.gz of the directory 'src'.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it would overall be good see a benchmark of tar.gz vs. tar.bz2 in terms of space and extraction speed, especially compared to the native MSI compression. While registry handling becomes much easier with one file, it would be good to say what the cost is.

Copy link
Owner Author

Choose a reason for hiding this comment

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

Agree, will do that separately.

"%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, a truncated placeholder of 0 bytes is recreated during uninstall
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
rem Delete the payload to save disk space, a truncated placeholder of 0 bytes is recreated during uninstall
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.

Copy link
Owner Author

Choose a reason for hiding this comment

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

Applied here 3327439

@lrandersson
Copy link
Owner Author

@marcoesters Applied the fixes here 3327439

@lrandersson lrandersson mentioned this pull request Feb 16, 2026
3 tasks
@lrandersson
Copy link
Owner Author

@marcoesters I think I've addressed all of your comments. Note that this doesn't go into main but into the next (final) branch.

Copy link
Collaborator

@marcoesters marcoesters left a comment

Choose a reason for hiding this comment

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

Once the canary issue is solved, we should take a closer look at logging. Here are some other comments.

Comment on lines +253 to +255
# 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"
Copy link
Collaborator

Choose a reason for hiding this comment

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

constructor has a --debug flag. We shouldn't enable debugging on default in the CI because many installers are built and executed in CI environments.

Copy link
Owner Author

@lrandersson lrandersson Feb 23, 2026

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Owner Author

Choose a reason for hiding this comment

The 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 pre/post uninstall/install.
This is what we discussed earlier about the issue of not having access to anything similar to tee, which is why I at least enforced file logging during the CI environment otherwise there is no way to tell what goes wrong from an error.

Copy link
Owner Author

Choose a reason for hiding this comment

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

I'd propose we keep the implementation but rename the variable to log_to_file

Copy link
Owner Author

@lrandersson lrandersson Feb 24, 2026

Choose a reason for hiding this comment

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

@marcoesters

  1. Always log to file 3c91055 (note that there is a small section remaining that relates now to add_debug but now the variable name is more in line with what it does: it enables "echo on" and prints some variables to the log.
  2. Generalize log files and move them to INSTDIR, remove within pre_uninstall.bat if necessary 556705a

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.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do they need to exist already?

Copy link
Owner Author

Choose a reason for hiding this comment

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

The directory src must exist since that is what we are trying to archive, but I guess dst does not explicitly need to exist, but since it's a public method I requested that as a defensive measure to avoid confusing tarfile or shutil errors, since I thought current implementation will at least raise a clear error message:

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

Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Owner Author

@lrandersson lrandersson Feb 24, 2026

Choose a reason for hiding this comment

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

26ebcb3 (changed inputs to input in docstring in later commit)

Comment on lines +326 to +335
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",
),
]
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Owner Author

@lrandersson lrandersson Feb 23, 2026

Choose a reason for hiding this comment

The 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 payload object what templates we have rendered.

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 prepare or design this slightly differently. If you have any other suggestions please let me know.

Note also that write_pyproject_toml indirectly depends on this function since it is referencing the files created by render_templates()

Copy link
Collaborator

Choose a reason for hiding this comment

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

write_pyproject_toml does indirectly depend on the function, but it doesn't use it. I think creating a separate data class just to store source and destination is a little heavy-handed. After all, you only need the locations of the rendered template files.

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.

Copy link
Owner Author

@lrandersson lrandersson Feb 24, 2026

Choose a reason for hiding this comment

The 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.


{%- if add_debug %}
rem Get the name of the install directory
for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Has this been tested with %INSTDIR% that ends in a path with spaces?

Copy link
Owner Author

Choose a reason for hiding this comment

The 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"

@lrandersson lrandersson merged commit 68a527e into dev-ra-798 Feb 26, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants