diff --git a/posit-bakery/posit_bakery/config/config.py b/posit-bakery/posit_bakery/config/config.py index 3f949657..a0a85e04 100644 --- a/posit-bakery/posit_bakery/config/config.py +++ b/posit-bakery/posit_bakery/config/config.py @@ -25,7 +25,7 @@ from posit_bakery.config.repository import Repository from posit_bakery.config.shared import BakeryPathMixin, BakeryYAMLModel from posit_bakery.config.templating import TPL_CONTAINERFILE, TPL_BAKERY_CONFIG_YAML -from posit_bakery.config.templating.render import jinja2_env +from posit_bakery.config.templating.render import jinja2_env, normalize_rendered_output from posit_bakery.config.image.parsed_version import ParsedVersion from posit_bakery.config.image.posit_product.const import ReleaseStreamEnum from posit_bakery.const import DEFAULT_BASE_IMAGE, DevVersionInclusionEnum, MatrixVersionInclusionEnum @@ -197,7 +197,7 @@ def create_image_files_template(image_path: Path, image_name: str, base_tag: str tpl = jinja2_env().from_string(TPL_CONTAINERFILE) rendered = tpl.render(image_name=image_name, base_tag=base_tag) with open(containerfile_path, "w") as f: - f.write(rendered) + f.write(normalize_rendered_output(rendered)) image_test_path = image_template_path / "test" if not image_test_path.is_dir(): @@ -417,14 +417,13 @@ def new(base_path: str | Path | os.PathLike) -> None: tpl = jinja2_env(loader=jinja2.FileSystemLoader(config_file.parent)).from_string(TPL_BAKERY_CONFIG_YAML) rendered = tpl.render(repo_url=util.try_get_repo_url(base_path)) with open(config_file, "w") as f: - f.write(rendered) + f.write(normalize_rendered_output(rendered)) def write(self) -> None: """Write the bakery config to the config file.""" stream = io.StringIO() self.yaml.dump(self._config_yaml, stream) - text = re.sub(r"[ \t]+$", "", stream.getvalue(), flags=re.MULTILINE) - self.config_file.write_text(text) + self.config_file.write_text(normalize_rendered_output(stream.getvalue())) def _get_image_index(self, image_name: str) -> int: """Returns the index of the image with the given name in the config. diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index f783efd5..46462085 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -24,6 +24,7 @@ from posit_bakery.config.registry import BaseRegistry, Registry from posit_bakery.config.shared import BakeryPathMixin, BakeryYAMLModel from posit_bakery.config.templating import jinja2_env +from posit_bakery.config.templating.render import normalize_rendered_output from posit_bakery.const import JINJA2_TEMPLATE_EXTENSIONS from posit_bakery.error import BakeryFileError, BakeryRenderError, BakeryTemplateError, BakeryRenderErrorGroup from .variant import ImageVariant @@ -571,11 +572,6 @@ def render_files(self, variants: list[ImageVariant] | None = None, regex_filters ) continue - # Enable trim_blocks for Containerfile templates - render_kwargs = {} - if tpl_rel_path.startswith("Containerfile"): - render_kwargs["trim_blocks"] = True - # If variants are specified, render Containerfile for each variant if tpl_rel_path.startswith("Containerfile") and variants: containerfile_base_name = tpl_rel_path.removesuffix(".jinja2") @@ -592,7 +588,7 @@ def render_files(self, variants: list[ImageVariant] | None = None, regex_filters template_values = self.generate_template_values(variant, containerfile_os) containerfile: Path = self.path / f"{containerfile_base_name}.{variant.extension}" try: - rendered = tpl.render(**template_values, **render_kwargs) + rendered = tpl.render(**template_values) except (jinja2.TemplateError, BakeryTemplateError) as e: log.error( f"Failed to render template [bold]{tpl_rel_path}[/] for image '{self.parent.name}' " @@ -612,7 +608,7 @@ def render_files(self, variants: list[ImageVariant] | None = None, regex_filters continue log.debug(f"[bright_black]Rendering [bold]{containerfile}") copy2(tpl_full_path, containerfile) - containerfile.write_text(rendered) + containerfile.write_text(normalize_rendered_output(rendered)) # Render other templates once else: @@ -621,7 +617,7 @@ def render_files(self, variants: list[ImageVariant] | None = None, regex_filters output_file.parent.mkdir(parents=True, exist_ok=True) template_values = self.generate_template_values() try: - rendered = tpl.render(**template_values, **render_kwargs) + rendered = tpl.render(**template_values) except (jinja2.TemplateError, BakeryTemplateError) as e: log.error( f"Failed to render template [bold]{tpl_rel_path}[/] for image '{self.parent.name}' matrix" @@ -639,7 +635,7 @@ def render_files(self, variants: list[ImageVariant] | None = None, regex_filters continue log.debug(f"[bright_black]Rendering [bold]{output_file}") copy2(tpl_full_path, output_file) - output_file.write_text(rendered) + output_file.write_text(normalize_rendered_output(rendered)) if exceptions: if len(exceptions) == 1: diff --git a/posit-bakery/posit_bakery/config/image/version.py b/posit-bakery/posit_bakery/config/image/version.py index e6402c70..f5f09ae2 100644 --- a/posit-bakery/posit_bakery/config/image/version.py +++ b/posit-bakery/posit_bakery/config/image/version.py @@ -21,6 +21,7 @@ from .variant import ImageVariant from .version_os import ImageVersionOS from ..templating import jinja2_env +from ..templating.render import normalize_rendered_output from ...error import BakeryFileError, BakeryRenderError, BakeryTemplateError, BakeryRenderErrorGroup log = logging.getLogger(__name__) @@ -468,11 +469,6 @@ def render_files( ) continue - # Enable trim_blocks for Containerfile templates - render_kwargs = {} - if tpl_rel_path.startswith("Containerfile"): - render_kwargs["trim_blocks"] = True - # If variants are specified, render Containerfile for each variant if tpl_rel_path.startswith("Containerfile") and variants: containerfile_base_name = tpl_rel_path.removesuffix(".jinja2") @@ -497,7 +493,7 @@ def render_files( template_values = self.generate_template_values(variant, containerfile_os) containerfile: Path = self.path / f"{containerfile_base_name}.{variant.extension}" try: - rendered = tpl.render(**template_values, **render_kwargs) + rendered = tpl.render(**template_values) except (jinja2.TemplateError, BakeryTemplateError) as e: log.error( f"Failed to render template [bold]{tpl_rel_path}[/] for image '{self.parent.name}' " @@ -517,7 +513,7 @@ def render_files( continue log.debug(f"[bright_black]Rendering [bold]{containerfile}") copy2(tpl_full_path, containerfile) - containerfile.write_text(rendered) + containerfile.write_text(normalize_rendered_output(rendered)) # Render other templates once else: @@ -526,7 +522,7 @@ def render_files( output_file.parent.mkdir(parents=True, exist_ok=True) template_values = self.generate_template_values() try: - rendered = tpl.render(**template_values, **render_kwargs) + rendered = tpl.render(**template_values) except (jinja2.TemplateError, BakeryTemplateError) as e: log.error( f"Failed to render template [bold]{tpl_rel_path}[/] for image '{self.parent.name}' " @@ -545,7 +541,7 @@ def render_files( continue log.debug(f"[bright_black]Rendering [bold]{output_file}") copy2(tpl_full_path, output_file) - output_file.write_text(rendered) + output_file.write_text(normalize_rendered_output(rendered)) if exceptions: if len(exceptions) == 1: diff --git a/posit-bakery/posit_bakery/config/templating/render.py b/posit-bakery/posit_bakery/config/templating/render.py index a266f650..a7ffdb23 100644 --- a/posit-bakery/posit_bakery/config/templating/render.py +++ b/posit-bakery/posit_bakery/config/templating/render.py @@ -42,3 +42,21 @@ def render_template(template: str, **kwargs) -> str: """ template = jinja2_env().from_string(template) return template.render(**kwargs).strip() + + +def normalize_rendered_output(text: str) -> str: + """Normalize rendered template output to match common pre-commit hook fixes. + + Strips trailing spaces and tabs from each line and ensures the result + ends with exactly one newline (or stays empty if it was empty). This + matches the output of the `trailing-whitespace` and `end-of-file-fixer` + pre-commit hooks, so files written by bakery's renderer don't fail + those hooks in consuming repositories. + + :param text: The rendered template text to normalize. + :return: The normalized text. + """ + text = re.sub(r"[ \t]+$", "", text, flags=re.MULTILINE) + if text: + text = text.rstrip("\n") + "\n" + return text diff --git a/posit-bakery/test/config/templating/test_render.py b/posit-bakery/test/config/templating/test_render.py index 505bd722..72af1563 100644 --- a/posit-bakery/test/config/templating/test_render.py +++ b/posit-bakery/test/config/templating/test_render.py @@ -1,7 +1,7 @@ import jinja2 import pytest -from posit_bakery.config.templating.render import jinja2_env +from posit_bakery.config.templating.render import jinja2_env, normalize_rendered_output pytestmark = [ @@ -61,3 +61,35 @@ def test_render_template(): template = "{{ 'Test String' | condense }} {{ 'foo-bar-baz' | regexReplace('-', '_') }}" rendered = jinja2_env().from_string(template).render() assert rendered == "TestString foo_bar_baz" + + +class TestNormalizeRenderedOutput: + def test_strips_trailing_spaces(self): + assert normalize_rendered_output("foo \nbar\n") == "foo\nbar\n" + + def test_strips_trailing_tabs(self): + assert normalize_rendered_output("foo\t\nbar\n") == "foo\nbar\n" + + def test_strips_mixed_trailing_whitespace(self): + assert normalize_rendered_output("foo \t \nbar\n") == "foo\nbar\n" + + def test_strips_whitespace_only_lines(self): + assert normalize_rendered_output("a\n \nb\n") == "a\n\nb\n" + + def test_collapses_multiple_trailing_newlines(self): + assert normalize_rendered_output("foo\n\n\n") == "foo\n" + + def test_adds_missing_trailing_newline(self): + assert normalize_rendered_output("foo\nbar") == "foo\nbar\n" + + def test_preserves_single_trailing_newline(self): + assert normalize_rendered_output("foo\nbar\n") == "foo\nbar\n" + + def test_empty_input_stays_empty(self): + assert normalize_rendered_output("") == "" + + def test_preserves_interior_blank_lines(self): + assert normalize_rendered_output("a\n\nb\n") == "a\n\nb\n" + + def test_does_not_touch_leading_whitespace(self): + assert normalize_rendered_output(" indented\n") == " indented\n" diff --git a/posit-bakery/test/config/test_e2e.py b/posit-bakery/test/config/test_e2e.py index 6b7571dc..68f5f0f9 100644 --- a/posit-bakery/test/config/test_e2e.py +++ b/posit-bakery/test/config/test_e2e.py @@ -7,6 +7,7 @@ from posit_bakery.config import BakeryConfig from posit_bakery.config.templating import TPL_BAKERY_CONFIG_YAML, render_template +from posit_bakery.config.templating.render import normalize_rendered_output from posit_bakery.const import DEFAULT_BASE_IMAGE from test.helpers import IMAGE_INDENT, VERSION_INDENT @@ -30,7 +31,7 @@ def test_create_from_scratch(tmpdir, common_image_variants): # Check that the bakery.yaml file was created as expected from its template. assert config_file.is_file() - expected_config = render_template(TPL_BAKERY_CONFIG_YAML, repo_url=repo_url) + expected_config = normalize_rendered_output(render_template(TPL_BAKERY_CONFIG_YAML, repo_url=repo_url)) assert config_file.read_text() == expected_config # Load the new project