Source code for mafw.scaffold.renderer

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Rendering of the canonical project template.

The canonical template is shipped as package data under
``mafw/scaffold/template`` and read back through :mod:`importlib.resources`, so it
travels with the installed MAFw distribution and works identically from a wheel, a
source install, or an isolated ``uvx`` environment.

Rendering uses the standard-library :class:`string.Template` with a small, closed
set of placeholders. :meth:`string.Template.substitute` (never ``safe_substitute``)
is used so that an unknown or missing placeholder is a hard error, turning template
drift into an immediate failure rather than silent corruption. Each placeholder
value is escaped once per target format (TOML, Python, Markdown, path) before
substitution.

.. versionadded:: 2.4
"""

from __future__ import annotations

import string
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from datetime import date
from importlib.resources import files
from importlib.resources.abc import Traversable
from pathlib import PurePosixPath

from mafw.scaffold.errors import MissingTemplateError
from mafw.scaffold.spec import ProjectSpec

TEMPLATE_PACKAGE = 'mafw.scaffold'
"""Package that owns the bundled template resource tree."""

TEMPLATE_DIRNAME = 'template'
"""Name of the template resource directory within :data:`TEMPLATE_PACKAGE`."""

TEMPLATE_SUFFIX = '.tmpl'
"""Suffix marking a template file; stripped from the rendered output name."""

IMPORT_NAME_PLACEHOLDER = '__import_name__'
"""Directory-name token in the template tree replaced by the project import name."""

# Special rendered-name mappings for files whose literal name cannot live in the
# source tree (a leading dot would be hidden / mishandled by some packaging tools).
_SPECIAL_OUTPUT_NAMES = {
    'gitignore': '.gitignore',
}

PLACEHOLDER_KEYS = frozenset(
    {
        'distribution_name',
        'import_name',
        'description',
        'python_version',
        'requires_python',
        'mafw_version',
        'year',
        'example_processor_name',
        'author_name',
        'author_email',
        'license_id',
    }
)
"""The closed set of placeholder keys available to every template.

