Skip to content

Commit fbce8ce

Browse files
ulises-jeremiasulises-jeremias
andauthored
feat(core): Jinja2 .template processing and .append convention (#149)
Render .template / .append.template with StrictUndefined, append .content via .append, and pass projectName + customOptions + --set into the scaffold context. Closes #146 Co-authored-by: ulises-jeremias <ulises.jeremias@users.noreply.github.com>
1 parent cf8c746 commit fbce8ce

6 files changed

Lines changed: 325 additions & 21 deletions

File tree

packages/create-python-app-core/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
88
"httpx>=0.28.1",
9+
"jinja2>=3.1.0",
910
"packaging>=24.0",
1011
]
1112

packages/create-python-app-core/src/create_python_app_core/installer.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from pathlib import Path
99
from typing import Any
1010

11-
from create_python_app_core.config import assert_directory_is_empty, load_cpa_config
11+
from create_python_app_core.config import (
12+
CpaConfig,
13+
assert_directory_is_empty,
14+
load_cpa_config,
15+
)
1216
from create_python_app_core.errors import CpaError, ScaffoldAbortedError
1317
from create_python_app_core.git_cache import download_repository
1418
from create_python_app_core.loaders import merge_layers
@@ -29,6 +33,31 @@ def uv_sync(dest: Path) -> None:
2933
_run(["uv", "sync"], cwd=dest)
3034

3135

