template-engine-copier skill: initial import
Copier + Jinja template-repository design skill: reference architecture, implementation workflow, validation checklist, and a full worked example template (questionnaire, extensions, resolvers, wrapper-first .scripts). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
---
|
||||
_template_suffix: ".jinja"
|
||||
|
||||
_exclude:
|
||||
- ".git"
|
||||
- ".copier"
|
||||
- ".scripts"
|
||||
- ".venv"
|
||||
- "Taskfile.yml"
|
||||
- "pyproject.toml"
|
||||
- "copier-answers-new.yml"
|
||||
|
||||
_jinja_extensions:
|
||||
- copier_templates_extensions.TemplateExtensionLoader
|
||||
- .copier/extensions/context_hook_extension/hook.py:ContextHookExtension
|
||||
- .copier/extensions/resolvers/resolve_safe_identifier.py:SafeIdentifierExtension
|
||||
|
||||
_answers_file: copier-answers.yml
|
||||
|
||||
!include .copier/questionnaire/*.yml
|
||||
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from copier_templates_extensions import ContextHook
|
||||
|
||||
|
||||
class ContextHookExtension(ContextHook):
|
||||
update = False
|
||||
|
||||
def hook(self, context):
|
||||
_ensure_flat_variables(context)
|
||||
_ensure_resources_instances(context)
|
||||
return context
|
||||
|
||||
|
||||
def _ensure_flat_variables(context):
|
||||
if 'flat' not in context or not isinstance(context.get('flat'), dict):
|
||||
context['flat'] = {}
|
||||
|
||||
metadata = context.get('template_metadata', {}) if isinstance(context, dict) else {}
|
||||
features = context.get('features', {}) if isinstance(context, dict) else {}
|
||||
modules = features.get('modules', {}) if isinstance(features, dict) else {}
|
||||
|
||||
domain_name = metadata.get('domain', '') if isinstance(metadata, dict) else ''
|
||||
project_name = metadata.get('project', '') if isinstance(metadata, dict) else ''
|
||||
|
||||
context['flat']['domain_name'] = domain_name
|
||||
context['flat']['project_name'] = project_name
|
||||
|
||||
instances = modules.get('instances', []) if isinstance(modules, dict) else []
|
||||
is_enabled = bool(modules.get('is_enabled', False)) if isinstance(modules, dict) else False
|
||||
|
||||
names = []
|
||||
for instance in instances if isinstance(instances, list) else []:
|
||||
if isinstance(instance, dict) and instance.get('name'):
|
||||
names.append(f"{domain_name}-{project_name}-{instance['name']}")
|
||||
|
||||
context['flat']['modules_is_enabled'] = is_enabled
|
||||
context['flat']['module_instance_names'] = names
|
||||
|
||||
|
||||
def _ensure_resources_instances(context):
|
||||
"""Expand folders containing [INDEX] into concrete per-instance directories."""
|
||||
base_path = './src'
|
||||
if not os.path.exists(base_path):
|
||||
return
|
||||
|
||||
targets = []
|
||||
for root, dirs, _ in os.walk(base_path):
|
||||
for d in dirs:
|
||||
if '[INDEX]' in d:
|
||||
targets.append(os.path.join(root, d))
|
||||
|
||||
for path in targets:
|
||||
parent = os.path.dirname(path)
|
||||
instances = context.get('flat', {}).get('module_instance_names', [])
|
||||
for idx, name in enumerate(instances):
|
||||
dst = os.path.join(parent, name)
|
||||
if os.path.exists(dst):
|
||||
continue
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
for fn in os.listdir(path):
|
||||
src_file = os.path.join(path, fn)
|
||||
if not os.path.isfile(src_file):
|
||||
continue
|
||||
with open(src_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read().replace('INDEX', str(idx))
|
||||
with open(os.path.join(dst, fn), 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
if os.path.exists(path):
|
||||
shutil.rmtree(path)
|
||||
@@ -0,0 +1,17 @@
|
||||
from jinja2.ext import Extension
|
||||
|
||||
|
||||
def resolve_safe_identifier(name: str) -> str:
|
||||
return (
|
||||
name
|
||||
.replace("-", "_")
|
||||
.replace("{", "")
|
||||
.replace("}", "")
|
||||
.replace(".", "_")
|
||||
)
|
||||
|
||||
|
||||
class SafeIdentifierExtension(Extension):
|
||||
def __init__(self, environment):
|
||||
super().__init__(environment)
|
||||
environment.globals["resolve_safe_identifier"] = resolve_safe_identifier
|
||||
@@ -0,0 +1 @@
|
||||
from .loggers import info, warning, debug
|
||||
@@ -0,0 +1,16 @@
|
||||
import logging
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def info(name, message, extra=None):
|
||||
logging.getLogger(name).info(message)
|
||||
|
||||
|
||||
def warning(name, message, extra=None):
|
||||
logging.getLogger(name).warning(message)
|
||||
|
||||
|
||||
def debug(name, message, extra=None):
|
||||
logging.getLogger(name).debug(message)
|
||||
@@ -0,0 +1,18 @@
|
||||
template_metadata:
|
||||
type: yaml
|
||||
help: "Generic template metadata used by naming and rendering hooks"
|
||||
default: |
|
||||
domain: demo
|
||||
project: sample-product
|
||||
|
||||
features:
|
||||
type: yaml
|
||||
help: "Feature/module matrix for template rendering"
|
||||
default: |
|
||||
modules:
|
||||
is_enabled: true
|
||||
instances:
|
||||
- name: users
|
||||
version: 1.0.0
|
||||
- name: billing
|
||||
version: 1.0.0
|
||||
@@ -0,0 +1,9 @@
|
||||
version: "3"
|
||||
|
||||
tasks:
|
||||
bootstrap-one:
|
||||
desc: -- --copier-force-copy "<true|false>" --copier-target-dir "<path>" --copier-params-file "<path>"
|
||||
cmds:
|
||||
- |
|
||||
./.scripts/copier/api/bootstrap-one.sh {{ .CLI_ARGS }}
|
||||
silent: true
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
. ./.scripts/copier/lib/--index.sh "$@"
|
||||
|
||||
_copier_bootstrap_one
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
export HL_V1_COPIER_TARGET_DIR=""
|
||||
export HL_V1_COPIER_FORCE_COPY=false
|
||||
export HL_V1_COPIER_PARAMS_FILE=""
|
||||
export HL_V1_COPIER_FILE_COPIER_ANSWERS="copier-answers.yml"
|
||||
export HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW="copier-answers-new.yml"
|
||||
|
||||
ALL_ARGS=("$@")
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--copier-target-dir=*)
|
||||
export HL_V1_COPIER_TARGET_DIR="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--copier-target-dir)
|
||||
export HL_V1_COPIER_TARGET_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--copier-force-copy=*)
|
||||
export HL_V1_COPIER_FORCE_COPY="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--copier-force-copy)
|
||||
export HL_V1_COPIER_FORCE_COPY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--copier-params-file=*)
|
||||
export HL_V1_COPIER_PARAMS_FILE="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--copier-params-file)
|
||||
export HL_V1_COPIER_PARAMS_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
set -- "${ALL_ARGS[@]}"
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
. ./.scripts/copier/lib/answers/--index-api.sh
|
||||
. ./.scripts/copier/lib/-bootstrap-one.sh
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
. ./.scripts/copier/lib/--env-vars-reader.sh "$@"
|
||||
. ./.scripts/copier/lib/--index-api.sh
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
_copier_bootstrap_one() {
|
||||
[ -z "${HL_V1_COPIER_TARGET_DIR}" ] && {
|
||||
echo "--copier-target-dir is required"
|
||||
return 1
|
||||
}
|
||||
|
||||
_copier_answers_new_ensure_existence
|
||||
_copier_answers_previous_pull_and_merge
|
||||
_copier_answers_params_pull_and_merge
|
||||
|
||||
if [ "${HL_V1_COPIER_FORCE_COPY}" = "true" ]; then
|
||||
copier copy \
|
||||
--trust \
|
||||
--force \
|
||||
--overwrite \
|
||||
--data-file "${HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW}" \
|
||||
. "${HL_V1_COPIER_TARGET_DIR}"
|
||||
else
|
||||
copier copy \
|
||||
--trust \
|
||||
--data-file "${HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW}" \
|
||||
. "${HL_V1_COPIER_TARGET_DIR}"
|
||||
fi
|
||||
|
||||
# Keep template repo CI separate from rendered repo CI, same pattern as source repos.
|
||||
if [ -f "${HL_V1_COPIER_TARGET_DIR}/.tmp-gitlab-ci.yml" ]; then
|
||||
mv "${HL_V1_COPIER_TARGET_DIR}/.tmp-gitlab-ci.yml" "${HL_V1_COPIER_TARGET_DIR}/.gitlab-ci.yml"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
. ./.scripts/copier/lib/answers/-new-ensure-existance.sh
|
||||
. ./.scripts/copier/lib/answers/-previous-pull-and-merge.sh
|
||||
. ./.scripts/copier/lib/answers/-params-pull-and-merge.sh
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
_copier_answers_new_ensure_existence() {
|
||||
[ -f "${HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW}" ] || touch "${HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW}"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
_copier_answers_params_pull_and_merge() {
|
||||
[ -f "${HL_V1_COPIER_PARAMS_FILE}" ] || return 0
|
||||
[ -f "${HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW}" ] || return 0
|
||||
|
||||
if command -v yq >/dev/null 2>&1; then
|
||||
local TMP_WORK_FILE="tmp-work-file.yml"
|
||||
yq eval-all '. as $item ireduce ({}; . * $item)' \
|
||||
"${HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW}" \
|
||||
"${HL_V1_COPIER_PARAMS_FILE}" >"${TMP_WORK_FILE}"
|
||||
mv "${TMP_WORK_FILE}" "${HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW}"
|
||||
else
|
||||
# Fallback: params file wins as full override when yq is unavailable.
|
||||
cp "${HL_V1_COPIER_PARAMS_FILE}" "${HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW}"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
_copier_answers_previous_pull_and_merge() {
|
||||
local PREVIOUS="${HL_V1_COPIER_TARGET_DIR}/${HL_V1_COPIER_FILE_COPIER_ANSWERS}"
|
||||
[ -f "${PREVIOUS}" ] && cp "${PREVIOUS}" "${HL_V1_COPIER_FILE_COPIER_ANSWERS_NEW}" || true
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Example Template Repository
|
||||
|
||||
This is a minimal but concrete Copier + Jinja template repository scaffold.
|
||||
|
||||
It is intentionally **domain-generic** and demonstrates:
|
||||
|
||||
- metadata + features questionnaire model,
|
||||
- dynamic module/component folder generation,
|
||||
- safe identifier resolver,
|
||||
- shell-based bootstrap/answers merge flow.
|
||||
|
||||
## Render example
|
||||
|
||||
```bash
|
||||
task copier:bootstrap-one -- --copier-target-dir "../rendered-example" --copier-params-file "./copier-answers.example.yaml"
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
copier: ./.scripts/copier/Taskfile.yml
|
||||
@@ -0,0 +1,12 @@
|
||||
template_metadata:
|
||||
domain: demo
|
||||
project: sample-product
|
||||
|
||||
features:
|
||||
modules:
|
||||
is_enabled: true
|
||||
instances:
|
||||
- name: users
|
||||
version: 1.0.0
|
||||
- name: billing
|
||||
version: 1.0.0
|
||||
@@ -0,0 +1,8 @@
|
||||
[project]
|
||||
name = "example-template-repo"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"copier==9.14.0",
|
||||
"copier-templates-extensions==0.3.2",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
{% macro render() %}
|
||||
{% set metadata = template_metadata if template_metadata is mapping else {} %}
|
||||
{% set features_map = features if features is mapping else {} %}
|
||||
{% set modules = features_map.get('modules', {}) if features_map else {} %}
|
||||
|
||||
Template Domain: {{ metadata.get('domain', 'unknown-domain') }}
|
||||
Template Project: {{ metadata.get('project', 'unknown-project') }}
|
||||
|
||||
{% if modules.get('is_enabled', false) -%}
|
||||
Include module components from ./components/
|
||||
{% else -%}
|
||||
No modules enabled.
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{%- set result = render() -%}
|
||||
{%- for line in result.split('\n') -%}
|
||||
{% if line | trim | length != 0 -%}
|
||||
{{ line }}
|
||||
{% endif %}
|
||||
{%- endfor %}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{% set instances_names = flat.get('module_instance_names', []) %}
|
||||
|
||||
{% for item in instances_names %}
|
||||
component: {{ resolve_safe_identifier(item) }}
|
||||
{% endfor %}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
component_id={{ resolve_safe_identifier(flat.module_instance_names[INDEX]) }}
|
||||
component_name={{ flat.module_instance_names[INDEX] }}
|
||||
generated_with=copier+jinja
|
||||
@@ -0,0 +1,17 @@
|
||||
stages:
|
||||
- lint
|
||||
- test
|
||||
|
||||
template-content-check:
|
||||
stage: lint
|
||||
image: alpine:3.20
|
||||
script:
|
||||
- test -f README.md
|
||||
- test -f copier-answers.example.yaml
|
||||
- test -d src
|
||||
|
||||
template-smoke-test:
|
||||
stage: test
|
||||
image: alpine:3.20
|
||||
script:
|
||||
- echo "Template repository scaffold is present"
|
||||
@@ -0,0 +1,2 @@
|
||||
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
|
||||
{{ _copier_answers|to_nice_yaml -}}
|
||||
Reference in New Issue
Block a user