"""Author-time template service: scaffold, edit, and validate templates.
This is the non-UI backend the GUI template authoring form (Frontend Spec
§5) drives. It owns every mutation of a template directory under
``config.paths.templates_dir`` so the NiceGUI page stays a thin view:
* :func:`create_template_dir` -- scaffold a new minimal Copier template
(the same shape :func:`exlab_wizard.ui.pages.templates.create_template`
historically produced, now routed through here so the page can delegate).
* :func:`read_manifest` / :func:`write_manifest` -- round-trip the
structured :class:`~exlab_wizard.template.manifest.TemplateManifest`
through ``copier.yml``, gating every write on the shared
:mod:`exlab_wizard.template.lint` rule set.
* :func:`read_content` / :func:`write_content_file` / :func:`upload_file`
-- read and write the template's content files (``*.jinja`` and the
editable text allowlist), with a Jinja2 parse gate on ``*.jinja`` saves
and size / file-count caps on uploads.
* :func:`rename_path` / :func:`delete_path` -- move and remove files
within the template, never touching ``copier.yml`` on delete.
* :func:`list_files` -- a pure directory walk the GUI tree consumes.
Two invariants run through the whole module:
* **Every** new or edited path is resolved through :func:`_safe_target`,
the single chokepoint that rejects traversal (``..``), absolute paths,
path separators inside a segment, Windows-reserved / control / non-ASCII
segment names, and any resolved path that escapes the template root.
The per-segment rule reuses
:func:`exlab_wizard.paths.project_name_violations` so author-time
filenames obey the same filesystem-safety contract as project names.
* **Every** write goes through :func:`exlab_wizard.io.atomic_write_bytes`
-- the temp-file + ``fsync`` + ``os.replace`` recipe -- so a crash
mid-write never leaves a half-written ``copier.yml`` or content file.
The :func:`write_manifest` / :func:`write_content_file` family also take an
optional ``expected_stat`` tuple ``(st_mtime, st_size)`` captured at read
time; if the on-disk file changed since, the write raises
:class:`StaleEditError` rather than clobbering a concurrent edit (the
optimistic-concurrency guard the GUI surfaces as "reload, your copy is
stale").
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
import yaml
from jinja2 import Environment, TemplateSyntaxError
from exlab_wizard.constants import (
COPIER_MANIFEST_NAME,
TEMPLATE_MAX_FILES,
TEMPLATE_UPLOAD_MAX_BYTES,
RunScope,
TemplateType,
)
from exlab_wizard.errors import ExLabError
from exlab_wizard.io.atomic_write import atomic_write_bytes
from exlab_wizard.logging import get_logger
from exlab_wizard.paths import project_name_violations
from exlab_wizard.template import lint
from exlab_wizard.template.manifest import TemplateManifest
if TYPE_CHECKING:
from collections.abc import Iterable
__all__ = [
"EDITABLE_SUFFIXES",
"StaleEditError",
"TemplateAuthoringError",
"UnsafePathError",
"create_template_dir",
"delete_path",
"is_editable",
"list_files",
"read_content",
"read_manifest",
"rename_path",
"upload_file",
"write_content_file",
"write_manifest",
]
_log = get_logger(__name__)
# ---------------------------------------------------------------------------
# Exceptions (subclass the repo base so callers can catch ExLabError)
# ---------------------------------------------------------------------------
[docs]
class TemplateAuthoringError(ExLabError):
"""Raised on an author-time template-edit failure.
Covers lint-rejected manifest saves, Jinja-syntax-rejected content
saves, over-cap uploads, and edits to non-editable / disallowed
paths. The two narrower failures below subclass this so callers can
catch the specific case or the whole family.
"""
[docs]
class StaleEditError(TemplateAuthoringError):
"""Raised when an optimistic-concurrency write loses a stat race.
The file changed on disk between the caller's read (which captured
``expected_stat``) and the write, so applying the edit would clobber
a concurrent change. The GUI surfaces this as "reload -- your copy is
stale" rather than silently overwriting.
"""
[docs]
class UnsafePathError(TemplateAuthoringError):
"""Raised when a requested path is not a safe in-template location.
Covers traversal (``..``), absolute paths, path separators or
Windows-reserved / control / non-ASCII characters inside a segment,
and any resolved target that escapes the template root.
"""
# ---------------------------------------------------------------------------
# Editable-suffix classification
# ---------------------------------------------------------------------------
# Suffixes the GUI may open in its text editor. Anything else (``.xlsx``,
# ``.png``, ...) is treated as opaque binary the operator can upload /
# rename / delete but not edit inline.
EDITABLE_SUFFIXES: frozenset[str] = frozenset(
{".md", ".txt", ".csv", ".yml", ".yaml", ".jinja", ".json"}
)
# Content file every scaffolded template carries. ``.jinja`` so Copier
# renders it; the body has no variables so it renders verbatim.
_SCAFFOLD_CONTENT_NAME = "notes.md.jinja"
_SCAFFOLD_CONTENT_BODY = "# Notes\n\nScaffolded by ExLab-Wizard.\n"
_JINJA_SUFFIX = ".jinja"
[docs]
def is_editable(path: Path) -> bool:
"""Return ``True`` if ``path`` is text the GUI may edit inline.
The decision is purely by suffix against :data:`EDITABLE_SUFFIXES`
(case-insensitive); the file need not exist. A ``foo.md.jinja`` is
editable (its final suffix ``.jinja`` is in the set), as is a bare
``.md`` / ``.csv`` / ``.json``; a ``.xlsx`` / ``.png`` is not.
"""
return path.suffix.lower() in EDITABLE_SUFFIXES
# ---------------------------------------------------------------------------
# The single path chokepoint
# ---------------------------------------------------------------------------
def _safe_target(template_dir: Path, rel: str) -> Path:
"""Resolve ``rel`` to an absolute path proven to live inside ``template_dir``.
Every new or edited path in this module flows through here. The
relative path is rejected outright if empty; otherwise it is split on
both ``/`` and ``\\`` and each non-empty segment is validated with
:func:`exlab_wizard.paths.project_name_violations` -- the same
filesystem-safety rule project names obey (no separators, no ``..`` /
trailing dot, no Windows-reserved name, no control / non-ASCII, not
over-length). The composed target is then ``resolve()``-d and required
to equal the resolved template root or sit beneath it, so a path that
escapes via symlink or a residual ``..`` is caught even though the
per-segment check already rejects literal ``..``.
Args:
template_dir: The template root the path must stay within.
rel: A relative, in-template path (POSIX or Windows separators).
Returns:
The resolved absolute path inside ``template_dir``.
Raises:
UnsafePathError: ``rel`` is empty, contains an empty / unsafe
segment, or resolves outside ``template_dir``.
"""
if not rel:
msg = "empty path is not a valid template target"
raise UnsafePathError(msg)
segments = rel.replace("\\", "/").split("/")
if any(seg == "" for seg in segments):
msg = f"path {rel!r} contains an empty segment"
raise UnsafePathError(msg)
for seg in segments:
violations = project_name_violations(seg)
if violations:
_token, detail = violations[0]
msg = f"unsafe path segment {seg!r} in {rel!r}: {detail}"
raise UnsafePathError(msg)
target = template_dir / rel
resolved = target.resolve()
base = template_dir.resolve()
if resolved != base and base not in resolved.parents:
msg = f"path {rel!r} escapes the template directory"
raise UnsafePathError(msg)
return resolved
# ---------------------------------------------------------------------------
# Scaffold
# ---------------------------------------------------------------------------
[docs]
def create_template_dir(
base_dir: Path,
*,
name: str,
template_type: str,
description: str = "",
run_scope: str | None = None,
) -> Path:
"""Scaffold a new minimal Copier template under ``base_dir``.
Writes ``<base_dir>/<name>/copier.yml`` (serialised from a
:class:`~exlab_wizard.template.manifest.TemplateManifest` so the
author-time and structured-edit paths emit byte-identical YAML) plus
one ``notes.md.jinja`` content file. Both writes go through
:func:`atomic_write_bytes`. The result is immediately loadable by
:class:`~exlab_wizard.template.copier_driver.TemplateEngine`.
Args:
base_dir: The ``templates_dir`` the new template is created under.
name: The template directory name. Stripped of surrounding
whitespace, then validated as a single safe filesystem
segment via :func:`project_name_violations`.
template_type: One of :class:`TemplateType` values.
description: Free-form ``_exlab_description`` text (stripped).
run_scope: Required for ``run`` templates; one of
:class:`RunScope` values. Must be ``None`` / unused otherwise.
Returns:
The new template's root directory.
Raises:
ValueError: Empty / duplicate ``name``, unknown ``template_type``,
or a run template missing / with an invalid ``run_scope``.
UnsafePathError: ``name`` is not a safe single filesystem segment.
"""
clean_name = name.strip()
if not clean_name:
msg = "template name must not be empty"
raise ValueError(msg)
if template_type not in {t.value for t in TemplateType}:
msg = f"unknown template type {template_type!r}"
raise ValueError(msg)
if template_type == TemplateType.RUN.value:
if run_scope is None:
msg = "run templates require a run_scope"
raise ValueError(msg)
if run_scope not in {s.value for s in RunScope}:
msg = f"unknown run_scope {run_scope!r}"
raise ValueError(msg)
violations = project_name_violations(clean_name)
if violations:
_token, detail = violations[0]
msg = f"unsafe template name {clean_name!r}: {detail}"
raise UnsafePathError(msg)
root = Path(base_dir) / clean_name
if root.exists():
msg = f"a template named {clean_name!r} already exists"
raise ValueError(msg)
root.mkdir(parents=True)
manifest = TemplateManifest(
exlab_type=template_type,
exlab_version="1.0",
exlab_run_scope=run_scope if template_type == TemplateType.RUN.value else None,
description=description.strip(),
)
atomic_write_bytes(root / COPIER_MANIFEST_NAME, manifest.to_yaml().encode("utf-8"))
atomic_write_bytes(root / _SCAFFOLD_CONTENT_NAME, _SCAFFOLD_CONTENT_BODY.encode("utf-8"))
_log.info("scaffolded %s template %r at %s", template_type, clean_name, root)
return root
# ---------------------------------------------------------------------------
# Manifest round-trip
# ---------------------------------------------------------------------------
def _stat_tuple(path: Path) -> tuple[float, int]:
"""Return the ``(st_mtime, st_size)`` stale-edit signature of ``path``."""
st = path.stat()
return (st.st_mtime, st.st_size)
def _check_stale(path: Path, expected_stat: tuple[float, int] | None) -> None:
"""Raise :class:`StaleEditError` if ``path``'s stat differs from expected.
A ``None`` ``expected_stat`` skips the guard (the caller opted out of
optimistic concurrency). A missing file with a non-``None`` expectation
is itself a stale condition (the file the caller read is gone).
"""
if expected_stat is None:
return
try:
current = _stat_tuple(path)
except OSError as exc:
msg = f"{path} changed on disk since it was read (now missing): {exc}"
raise StaleEditError(msg) from exc
if current != expected_stat:
msg = (
f"{path} changed on disk since it was read "
f"(expected stat {expected_stat}, found {current})"
)
raise StaleEditError(msg)
[docs]
def read_manifest(template_dir: Path) -> tuple[TemplateManifest, tuple[float, int]]:
"""Read ``copier.yml`` and return the parsed manifest + its stat signature.
The returned ``(st_mtime, st_size)`` tuple is passed back to
:func:`write_manifest` as ``expected_stat`` to detect a concurrent
edit. The manifest is parsed via
:meth:`TemplateManifest.from_yaml`, which is tolerant of missing
``_exlab_*`` keys.
Args:
template_dir: The template root.
Returns:
``(manifest, (st_mtime, st_size))``.
Raises:
TemplateAuthoringError: ``copier.yml`` is missing or unreadable.
"""
copier_path = Path(template_dir) / COPIER_MANIFEST_NAME
try:
text = copier_path.read_text(encoding="utf-8")
stat = _stat_tuple(copier_path)
except OSError as exc:
msg = f"failed to read {copier_path}: {exc}"
raise TemplateAuthoringError(msg) from exc
return TemplateManifest.from_yaml(text), stat
[docs]
def write_manifest(
template_dir: Path,
manifest: TemplateManifest,
*,
expected_stat: tuple[float, int] | None = None,
) -> None:
"""Serialise ``manifest`` to ``copier.yml``, gated on the lint rule set.
The manifest is rendered with :meth:`TemplateManifest.to_yaml`, then
re-parsed and run through :func:`exlab_wizard.template.lint.lint_manifest_dict`.
If any finding is an ERROR the file is **not** written and a
:class:`TemplateAuthoringError` carrying the joined error messages is
raised, so a manifest that ``TemplateEngine.resolve`` would reject can
never be saved. WARN findings do not block the save. On success the
bytes are written through :func:`atomic_write_bytes`.
Args:
template_dir: The template root.
manifest: The manifest to serialise.
expected_stat: Optional ``(st_mtime, st_size)`` from
:func:`read_manifest`; if given and the on-disk file differs,
:class:`StaleEditError` is raised before any write.
Raises:
StaleEditError: ``expected_stat`` given and the file changed.
TemplateAuthoringError: The serialised manifest has lint ERRORs.
"""
copier_path = Path(template_dir) / COPIER_MANIFEST_NAME
_check_stale(copier_path, expected_stat)
text = manifest.to_yaml()
parsed = yaml.safe_load(text)
manifest_dict = parsed if isinstance(parsed, dict) else {}
findings = lint.lint_manifest_dict(manifest_dict, copier_path)
if lint.has_errors(findings):
errors = "; ".join(f.message for f in findings if f.severity == "error")
msg = f"manifest has lint errors, not written: {errors}"
raise TemplateAuthoringError(msg)
atomic_write_bytes(copier_path, text.encode("utf-8"))
# ---------------------------------------------------------------------------
# Content read / write
# ---------------------------------------------------------------------------
[docs]
def read_content(path: Path) -> tuple[str, tuple[float, int]]:
"""Read an editable text file as UTF-8 and return its content + stat.
Args:
path: The file to read. Its suffix must be in
:data:`EDITABLE_SUFFIXES`.
Returns:
``(text, (st_mtime, st_size))`` -- the stat is the
optimistic-concurrency signature for a later
:func:`write_content_file`.
Raises:
TemplateAuthoringError: The suffix is not editable, or the file
is missing / unreadable / not valid UTF-8.
"""
p = Path(path)
if not is_editable(p):
msg = f"{p} is not an editable text file (suffix {p.suffix!r})"
raise TemplateAuthoringError(msg)
try:
text = p.read_text(encoding="utf-8")
stat = _stat_tuple(p)
except (OSError, UnicodeDecodeError) as exc:
msg = f"failed to read {p}: {exc}"
raise TemplateAuthoringError(msg) from exc
return text, stat
[docs]
def write_content_file(
template_dir: Path,
rel: str,
text: str,
*,
expected_stat: tuple[float, int] | None = None,
) -> Path:
"""Write ``text`` to an in-template content file at ``rel``.
The target is resolved through :func:`_safe_target`. When ``rel`` ends
in ``.jinja`` the text is parsed with Jinja2 first; a syntax error
refuses the save with a :class:`TemplateAuthoringError` (so a broken
template never lands on disk). The write itself goes through
:func:`atomic_write_bytes`.
Args:
template_dir: The template root.
rel: The in-template relative path to write.
text: The UTF-8 content to write.
expected_stat: Optional ``(st_mtime, st_size)`` from
:func:`read_content`; if given and the on-disk file differs,
:class:`StaleEditError` is raised before any write.
Returns:
The resolved absolute path written.
Raises:
UnsafePathError: ``rel`` is not a safe in-template path.
StaleEditError: ``expected_stat`` given and the file changed.
TemplateAuthoringError: A ``.jinja`` target with a syntax error.
"""
target = _safe_target(Path(template_dir), rel)
_check_stale(target, expected_stat)
if target.suffix.lower() == _JINJA_SUFFIX:
_validate_jinja(text, rel)
target.parent.mkdir(parents=True, exist_ok=True)
atomic_write_bytes(target, text.encode("utf-8"))
return target
def _validate_jinja(text: str, rel: str) -> None:
"""Parse ``text`` as Jinja2; raise :class:`TemplateAuthoringError` on error.
Parse-only (never renders), mirroring
:func:`exlab_wizard.template.lint._lint_jinja_files`.
"""
try:
Environment().parse(text) # parse-only; never renders untrusted input
except TemplateSyntaxError as exc:
msg = f"{rel}: Jinja syntax error on line {exc.lineno}: {exc.message}"
raise TemplateAuthoringError(msg) from exc
# ---------------------------------------------------------------------------
# Upload
# ---------------------------------------------------------------------------
[docs]
def upload_file(
template_dir: Path,
filename: str,
data: bytes,
*,
render_as_template: bool = False,
) -> Path:
"""Write uploaded ``data`` into the template as ``filename``.
The filename is resolved through :func:`_safe_target` (so a traversal
or absolute path is rejected). When ``render_as_template`` is set and
the name is not already ``*.jinja``, a ``.jinja`` suffix is appended so
Copier renders the file. Two caps gate the write:
* the upload may not exceed :data:`TEMPLATE_UPLOAD_MAX_BYTES`;
* the template may not already hold :data:`TEMPLATE_MAX_FILES` files.
A ``.jinja`` upload that decodes as UTF-8 text is Jinja-parse-checked
(a binary ``.jinja`` -- unusual but possible -- skips the parse). The
write goes through :func:`atomic_write_bytes`.
Args:
template_dir: The template root.
filename: The upload's in-template name (single path, may nest).
data: The raw bytes to write.
render_as_template: Append ``.jinja`` so Copier renders the file.
Returns:
The resolved absolute path written.
Raises:
UnsafePathError: ``filename`` is not a safe in-template path.
TemplateAuthoringError: The upload exceeds the size cap, the
template is at the file-count cap, or a UTF-8 ``.jinja`` upload
has a Jinja syntax error.
"""
if len(data) > TEMPLATE_UPLOAD_MAX_BYTES:
msg = (
f"upload {filename!r} is {len(data)} bytes, exceeds the "
f"{TEMPLATE_UPLOAD_MAX_BYTES}-byte cap"
)
raise TemplateAuthoringError(msg)
root = Path(template_dir)
existing = _count_files(root)
if existing >= TEMPLATE_MAX_FILES:
msg = (
f"template already holds {existing} files, at the "
f"{TEMPLATE_MAX_FILES}-file cap; cannot upload {filename!r}"
)
raise TemplateAuthoringError(msg)
name = filename
if render_as_template and not name.lower().endswith(_JINJA_SUFFIX):
name = f"{name}{_JINJA_SUFFIX}"
target = _safe_target(root, name)
if target.suffix.lower() == _JINJA_SUFFIX:
try:
decoded = data.decode("utf-8")
except UnicodeDecodeError:
decoded = None # Binary .jinja: skip the parse, write verbatim.
if decoded is not None:
_validate_jinja(decoded, name)
target.parent.mkdir(parents=True, exist_ok=True)
atomic_write_bytes(target, data)
return target
def _count_files(template_dir: Path) -> int:
"""Count regular files (not directories) under ``template_dir``."""
root = Path(template_dir)
if not root.is_dir():
return 0
return sum(1 for p in root.rglob("*") if p.is_file())
# ---------------------------------------------------------------------------
# Rename / delete
# ---------------------------------------------------------------------------
[docs]
def rename_path(template_dir: Path, src_rel: str, dst_rel: str) -> Path:
"""Move an in-template path from ``src_rel`` to ``dst_rel``.
Both ends are resolved through :func:`_safe_target`, so neither may
escape the template root. The move is ``os.replace`` (atomic on the
same filesystem); ``copier.yml`` may not be renamed away.
Args:
template_dir: The template root.
src_rel: The existing in-template path.
dst_rel: The new in-template path.
Returns:
The resolved absolute destination path.
Raises:
UnsafePathError: Either end is not a safe in-template path.
TemplateAuthoringError: ``src_rel`` is ``copier.yml`` or does not
exist.
"""
root = Path(template_dir)
src = _safe_target(root, src_rel)
dst = _safe_target(root, dst_rel)
if src == (root / COPIER_MANIFEST_NAME).resolve():
msg = "copier.yml cannot be renamed"
raise TemplateAuthoringError(msg)
if not src.exists():
msg = f"cannot rename {src_rel!r}: it does not exist"
raise TemplateAuthoringError(msg)
dst.parent.mkdir(parents=True, exist_ok=True)
os.replace(src, dst)
return dst
[docs]
def delete_path(template_dir: Path, rel: str) -> None:
"""Delete an in-template file or directory at ``rel``.
Resolved through :func:`_safe_target`. A file is ``unlink``-ed, a
directory is removed recursively with :func:`shutil.rmtree` (only ever
within the template root). ``copier.yml`` may not be deleted.
Args:
template_dir: The template root.
rel: The in-template path to remove.
Raises:
UnsafePathError: ``rel`` is not a safe in-template path.
TemplateAuthoringError: ``rel`` is ``copier.yml`` or does not
exist.
"""
root = Path(template_dir)
target = _safe_target(root, rel)
if target == (root / COPIER_MANIFEST_NAME).resolve():
msg = "copier.yml cannot be deleted"
raise TemplateAuthoringError(msg)
if not target.exists():
msg = f"cannot delete {rel!r}: it does not exist"
raise TemplateAuthoringError(msg)
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
# ---------------------------------------------------------------------------
# Directory listing (pure, GUI tree)
# ---------------------------------------------------------------------------
[docs]
def list_files(template_dir: Path) -> list[dict]:
"""Walk ``template_dir`` and return a sorted entry list for the GUI tree.
Each entry is ``{"rel": str, "is_dir": bool, "editable": bool,
"size": int}`` -- ``rel`` is the POSIX-style path relative to the
template root, ``editable`` is :func:`is_editable` (always ``False``
for directories), and ``size`` is the file size in bytes (``0`` for
directories). Entries are sorted by ``rel`` for a stable tree.
Args:
template_dir: The template root.
Returns:
The sorted entry list (empty if ``template_dir`` is not a
directory).
"""
root = Path(template_dir)
if not root.is_dir():
return []
entries: list[dict] = []
for p in _iter_paths(root):
rel = p.relative_to(root).as_posix()
is_dir = p.is_dir()
entries.append(
{
"rel": rel,
"is_dir": is_dir,
"editable": (not is_dir) and is_editable(p),
"size": 0 if is_dir else p.stat().st_size,
}
)
entries.sort(key=lambda e: e["rel"])
return entries
def _iter_paths(root: Path) -> Iterable[Path]:
"""Yield every path under ``root`` (files and directories)."""
yield from root.rglob("*")