diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 39c4ff6..029a233 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,8 @@ repos: + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: 'v0.6.2' @@ -6,10 +10,6 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] exclude: test.* - - repo: https://github.com/psf/black - rev: 24.8.0 - hooks: - - id: black - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: @@ -22,6 +22,7 @@ repos: - id: check-shebang-scripts-are-executable - id: check-symlinks - id: check-toml + exclude: test.* - id: check-yaml exclude: | (?x)^( diff --git a/project_forge/context_builder/context.py b/project_forge/context_builder/context.py index cafd5cd..7a36297 100644 --- a/project_forge/context_builder/context.py +++ b/project_forge/context_builder/context.py @@ -5,7 +5,7 @@ from project_forge.configurations.composition import Composition from project_forge.context_builder.overlays import process_overlay -from project_forge.rendering import render_expression +from project_forge.rendering.expressions import render_expression def get_starting_context() -> dict: diff --git a/project_forge/context_builder/overlays.py b/project_forge/context_builder/overlays.py index 99c7f3e..38d1635 100644 --- a/project_forge/context_builder/overlays.py +++ b/project_forge/context_builder/overlays.py @@ -5,7 +5,7 @@ from project_forge.configurations.composition import Overlay from project_forge.context_builder.questions import answer_question -from project_forge.rendering import render_expression +from project_forge.rendering.expressions import render_expression def process_overlay(overlay: Overlay, running_context: dict, question_ui: Callable) -> dict: diff --git a/project_forge/context_builder/questions.py b/project_forge/context_builder/questions.py index fb149e6..c114b44 100644 --- a/project_forge/context_builder/questions.py +++ b/project_forge/context_builder/questions.py @@ -4,7 +4,7 @@ from project_forge.configurations.pattern import Choice, Question from project_forge.core.validators import ExprValidator -from project_forge.rendering import render_bool_expression, render_expression +from project_forge.rendering.expressions import render_bool_expression, render_expression def filter_choices(choices: List[Choice], running_context: dict) -> dict: diff --git a/project_forge/core/validators.py b/project_forge/core/validators.py index 563bdc5..df1bd0d 100644 --- a/project_forge/core/validators.py +++ b/project_forge/core/validators.py @@ -2,7 +2,7 @@ from typing import Any, TypeVar -from project_forge.rendering import render_expression +from project_forge.rendering.expressions import render_expression T = TypeVar("T") diff --git a/project_forge/rendering/__init__.py b/project_forge/rendering/__init__.py new file mode 100644 index 0000000..572c630 --- /dev/null +++ b/project_forge/rendering/__init__.py @@ -0,0 +1 @@ +"""Tools for rendering templates and template strings.""" diff --git a/project_forge/rendering/environment.py b/project_forge/rendering/environment.py new file mode 100644 index 0000000..5af9b7a --- /dev/null +++ b/project_forge/rendering/environment.py @@ -0,0 +1,114 @@ +"""Tools and classes for managing the Jinja2 rendering environment.""" + +import re +from collections import ChainMap +from pathlib import Path +from typing import Any, Callable, List, Optional + +from jinja2 import BaseLoader, Environment, TemplateNotFound, Undefined + + +class InheritanceMap(ChainMap[str, Path]): + """Provides convenience functions for managing template inheritance.""" + + @property + def is_empty(self) -> bool: + """The context has only one mapping and it is empty.""" + return len(self.maps) == 1 and len(self.maps[0]) == 0 + + def inheritance(self, key: str) -> List[Path]: + """ + Show all the values associated with a key, from most recent to least recent. + + If the maps were added in the order `{"a": Path("1")}, {"a": Path("2")}, {"a": Path("3")}`, + The output for `inheritance("a")` would be `[Path("3"), Path("2"), Path("1")]`. + + Args: + key: The key to look up + + Returns: + The values for that key with the last value first. + """ + return [mapping[key] for mapping in self.maps[::-1] if key in mapping] + + +class SuperUndefined(Undefined): + """Let calls to super() work ok.""" + + def __getattr__(self, name: str) -> Any: + """Override the superclass' __getattr__ method to handle the `jinja_pass_arg` attribute.""" + if name.startswith("__"): + raise AttributeError(name) + return False if name == "jinja_pass_arg" else self._fail_with_undefined_error() + + def __call__(self) -> str: + """If the undefined is called (like super()) it outputs an empty string.""" + return "" + + +class InheritanceLoader(BaseLoader): + """Load templates from inherited templates of the same name.""" + + extends_re: str = "{block_start_string}\\s*extends\\s*[\"']([^\"']+)[\"']\\s*{block_end_string}" + + def __init__(self, inheritance_map: InheritanceMap): + self.templates = inheritance_map + + def get_source(self, environment: Environment, template: str) -> tuple[str, str | None, Callable[[], bool] | None]: + """Load the template.""" + # Parse the name of the template + bits = template.split("/") + index = 0 + if len(bits) == 2 and bits[0].isdigit(): + index = int(bits[0]) + template_name = bits[1] + else: + template_name = template + + # Get template inheritance + inheritance = self.templates.inheritance(template_name) + inheritance_len = len(inheritance) + + if not inheritance: + raise TemplateNotFound(template_name) + + # Load the template from the index + if index >= inheritance_len: + raise TemplateNotFound(template) # Maybe this wasn't one of our customized extended paths + + path = inheritance[index] + source = path.read_text() + + # look for an `extends` tag + block_start_string = environment.block_start_string + block_end_string = environment.block_end_string + regex = re.compile( + self.extends_re.format(block_start_string=block_start_string, block_end_string=block_end_string) + ) + if match := regex.search(source): + if index == len(inheritance) - 1: + # we've reached our last template, so we must remove the `extends` tag completely + source = source.replace(match[0], "") + else: + # rewrite the `extends` tag to reference the next item in the inheritance + source = source.replace(match[1], f"{index + 1}/{match[1]}") + + return source, None, lambda: True + + +def load_environment(template_map: Optional[InheritanceMap] = None, extensions: Optional[list] = None) -> Environment: + """ + Load the Jinja2 template environment. + + Args: + template_map: The template inheritance used to load the templates + extensions: A list of Jinja extensions to load into the environment + + Returns: + The Jinja environment + """ + template_map = template_map or InheritanceMap() + extensions = extensions or [] + return Environment( # NOQA: S701 + loader=InheritanceLoader(template_map), extensions=extensions, undefined=SuperUndefined + ) diff --git a/project_forge/rendering/templates.py b/project_forge/rendering/templates.py new file mode 100644 index 0000000..a7cf58b --- /dev/null +++ b/project_forge/rendering/templates.py @@ -0,0 +1,56 @@ +"""Management of templates.""" + +from pathlib import Path +from typing import Dict, List + +from project_forge.rendering.environment import InheritanceMap + + +def catalog_templates(template_dir: Path) -> Dict[str, Path]: + """ + Catalog templates into a dictionary. + + This creates a mapping of a relative file name to a full path. + + For a file structure like: + + /path-to-templates/ + {{ repo_name }}/ + file1.txt + subdir/ + file2.txt + empty-subdir/ + + A call to `catalog_templates(Path("/path-to-templates"))` would return: + + { + "{{ repo_name }}": Path("/path-to-templates/{{ repo_name }}"), + "{{ repo_name }}/file1.txt": Path("/path-to-templates/{{ repo_name }}/file1.txt"), + "{{ repo_name }}/subdir": Path("/path-to-templates/{{ repo_name }}/subdir"), + "{{ repo_name }}/subdir/file2.txt": Path("/path-to-templates/{{ repo_name }}/subdir/file2.txt"), + "{{ repo_name }}/empty-subdir": Path("/path-to-templates/{{ repo_name }}/empty-subdir"), + } + + Args: + template_dir: The directory to catalog + + Returns: + A mapping of the relative path as a string to the full path + """ + templates = {} + for root, dirs, files in template_dir.walk(): + for file in files: + template_path = root / file + templates[str(template_path.relative_to(template_dir))] = template_path + for dir_ in dirs: + template_path = root / dir_ + templates[str(template_path.relative_to(template_dir))] = template_path + return {key: templates[key] for key in sorted(templates)} + + +def catalog_inheritance(template_paths: List[Path]) -> InheritanceMap: + """Create an InheritanceMap that reflects the inheritance of all the template paths.""" + inheritance = InheritanceMap() + for template_path in template_paths: + inheritance = inheritance.new_child(catalog_templates(template_path)) + return inheritance diff --git a/tests/fixtures/python-boilerplate/{{ repo_name }}/pyproject.toml b/tests/fixtures/python-boilerplate/{{ repo_name }}/pyproject.toml new file mode 100644 index 0000000..cf94fa5 --- /dev/null +++ b/tests/fixtures/python-boilerplate/{{ repo_name }}/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ repo_name }}" +description = "{{ short_description }}" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python :: 3.12", +] +keywords = ["{{ repo_name }}"] +dynamic = ["version"] +license = { file = "LICENSE" } +requires-python = ">=3.9" +dependencies = [{% block dependencies %}{% for pkg, version in dependencies.items() %} + "{{ pkg }}{{ version }}", +{%- endfor %}{% endblock dependencies %} +] + +[project.optional-dependencies] +{% block optional_dependencies %} +dev = [{% block dev_dependencies %}{% for pkg, version in dev_requirements.items() %} + "{{ pkg }}{{ version }}", +{%- endfor %}{% endblock dev_dependencies %} +] +test = [{% block test_dependencies %}{% for pkg, version in test_requirements.items() %} + "{{ pkg }}{{ version }}", +{%- endfor %}{% endblock test_dependencies %} +] +docs = [{% block docs_dependencies %}{% for pkg, version in docs_requirements.items() %} + "{{ pkg }}{{ version }}", +{%- endfor %}{% endblock docs_dependencies %} +] + +[tool.hatch.version] +path = "{{ repo_name }}/__init__.py" diff --git a/tests/fixtures/python-package/{{ repo_name }}/pyproject.toml b/tests/fixtures/python-package/{{ repo_name }}/pyproject.toml new file mode 100644 index 0000000..d92cbc9 --- /dev/null +++ b/tests/fixtures/python-package/{{ repo_name }}/pyproject.toml @@ -0,0 +1,5 @@ +{% extends "pyproject.toml" %} + +{% block test_dependencies %}{{ super() }}{% for pkg, version in test_requirements.items() %} + "{{ pkg }}{{ version }}", +{%- endfor %}{% endblock test_dependencies %} diff --git a/tests/test_rendering/test_environment.py b/tests/test_rendering/test_environment.py new file mode 100644 index 0000000..99c0239 --- /dev/null +++ b/tests/test_rendering/test_environment.py @@ -0,0 +1,116 @@ +"""Tests for `project_forge.rendering.environment`.""" + +from pathlib import Path + +import pytest +from project_forge.rendering.environment import InheritanceMap, SuperUndefined, InheritanceLoader +from jinja2.exceptions import UndefinedError +from jinja2 import Environment, TemplateNotFound + + +@pytest.fixture +def init_map(tmp_path: Path) -> InheritanceMap: + """Create a simple template inheritance map.""" + p1 = tmp_path / "dir1" / "a.txt" + p1.parent.mkdir(parents=True, exist_ok=True) + p1.write_text("{% extends 'a.txt' %}") + p2 = tmp_path / "dir2" / "a.txt" + p2.parent.mkdir(parents=True, exist_ok=True) + p2.write_text("{% extends 'a.txt' %}") + p3 = tmp_path / "dir3" / "a.txt" + p3.parent.mkdir(parents=True, exist_ok=True) + p3.write_text("{% extends 'a.txt' %}") + return InheritanceMap({"a.txt": p1}, {"a.txt": p2}, {"a.txt": p3}) + + +class TestInheritanceMap: + """Tests for InheritanceMap.""" + + def test_is_empty_returns_true_when_empty(self): + """When an inheritance map is empty, is_empty returns true.""" + assert InheritanceMap().is_empty + + def test_is_empty_returns_false_when_not_empty(self, init_map: InheritanceMap): + """When an inheritance map is not empty, is_empty returns false.""" + assert not init_map.is_empty + + def test_inheritance_for_key_returns_values_in_reverse_order(self, init_map: InheritanceMap): + assert init_map.inheritance("a.txt") == [ + init_map.maps[2]["a.txt"], + init_map.maps[1]["a.txt"], + init_map.maps[0]["a.txt"], + ] + + def test_inheritance_for_missing_key_returns_empty_list(self, init_map: InheritanceMap): + assert init_map.inheritance("b") == [] + + +class TestSuperUndefined: + """Tests for SuperUndefined.""" + + def test_getattr_raises_undefined_error(self): + """Accessing an arbitrary attribute raises an UndefinedError.""" + sup_undefined = SuperUndefined() + with pytest.raises(UndefinedError): + assert sup_undefined.__getattr__("valid_attr") == False + + def test_getattr_jinja_pass_arg_returns_false(self): + """Accessing the `jinja_pass_arg` attribute returns False.""" + sup_undefined = SuperUndefined() + assert sup_undefined.__getattr__("jinja_pass_arg") == False + + def test_getattr_dunder_attribute_raises_attribute_error(self): + """Accessing a dunder attribute raises an AttributeError.""" + sup_undefined = SuperUndefined() + with pytest.raises(AttributeError): + sup_undefined.__getattr__("__dunder_attr__") + + def test_calling_an_instance_returns_empty_string(self): + """Calling a SuperUndefined instance returns an empty string.""" + sup_undefined = SuperUndefined() + assert sup_undefined.__call__() == "" + + +class TestInheritanceLoader: + """Tests for InheritanceLoader.""" + + def test_get_source_returns_inheritance_key(self, init_map: InheritanceMap): + """""" + # Prepare environments and data + loader = InheritanceLoader(init_map) + env = Environment(loader=loader) + template_name = "a.txt" + + # Test when the get_source is provided with valid template name + try: + source, _, _ = env.loader.get_source(env, template_name) + assert isinstance(source, str) + except TemplateNotFound as e: + pytest.fail(f"Unexpected TemplateNotFound error: {str(e)}") + + def test_get_source_for_missing_key_raises_error(self, init_map: InheritanceMap): + # Test when the get_source is provided with invalid template name + loader = InheritanceLoader(init_map) + env = Environment(loader=loader) + template_name = "invalid_template" + + with pytest.raises(TemplateNotFound): + env.loader.get_source(env, "invalid_template") + + def test_get_source_beyond_inheritance_raises_error(self, init_map: InheritanceMap): + """Test when the get_source is provided with a template number beyond the inheritance.""" + loader = InheritanceLoader(init_map) + env = Environment(loader=loader) + template_name = "a.txt" + + with pytest.raises(TemplateNotFound): + env.loader.get_source(env, f"3/{template_name}") + + def test_get_source_for_last_template_removes_extends_tag(self, init_map: InheritanceMap): + """Test when the get_source is provided with a template number one below the inheritance.""" + loader = InheritanceLoader(init_map) + env = Environment(loader=loader) + template_name = "a.txt" + + source = env.loader.get_source(env, f"2/{template_name}") + assert source[0] == "" diff --git a/tests/test_rendering/test_templates.py b/tests/test_rendering/test_templates.py new file mode 100644 index 0000000..eed8465 --- /dev/null +++ b/tests/test_rendering/test_templates.py @@ -0,0 +1,101 @@ +"""Tests for project_forge.rendering.templates.""" + +from pathlib import Path + +from project_forge.rendering import templates +import pytest +from project_forge.rendering.templates import catalog_templates, catalog_inheritance, InheritanceMap +from icecream import ic + + +def generate_fake_templates(location: Path): + """Create a directory of dummy templates.""" + template1_dir = location / "template1" + template1_dir.mkdir(parents=True, exist_ok=True) + subdir = template1_dir / "subdir" + subdir.mkdir() + empty_dir = template1_dir / "empty" + empty_dir.mkdir() + template2_dir = location / "template2" + template2_dir.mkdir(parents=True, exist_ok=True) + + (template1_dir / "inherit.txt").touch() + (template2_dir / "inherit.txt").touch() + (template1_dir / "template1.txt").touch() + (template2_dir / "template2.txt").touch() + (subdir / "subdir.txt").touch() + + +class TestCatalogTemplates: + """Tests of the `catalog_templates` function.""" + + def test_result_keys_are_relative_filepaths(self, tmp_path: Path): + """The returned keys are relative filepaths as strings.""" + # Assemble + generate_fake_templates(tmp_path) + template1 = tmp_path / "template1" + expected_keys = {"subdir", "empty", "inherit.txt", "subdir/subdir.txt", "template1.txt"} + + # Act + result = catalog_templates(template1) + + # Assert + assert set(result.keys()) == expected_keys + + for key in expected_keys: + assert (template1 / key).exists() + + def test_result_values_are_full_paths(self, tmp_path: Path): + """The returned values are full filepaths as `Path`s.""" + # Assemble + generate_fake_templates(tmp_path) + template1 = tmp_path / "template1" + expected_keys = {"subdir", "empty", "inherit.txt", "subdir/subdir.txt", "template1.txt"} + + # Act + result = catalog_templates(template1) + + # Assert + for value in result.values(): + assert value.exists() + assert value.is_absolute() + + +class TestCatalogInheritance: + """Tests for the `catalog_inheritance` function.""" + + def test_empty_list_results_in_empty_map(self): + """Cataloging an empty list returns an empty InheritanceMap.""" + result = catalog_inheritance([]) + assert isinstance(result, InheritanceMap) + assert len(result.maps) == 1 + assert len(result.maps[0]) == 0 + + def test_single_path_results_in_one_extra_map(self, tmp_path: Path): + generate_fake_templates(tmp_path) + template_paths = [tmp_path / "template1"] + result = catalog_inheritance(template_paths) + assert isinstance(result, InheritanceMap) + assert len(result.maps) == 2, "InheritanceMap should have one child for a single element template_paths list" + + def test_multiple_paths_has_multiple_maps(self, tmp_path: Path): + generate_fake_templates(tmp_path) + template_paths = [tmp_path / "template1", tmp_path / "template2"] + + result = catalog_inheritance(template_paths) + assert isinstance(result, InheritanceMap) + assert ( + len(result.maps) == len(template_paths) + 1 + ), "Number of children should match number of template paths plus 1" + assert result.maps[0] == { + "inherit.txt": tmp_path / "template2/inherit.txt", + "template2.txt": tmp_path / "template2/template2.txt", + } + assert result.maps[1] == { + "inherit.txt": tmp_path / "template1/inherit.txt", + "template1.txt": tmp_path / "template1/template1.txt", + "subdir/subdir.txt": tmp_path / "template1/subdir/subdir.txt", + "empty": tmp_path / "template1/empty", + "subdir": tmp_path / "template1/subdir", + } + assert result.maps[2] == {}