Call sub-templates from a hook#

This guide shows how to drive sub-template generation from a top-level template's post_gen_project.py, using cookieplone.utils.subtemplates.run_subtemplates() and a dictionary of custom handlers. It uses the monorepo project template from plone/cookieplone-templates as a reference: that template composes a full Plone project out of seven sub-templates (backend, frontend, docs, cache, project settings, CI, and VS Code configuration).

Prerequisites#

  • You already have a template repository that declares sub-templates (see Sub-templates).

  • Your main template's cookieplone.json lists the sub-templates to run under config.subtemplates.

  • You know which sub-templates need custom handling (extra context, folder rewrites, post-processing) and which can fall through to the default generator.

Step 1: Import the helper#

At the top of hooks/post_gen_project.py:

from collections import OrderedDict
from pathlib import Path

from cookieplone import generator
from cookieplone.utils import console, files, git, npm, plone
from cookieplone.utils.subtemplates import run_subtemplates

context: OrderedDict = {{cookiecutter}}
versions: dict | OrderedDict = {{versions}}

The {{cookiecutter}} and {{versions}} markers are Jinja substitutions that Cookiecutter fills in with the rendered context and the loaded global_versions mapping.

Step 2: Write one handler per sub-template#

A handler must have the signature (context: OrderedDict, output_dir: Path) -> Path and return the directory that the sub-template generated into. run_subtemplates() passes a deep copy of the context to each handler, so mutating context in place is safe.

A simple handler#

The backend sub-template needs a headless flavor and should skip CI/docs scaffolding (those belong to the parent project), then clean up residual .git artifacts:

BACKEND_ADDON_REMOVE = [".git"]
TEMPLATES_FOLDER = "templates"


def generate_addons_backend(context: OrderedDict, output_dir: Path) -> Path:
    """Run the Plone backend add-on generator."""
    folder_name = "backend"
    context["feature_headless"] = "1"
    context["initialize_ci"] = "0"
    context["initialize_documentation"] = "0"
    path = generator.generate_subtemplate(
        f"{TEMPLATES_FOLDER}/add-ons/backend",
        output_dir,
        folder_name,
        context,
        BACKEND_ADDON_REMOVE,
    )
    files.remove_files(output_dir / folder_name, BACKEND_ADDON_REMOVE)
    return path

Key points:

  • The handler mutates context freely; the copy is local.

  • It returns the Path produced by cookieplone.generator.generate_subtemplate().

  • Extra cleanup (files.remove_files) happens after generation but still inside the handler.

A handler with post-processing#

The frontend handler does more work: it normalizes scoped npm package names, disables release automation in .release-it.json, and rewrites hard-coded repository URLs in the generated files.

def generate_addons_frontend(context: OrderedDict, output_dir: Path) -> Path:
    """Run the Volto add-on generator."""
    folder_name = "frontend"
    context = _fix_frontend_addon_name(context)
    frontend_addon_name = context["frontend_addon_name"]
    context["initialize_documentation"] = "0"
    context["initialize_ci"] = "0"
    path = generator.generate_subtemplate(
        f"{TEMPLATES_FOLDER}/add-ons/frontend",
        output_dir,
        folder_name,
        context,
        FRONTEND_ADDON_REMOVE,
    )
    # Disable release automation that only makes sense for stand-alone add-ons.
    release_it_path = path / "packages" / frontend_addon_name / ".release-it.json"
    if release_it_path.is_file():
        data = json.loads(release_it_path.read_text())
        data["github"]["release"] = False
        data["plonePrePublish"]["publish"] = False
        data["npm"]["publish"] = False
        release_it_path.write_text(json.dumps(data, indent=2))
    # Rewrite stand-alone repository URLs to point at the parent monorepo.
    _find_replace_in_folder(path, {
        "https://github.com/.../frontend_addon_name":
            "{{ cookiecutter.__repository_url }}",
    })
    return path

A handler with a composed context#

Some sub-templates run against a narrower context built from the parent's answers. For example, the GitHub Actions CI sub-template only needs a handful of derived values:

def generate_ci_gh_project(context: OrderedDict, output_dir: Path) -> Path:
    """Generate GitHub Actions workflows for the monorepo."""
    ci_context = OrderedDict({
        "npm_package_name": context["__npm_package_name"],
        "container_image_prefix": context["__container_image_prefix"],
        "python_version": versions["backend_python"],
        "node_version": context["__node_version"],
        "has_cache": context["devops_cache"],
        "has_docs": context["initialize_documentation"],
        "has_deploy": context["devops_gha_deploy"],
        "__cookieplone_repository_path": context["__cookieplone_repository_path"],
    })
    return generator.generate_subtemplate(
        f"{TEMPLATES_FOLDER}/ci/gh_project",
        output_dir,
        ".github",
        ci_context,
    )