A unit test asserts that the placeholders discovered across the shipped templates
are a subset of these keys, so a template can never reference an undefined
placeholder.
"""

EXAMPLE_PROCESSOR_NAME = 'ExampleProcessor'
"""Class name of the generated example Processor (also its registered name)."""


[docs] @dataclass(frozen=True) class RenderedFile: """A single rendered output file. :ivar relative_path: The output path relative to the project root, using POSIX separators. :ivar content: The fully rendered file content. """ relative_path: PurePosixPath content: str
[docs] def _toml_basic_string(value: str) -> str: """Escape a value for inclusion as a TOML basic string body (without quotes). Only the characters that are illegal inside a TOML basic string are escaped, which is sufficient for the controlled values (description, names) placed into the generated ``pyproject.toml``. :param value: The raw value to escape. :type value: str :return: The escaped value, safe to embed between double quotes in TOML. :rtype: str """ out: list[str] = [] for char in value: if char == '\\': out.append('\\\\') elif char == '"': out.append('\\"') elif char == '\n': out.append('\\n') elif char == '\t': out.append('\\t') elif char == '\r': out.append('\\r') else: out.append(char) return ''.join(out)
[docs] def build_context(spec: ProjectSpec) -> dict[str, str]: """Build the placeholder substitution context from a project specification. Values are escaped per target format where needed. The import name is already validated to the identifier grammar, so it needs no escaping; free-text values that land in TOML (the description, the distribution name) are escaped as TOML basic strings. :param spec: The validated project specification. :type spec: mafw.scaffold.spec.ProjectSpec :return: A mapping from placeholder key to its rendered string value. :rtype: dict[str, str] """ context = { 'distribution_name': _toml_basic_string(spec.distribution_name), 'import_name': spec.import_name, 'description': _toml_basic_string(spec.description), 'python_version': spec.python_version, 'requires_python': f'>={spec.python_version}', 'mafw_version': spec.mafw_version, 'year': str(date.today().year), 'example_processor_name': EXAMPLE_PROCESSOR_NAME, # Author name/e-mail land in TOML (pyproject) and in plain-text headers, so # they are escaped as TOML basic strings for safe embedding there. 'author_name': _toml_basic_string(spec.author_name), 'author_email': _toml_basic_string(spec.author_email), 'license_id': spec.license_id, } # Guard against accidental drift between the declared key set and the context. assert set(context) == set(PLACEHOLDER_KEYS) return context
[docs] def _template_root() -> Traversable: """Return the root of the bundled template resource tree. :return: A traversable pointing at the template directory. :rtype: Traversable :raises mafw.scaffold.errors.MissingTemplateError: If the template directory is absent. """ root = files(TEMPLATE_PACKAGE) / TEMPLATE_DIRNAME if not root.is_dir(): raise MissingTemplateError('The bundled project template is missing from the installed MAFw distribution.') return root
[docs] def _iter_template_files(node: Traversable, prefix: PurePosixPath) -> Iterator[tuple[PurePosixPath, str]]: """Recursively yield ``(relative_path, raw_text)`` pairs for every template file. :param node: The current traversable node (file or directory). :type node: Traversable :param prefix: The accumulated relative path of ``node``. :type prefix: PurePosixPath :return: An iterator of relative paths and their raw template text. :rtype: Iterator[tuple[PurePosixPath, str]] """ if node.is_dir(): for child in node.iterdir(): yield from _iter_template_files(child, prefix / child.name) else: yield prefix, node.read_text(encoding='utf-8')
[docs] def _render_output_name(name: str) -> str: """Map a template file name to its rendered output name. This strips the ``.tmpl`` suffix and applies the special-name table (for example ``gitignore`` becomes ``.gitignore``). :param name: The template file name. :type name: str :return: The rendered output file name. :rtype: str """ if name.endswith(TEMPLATE_SUFFIX): name = name[: -len(TEMPLATE_SUFFIX)] return _SPECIAL_OUTPUT_NAMES.get(name, name)
[docs] def _render_path(relative: PurePosixPath, import_name: str) -> PurePosixPath: """Render a template-relative path into its output path. Directory tokens equal to :data:`IMPORT_NAME_PLACEHOLDER` are replaced by the project import name, and the final component is mapped through :func:`_render_output_name`. :param relative: The template-relative path. :type relative: PurePosixPath :param import_name: The project import name. :type import_name: str :return: The rendered output path, relative to the project root. :rtype: PurePosixPath """ parts = [import_name if part == IMPORT_NAME_PLACEHOLDER else part for part in relative.parts] parts[-1] = _render_output_name(parts[-1]) return PurePosixPath(*parts)
[docs] def render_project(spec: ProjectSpec) -> list[RenderedFile]: """Render every template file for the given project specification. :param spec: The validated project specification. :type spec: mafw.scaffold.spec.ProjectSpec :return: The list of rendered files, each with its project-relative path. :rtype: list[RenderedFile] :raises mafw.scaffold.errors.MissingTemplateError: If the bundled template is absent or empty. :raises KeyError: If a template references an undefined placeholder. """ context = build_context(spec) root = _template_root() rendered: list[RenderedFile] = [] for relative, raw_text in _iter_template_files(root, PurePosixPath()): # substitute() raises KeyError on an unknown placeholder, so drift fails loudly. content = string.Template(raw_text).substitute(context) output_path = _render_path(relative, spec.import_name) rendered.append(RenderedFile(relative_path=output_path, content=content)) if not rendered: raise MissingTemplateError('The bundled project template contains no files.') # Deterministic ordering keeps generation byte-reproducible regardless of the # filesystem iteration order of the resource reader. rendered.sort(key=lambda item: str(item.relative_path)) return rendered
[docs] def discover_placeholders(mapping: Mapping[str, str] | None = None) -> set[str]: """Return the set of placeholder identifiers referenced across all templates. This is used by the test-suite to assert that templates reference only keys in :data:`mafw.scaffold.renderer.PLACEHOLDER_KEYS`. :param mapping: Unused; kept for API symmetry with callers that pass a context. :type mapping: Mapping[str, str] | None :return: The set of placeholder identifiers found in the templates. :rtype: set[str] """ root = _template_root() found: set[str] = set() for _relative, raw_text in _iter_template_files(root, PurePosixPath()): template = string.Template(raw_text) # Pattern groups: 'named' and 'braced' capture ${name} / $name identifiers. for match in template.pattern.finditer(raw_text): identifier = match.group('named') or match.group('braced') if identifier is not None: found.add(identifier) return found