36+
def _config_path(source: ResolvedSource, root: Path) -> Path:
37+
cfg_path = root / "cpa.config.json"
38+
if not cfg_path.is_file() and source.subdir:
39+
cfg_path = root / source.subdir / "cpa.config.json"
40+
return cfg_path
41+
42+
43+
def build_scaffold_context(
44+
project_name: str,
45+
configs: list[CpaConfig],
46+
options: dict[str, Any] | None = None,
47+
) -> dict[str, Any]:
48+
"""Build Jinja context: projectName + customOption defaults + --set overrides."""
49+
context: dict[str, Any] = {"projectName": project_name}
50+
for cfg in configs:
51+
for opt in cfg.custom_options:
52+
if opt.key not in context and opt.default is not None:
53+
context[opt.key] = opt.default
54+
if options:
55+
set_map = options.get("set") or {}
56+
if isinstance(set_map, dict):
57+
context.update(set_map)
58+
return context
59+
60+
3261
def scaffold_project(
3362
project_directory: str,
3463
*,
@@ -43,24 +72,22 @@ def scaffold_project(
4372
options: dict[str, Any] | None = None,
4473
) -> Path:
4574
"""Create a project directory from template + addon layers."""
46-
_ = options
4775
dest = Path(project_directory).expanduser().resolve()
4876
assert_directory_is_empty(dest, force=force)
4977
dest.mkdir(parents=True, exist_ok=True)
5078

5179
specs = [template, *(addons or []), *(extend or [])]
5280
layers: list[tuple[ResolvedSource, Path]] = []
81+
configs: list[CpaConfig] = []
5382
try:
5483
for spec in specs:
5584
source = resolve_source(spec, cache_dir=cache_dir)
5685
root = download_repository(source, offline=offline, cache_root=cache_dir)
5786
layers.append((source, root))
58-
cfg_path = root / "cpa.config.json"
59-
if not cfg_path.is_file() and source.subdir:
60-
cfg_path = root / source.subdir / "cpa.config.json"
61-
load_cpa_config(cfg_path)
87+
configs.append(load_cpa_config(_config_path(source, root)))
6288

63-
merge_layers(layers, dest)
89+
context = build_scaffold_context(dest.name, configs, options)
90+
merge_layers(layers, dest, context=context)
6491

6592
if install and (dest / "pyproject.toml").is_file():
6693
uv_sync(dest)

packages/create-python-app-core/src/create_python_app_core/loaders.py

Lines changed: 118 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,125 @@
44

55
import shutil
66
from pathlib import Path
7+
from typing import Any
78

8-
from create_python_app_core.errors import ManifestLoadError
9+
from jinja2 import Environment, StrictUndefined, TemplateError
10+
11+
from create_python_app_core.errors import ManifestLoadError, ScaffoldAbortedError
912
from create_python_app_core.paths import ResolvedSource, get_template_dir_path
1013

14+
_JINJA = Environment(
15+
undefined=StrictUndefined,
16+
keep_trailing_newline=True,
17+
autoescape=False,
18+
)
19+
20+
21+
def _mode_from_path(rel: Path) -> str:
22+
name = rel.name
23+
if name.endswith(".append.template") or name.endswith(".template.append"):
24+
return "appendTemplate"
25+
if name.endswith(".append"):
26+
return "append"
27+
if name.endswith(".template"):
28+
return "copyTemplate"
29+
return "copy"
30+
31+
32+
def _output_rel(rel: Path) -> Path:
33+
"""Strip processing suffixes from a relative path."""
34+
name = rel.name
35+
for suffix in (".append.template", ".template.append", ".template", ".append"):
36+
if name.endswith(suffix):
37+
name = name[: -len(suffix)]
38+
break
39+
return rel.with_name(name)
40+
41+
42+
def render_template(content: str, context: dict[str, Any], *, path: str) -> str:
43+
"""Render Jinja2 content with StrictUndefined."""
44+
try:
45+
return _JINJA.from_string(content).render(**context)
46+
except TemplateError as exc:
47+
raise ScaffoldAbortedError(
48+
f"Template render failed for {path}: {exc}"
49+
) from exc
50+
51+
52+
def _write_bytes(target: Path, data: bytes, *, append: bool) -> None:
53+
target.parent.mkdir(parents=True, exist_ok=True)
54+
mode = "ab" if append else "wb"
55+
with target.open(mode) as fh:
56+
fh.write(data)
57+
58+
59+
def _write_text(target: Path, text: str, *, append: bool) -> None:
60+
target.parent.mkdir(parents=True, exist_ok=True)
61+
mode = "a" if append else "w"
62+
with target.open(mode, encoding="utf-8") as fh:
63+
fh.write(text)
64+
65+
66+
def process_file(
67+
src: Path,
68+
dest_root: Path,
69+
rel: Path,
70+
*,
71+
context: dict[str, Any],
72+
overwrite: bool = True,
73+
) -> Path | None:
74+
"""Copy / render / append one file into dest_root. Returns written path or None."""
75+
mode = _mode_from_path(rel)
76+
out_rel = _output_rel(rel)
77+
target = dest_root / out_rel
1178

12-
def copy_tree(src: Path, dest: Path, *, overwrite: bool = True) -> list[Path]:
13-
"""Copy files from src into dest. Returns list of written paths."""
79+
if mode == "copy":
80+
if target.exists() and not overwrite:
81+
return None
82+
target.parent.mkdir(parents=True, exist_ok=True)
83+
shutil.copy2(src, target)
84+
return target
85+
86+
if mode == "append":
87+
content = src.read_text(encoding="utf-8")
88+
_write_text(target, content, append=True)
89+
return target
90+
91+
# template modes
92+
rendered = render_template(
93+
src.read_text(encoding="utf-8"),
94+
context,
95+
path=str(rel),
96+
)
97+
append = mode == "appendTemplate"
98+
if target.exists() and not overwrite and not append:
99+
return None
100+
_write_text(target, rendered, append=append)
101+
return target
102+
103+
104+
def copy_tree(
105+
src: Path,
106+
dest: Path,
107+
*,
108+
overwrite: bool = True,
109+
context: dict[str, Any] | None = None,
110+
) -> list[Path]:
111+
"""Copy files from src into dest with .template / .append processing."""
14112
written: list[Path] = []
15113
if not src.is_dir():
16114
raise ManifestLoadError(f"template directory not found: {src}")
17115
dest.mkdir(parents=True, exist_ok=True)
18-
for path in src.rglob("*"):
116+
ctx = context or {}
117+
for path in sorted(src.rglob("*")):
19118
if path.is_dir():
20119
continue
21120
rel = path.relative_to(src)
22-
target = dest / rel
23-
if target.exists() and not overwrite:
24-
continue
25-
target.parent.mkdir(parents=True, exist_ok=True)
26-
shutil.copy2(path, target)
27-
written.append(target)
121+
result = process_file(
122+
path, dest, rel, context=ctx, overwrite=overwrite
123+
)
124+
if result is not None:
125+
written.append(result)
28126
return written
29127

30128

@@ -34,18 +132,25 @@ def load_layer(
34132
dest: Path,
35133
*,
36134
overwrite: bool = True,
135+
context: dict[str, Any] | None = None,
37136
) -> list[Path]:
38137
"""Load one template/extension layer into dest."""
39138
template_root = get_template_dir_path(source, root)
40-
return copy_tree(template_root, dest, overwrite=overwrite)
139+
return copy_tree(
140+
template_root, dest, overwrite=overwrite, context=context
141+
)
41142

42143

43144
def merge_layers(
44145
layers: list[tuple[ResolvedSource, Path]],
45146
dest: Path,
147+
*,
148+
context: dict[str, Any] | None = None,
46149
) -> list[Path]:
47-
"""Apply layers in order: template → addons → extend (later wins)."""
150+
"""Apply layers in order: template → addons → extend (later wins for copies)."""
48151
written: list[Path] = []
49152
for source, root in layers:
50-
written.extend(load_layer(source, root, dest, overwrite=True))
153+
written.extend(
154+
load_layer(source, root, dest, overwrite=True, context=context)
155+
)
51156
return written

packages/create-python-app-core/tests/test_loaders.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from pathlib import Path
22

3-
from create_python_app_core.loaders import merge_layers
3+
import pytest
4+
5+
from create_python_app_core.errors import ScaffoldAbortedError
6+
from create_python_app_core.loaders import merge_layers, process_file, render_template
47
from create_python_app_core.paths import ResolvedSource
58

69

@@ -22,3 +25,49 @@ def test_merge_later_wins(tmp_path: Path) -> None:
2225
merge_layers([a, b], dest)
2326
assert (dest / "README.md").read_text() == "b"
2427
assert (dest / "keep.txt").read_text() == "keep"
28+
29+
30+
def test_template_renders_and_strips_suffix(tmp_path: Path) -> None:
31+
layer = _layer(
32+
tmp_path,
33+
"t",
34+
{"README.md.template": "Hello {{ projectName }}!\n"},
35+
)
36+
dest = tmp_path / "out"
37+
merge_layers([layer], dest, context={"projectName": "demo"})
38+
assert (dest / "README.md").read_text() == "Hello demo!\n"
39+
assert not (dest / "README.md.template").exists()
40+
41+
42+
def test_append_concatenates(tmp_path: Path) -> None:
43+
a = _layer(tmp_path, "a", {"notes.txt": "one\n"})
44+
b = _layer(tmp_path, "b", {"notes.txt.append": "two\n"})
45+
dest = tmp_path / "out"
46+
merge_layers([a, b], dest)
47+
assert (dest / "notes.txt").read_text() == "one\ntwo\n"
48+
49+
50+
def test_append_template(tmp_path: Path) -> None:
51+
a = _layer(tmp_path, "a", {".env.example": "A=1\n"})
52+
b = _layer(
53+
tmp_path,
54+
"b",
55+
{".env.example.append.template": "NAME={{ projectName }}\n"},
56+
)
57+
dest = tmp_path / "out"
58+
merge_layers([a, b], dest, context={"projectName": "api"})
59+
assert (dest / ".env.example").read_text() == "A=1\nNAME=api\n"
60+
61+
62+
def test_strict_undefined_raises() -> None:
63+
with pytest.raises(ScaffoldAbortedError, match="Template render failed"):
64+
render_template("{{ missing }}", {}, path="x.template")
65+
66+
67+
def test_process_file_copy(tmp_path: Path) -> None:
68+
src = tmp_path / "a.txt"
69+
src.write_text("x")
70+
dest = tmp_path / "out"
71+
written = process_file(src, dest, Path("a.txt"), context={})
72+
assert written == dest / "a.txt"
73+
assert (dest / "a.txt").read_text() == "x"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from create_python_app_core.config import CpaConfig, CpaCustomOption
6+
from create_python_app_core.installer import build_scaffold_context, scaffold_project
7+
8+
9+
def test_build_scaffold_context_merges_defaults_and_set() -> None:
10+
cfg = CpaConfig(
11+
custom_options=[
12+
CpaCustomOption(key="apiPrefix", default="/api/v1"),
13+
CpaCustomOption(key="enableCors", default="true"),
14+
]
15+
)
16+
ctx = build_scaffold_context(
17+
"my-api",
18+
[cfg],
19+
{"set": {"enableCors": "false", "extra": "1"}},
20+
)
21+
assert ctx == {
22+
"projectName": "my-api",
23+
"apiPrefix": "/api/v1",
24+
"enableCors": "false",
25+
"extra": "1",
26+
}
27+
28+
29+
def test_scaffold_renders_template_from_set(
30+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
31+
) -> None:
32+
monkeypatch.setenv("CPA_SKIP_GIT", "1")
33+
root = tmp_path / "tpl"
34+
(root / "template").mkdir(parents=True)
35+
(root / "template" / "hello.txt.template").write_text(
36+
"hi {{ projectName }} {{ tag }}\n"
37+
)
38+
dest = tmp_path / "app"
39+
scaffold_project(
40+
str(dest),
41+
template=f"file://{root}",
42+
install=False,
43+
options={"set": {"tag": "x"}},
44+
)
45+
assert (dest / "hello.txt").read_text() == "hi app x\n"

0 commit comments

Comments
 (0)