The generated files land in .github/ at the project root, which is why the handler passes folder_name=".github" explicitly.

A handler that rewrites the output directory#

Some sub-templates need to be rendered as the parent directory. For example, a cache sub-template that adds files next to the project without introducing a new folder:

def generate_sub_cache(context: OrderedDict, output_dir: Path) -> Path:
    """Add cache structure to the existing project folder."""
    folder_name = output_dir.name
    parent_dir = output_dir.parent
    return generator.generate_subtemplate(
        f"{TEMPLATES_FOLDER}/sub/cache", parent_dir, folder_name, context,
    )

Step 3: Register the handlers#

Collect every handler in a module-level dict keyed by the sub-template id (the same id declared in config.subtemplates in your cookieplone.json):

SUBTEMPLATE_HANDLERS = {
    "add-ons/backend": generate_addons_backend,
    "add-ons/frontend": generate_addons_frontend,
    "docs/starter": generate_docs_starter,
    "sub/cache": generate_sub_cache,
    "sub/project_settings": generate_sub_project_settings,
    "ci/gh_project": generate_ci_gh_project,
    "ide/vscode": generate_ide_vscode,
}

If a sub-template is declared in config.subtemplates but not present in this dict, run_subtemplates() will fall back to a default call with the entry's folder_name. That fallback is useful for straightforward sub-templates that only need the defaults.

Step 4: Invoke run_subtemplates() from main()#

Inside the main() function of your post-generation hook, replace any manual loop over __cookieplone_subtemplates with a single call:

def main():
    output_dir = Path.cwd()

    # {{ cookiecutter.__cookieplone_subtemplates }}
    run_subtemplates(
        context,
        output_dir,
        handlers=SUBTEMPLATE_HANDLERS,
        global_versions=versions,
    )

    # Continue with other post-generation tasks (namespace packages,
    # code formatting, git initialization, ...).
    plone.create_namespace_packages(
        output_dir / "backend/src/packagename",
        context.get("python_package_name"),
        style="native",
    )


if __name__ == "__main__":
    main()

Propagating version pins#

The global_versions parameter passes the parent template's version pins (from config.versions) to each child sub-template. Without it, child templates cannot use {{ versions.X }} expressions because the versions dict would be empty when the sub-template renders.

Pass the versions variable that Cookiecutter renders from the repository's cookieplone-config.json:

versions: dict | OrderedDict = {{versions}}

# later, in main():
run_subtemplates(context, output_dir, handlers=SUBTEMPLATE_HANDLERS, global_versions=versions)

If your handlers call cookieplone.generator.generate_subtemplate() directly, pass global_versions there too:

def generate_addons_backend(context: OrderedDict, output_dir: Path) -> Path:
    return generator.generate_subtemplate(
        f"{TEMPLATES_FOLDER}/add-ons/backend",
        output_dir,
        "backend",
        context,
        global_versions=versions,
    )

The trailing # {{ cookiecutter.__cookieplone_subtemplates }} comment is important: it ensures Cookiecutter treats the sub-templates list as a rendered context value, which is what run_subtemplates() then reads at runtime.

Why use run_subtemplates()#

Before the helper existed, every monorepo hook implemented its own loop:

# Old pattern, do not copy into new templates.
funcs = {k: v for k, v in globals().items() if k.startswith("generate_")}
for template_id, title, enabled in subtemplates:
    template_slug = template_id.replace("/", "_").replace("-", "")
    func_name = f"generate_{template_slug}"
    func = funcs.get(func_name)
    if not func:
        raise ValueError(f"No handler for {template_id}")
    ...

Using run_subtemplates() gives you:

  • Explicit dispatch. Handlers are wired up in a visible dict rather than discovered by name munging.

  • Consistent logging and deep-copying. The helper prints each step and guarantees that handlers can not accidentally mutate a shared context.

  • Default fallback. Simple sub-templates no longer require a matching handler at all.

  • Schema compatibility. Both the legacy [id, title, enabled] format and the dict format with folder_name are accepted transparently.

Full example#

The complete reference implementation is the monorepo template in the cookieplone-templates repository:

Read it alongside this guide when you build your own multi-part template.