Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/create-python-app-core/pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.28.1",
"jinja2>=3.1.0",
"packaging>=24.0",
]

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,11 @@
from pathlib import Path
from typing import Any

from create_python_app_core.config import assert_directory_is_empty, load_cpa_config
from create_python_app_core.config import (
CpaConfig,
assert_directory_is_empty,
load_cpa_config,
)
from create_python_app_core.errors import CpaError, ScaffoldAbortedError
from create_python_app_core.git_cache import download_repository
from create_python_app_core.loaders import merge_layers
Expand All@@ -29,6 +33,31 @@ def uv_sync(dest: Path) -> None:
_run(["uv", "sync"], cwd=dest)


def _config_path(source: ResolvedSource, root: Path) -> Path:
cfg_path = root / "cpa.config.json"
if not cfg_path.is_file() and source.subdir:
cfg_path = root / source.subdir / "cpa.config.json"
return cfg_path


def build_scaffold_context(
project_name: str,
configs: list[CpaConfig],
options: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build Jinja context: projectName + customOption defaults + --set overrides."""
context: dict[str, Any] = {"projectName": project_name}
for cfg in configs:
for opt in cfg.custom_options:
if opt.key not in context and opt.default is not None:
context[opt.key] = opt.default
if options:
set_map = options.get("set") or {}
if isinstance(set_map, dict):
context.update(set_map)
return context


def scaffold_project(
project_directory: str,
*,
Expand All@@ -43,24 +72,22 @@ def scaffold_project(
options: dict[str, Any] | None = None,
) -> Path:
"""Create a project directory from template + addon layers."""
_ = options
dest = Path(project_directory).expanduser().resolve()
assert_directory_is_empty(dest, force=force)
dest.mkdir(parents=True, exist_ok=True)

specs = [template, *(addons or []), *(extend or [])]
layers: list[tuple[ResolvedSource, Path]] = []
configs: list[CpaConfig] = []
try:
for spec in specs:
source = resolve_source(spec, cache_dir=cache_dir)
root = download_repository(source, offline=offline, cache_root=cache_dir)
layers.append((source, root))
cfg_path = root / "cpa.config.json"
if not cfg_path.is_file() and source.subdir:
cfg_path = root / source.subdir / "cpa.config.json"
load_cpa_config(cfg_path)
configs.append(load_cpa_config(_config_path(source, root)))

merge_layers(layers, dest)
context = build_scaffold_context(dest.name, configs, options)
merge_layers(layers, dest, context=context)

if install and (dest / "pyproject.toml").is_file():
uv_sync(dest)
Expand Down
131 changes: 118 additions & 13 deletions packages/create-python-app-core/src/create_python_app_core/loaders.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,27 +4,125 @@

import shutil
from pathlib import Path
from typing import Any

from create_python_app_core.errors import ManifestLoadError
from jinja2 import Environment, StrictUndefined, TemplateError

from create_python_app_core.errors import ManifestLoadError, ScaffoldAbortedError
from create_python_app_core.paths import ResolvedSource, get_template_dir_path

_JINJA = Environment(
undefined=StrictUndefined,
keep_trailing_newline=True,
autoescape=False,
)


def _mode_from_path(rel: Path) -> str:
name = rel.name
if name.endswith(".append.template") or name.endswith(".template.append"):
return "appendTemplate"
if name.endswith(".append"):
return "append"
if name.endswith(".template"):
return "copyTemplate"
return "copy"


def _output_rel(rel: Path) -> Path:
"""Strip processing suffixes from a relative path."""
name = rel.name
for suffix in (".append.template", ".template.append", ".template", ".append"):
if name.endswith(suffix):
name = name[: -len(suffix)]
break
return rel.with_name(name)


def render_template(content: str, context: dict[str, Any], *, path: str) -> str:
"""Render Jinja2 content with StrictUndefined."""
try:
return _JINJA.from_string(content).render(**context)
except TemplateError as exc:
raise ScaffoldAbortedError(
f"Template render failed for {path}: {exc}"
) from exc


def _write_bytes(target: Path, data: bytes, *, append: bool) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
mode = "ab" if append else "wb"
with target.open(mode) as fh:
fh.write(data)


def _write_text(target: Path, text: str, *, append: bool) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
mode = "a" if append else "w"
with target.open(mode, encoding="utf-8") as fh:
fh.write(text)


def process_file(
src: Path,
dest_root: Path,
rel: Path,
*,
context: dict[str, Any],
overwrite: bool = True,
) -> Path | None:
"""Copy / render / append one file into dest_root. Returns written path or None."""
mode = _mode_from_path(rel)
out_rel = _output_rel(rel)
target = dest_root / out_rel

def copy_tree(src: Path, dest: Path, *, overwrite: bool = True) -> list[Path]:
"""Copy files from src into dest. Returns list of written paths."""
if mode == "copy":
if target.exists() and not overwrite:
return None
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
return target

if mode == "append":
content = src.read_text(encoding="utf-8")
_write_text(target, content, append=True)
return target

# template modes
rendered = render_template(
src.read_text(encoding="utf-8"),
context,
path=str(rel),
)
append = mode == "appendTemplate"
if target.exists() and not overwrite and not append:
return None
_write_text(target, rendered, append=append)
return target


def copy_tree(
src: Path,
dest: Path,
*,
overwrite: bool = True,
context: dict[str, Any] | None = None,
) -> list[Path]:
"""Copy files from src into dest with .template / .append processing."""
written: list[Path] = []
if not src.is_dir():
raise ManifestLoadError(f"template directory not found: {src}")
dest.mkdir(parents=True, exist_ok=True)
for path in src.rglob("*"):
ctx = context or {}
for path in sorted(src.rglob("*")):
if path.is_dir():
continue
rel = path.relative_to(src)
target = dest / rel
if target.exists() and not overwrite:
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
written.append(target)
result = process_file(
path, dest, rel, context=ctx, overwrite=overwrite
)
if result is not None:
written.append(result)
return written


Expand All@@ -34,18 +132,25 @@ def load_layer(
dest: Path,
*,
overwrite: bool = True,
context: dict[str, Any] | None = None,
) -> list[Path]:
"""Load one template/extension layer into dest."""
template_root = get_template_dir_path(source, root)
return copy_tree(template_root, dest, overwrite=overwrite)
return copy_tree(
template_root, dest, overwrite=overwrite, context=context
)


def merge_layers(
layers: list[tuple[ResolvedSource, Path]],
dest: Path,
*,
context: dict[str, Any] | None = None,
) -> list[Path]:
"""Apply layers in order: template → addons → extend (later wins)."""
"""Apply layers in order: template → addons → extend (later wins for copies)."""
written: list[Path] = []
for source, root in layers:
written.extend(load_layer(source, root, dest, overwrite=True))
written.extend(
load_layer(source, root, dest, overwrite=True, context=context)
)
return written
51 changes: 50 additions & 1 deletion packages/create-python-app-core/tests/test_loaders.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
from pathlib import Path

from create_python_app_core.loaders import merge_layers
import pytest

from create_python_app_core.errors import ScaffoldAbortedError
from create_python_app_core.loaders import merge_layers, process_file, render_template
from create_python_app_core.paths import ResolvedSource


Expand All@@ -22,3 +25,49 @@ def test_merge_later_wins(tmp_path: Path) -> None:
merge_layers([a, b], dest)
assert (dest / "README.md").read_text() == "b"
assert (dest / "keep.txt").read_text() == "keep"


def test_template_renders_and_strips_suffix(tmp_path: Path) -> None:
layer = _layer(
tmp_path,
"t",
{"README.md.template": "Hello {{ projectName }}!\n"},
)
dest = tmp_path / "out"
merge_layers([layer], dest, context={"projectName": "demo"})
assert (dest / "README.md").read_text() == "Hello demo!\n"
assert not (dest / "README.md.template").exists()


def test_append_concatenates(tmp_path: Path) -> None:
a = _layer(tmp_path, "a", {"notes.txt": "one\n"})
b = _layer(tmp_path, "b", {"notes.txt.append": "two\n"})
dest = tmp_path / "out"
merge_layers([a, b], dest)
assert (dest / "notes.txt").read_text() == "one\ntwo\n"


def test_append_template(tmp_path: Path) -> None:
a = _layer(tmp_path, "a", {".env.example": "A=1\n"})
b = _layer(
tmp_path,
"b",
{".env.example.append.template": "NAME={{ projectName }}\n"},
)
dest = tmp_path / "out"
merge_layers([a, b], dest, context={"projectName": "api"})
assert (dest / ".env.example").read_text() == "A=1\nNAME=api\n"


def test_strict_undefined_raises() -> None:
with pytest.raises(ScaffoldAbortedError, match="Template render failed"):
render_template("{{ missing }}", {}, path="x.template")


def test_process_file_copy(tmp_path: Path) -> None:
src = tmp_path / "a.txt"
src.write_text("x")
dest = tmp_path / "out"
written = process_file(src, dest, Path("a.txt"), context={})
assert written == dest / "a.txt"
assert (dest / "a.txt").read_text() == "x"
45 changes: 45 additions & 0 deletions packages/create-python-app-core/tests/test_scaffold_context.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
from pathlib import Path

import pytest

from create_python_app_core.config import CpaConfig, CpaCustomOption
from create_python_app_core.installer import build_scaffold_context, scaffold_project


def test_build_scaffold_context_merges_defaults_and_set() -> None:
cfg = CpaConfig(
custom_options=[
CpaCustomOption(key="apiPrefix", default="/api/v1"),
CpaCustomOption(key="enableCors", default="true"),
]
)
ctx = build_scaffold_context(
"my-api",
[cfg],
{"set": {"enableCors": "false", "extra": "1"}},
)
assert ctx == {
"projectName": "my-api",
"apiPrefix": "/api/v1",
"enableCors": "false",
"extra": "1",
}


def test_scaffold_renders_template_from_set(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CPA_SKIP_GIT", "1")
root = tmp_path / "tpl"
(root / "template").mkdir(parents=True)
(root / "template" / "hello.txt.template").write_text(
"hi {{ projectName }} {{ tag }}\n"
)
dest = tmp_path / "app"
scaffold_project(
str(dest),
template=f"file://{root}",
install=False,
options={"set": {"tag": "x"}},
)
assert (dest / "hello.txt").read_text() == "hi app x\n"
Loading
Loading