exlab_wizard.template.authoring#

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:

Two invariants run through the whole module:

  • Every new or edited path is resolved through _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 exlab_wizard.paths.project_name_violations() so author-time filenames obey the same filesystem-safety contract as project names.

  • Every write goes through 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 write_manifest() / 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 StaleEditError rather than clobbering a concurrent edit (the optimistic-concurrency guard the GUI surfaces as “reload, your copy is stale”).

Functions

create_template_dir(base_dir, *, name, ...)

Scaffold a new minimal Copier template under base_dir.

delete_path(template_dir, rel)

Delete an in-template file or directory at rel.

is_editable(path)

Return True if path is text the GUI may edit inline.

list_files(template_dir)

Walk template_dir and return a sorted entry list for the GUI tree.

read_content(path)

Read an editable text file as UTF-8 and return its content + stat.

read_manifest(template_dir)

Read copier.yml and return the parsed manifest + its stat signature.

rename_path(template_dir, src_rel, dst_rel)

Move an in-template path from src_rel to dst_rel.

upload_file(template_dir, filename, data, *)

Write uploaded data into the template as filename.

write_content_file(template_dir, rel, text, *)

Write text to an in-template content file at rel.

write_manifest(template_dir, manifest, *[, ...])

Serialise manifest to copier.yml, gated on the lint rule set.

Exceptions

StaleEditError

Raised when an optimistic-concurrency write loses a stat race.

TemplateAuthoringError

Raised on an author-time template-edit failure.

UnsafePathError

Raised when a requested path is not a safe in-template location.

exception exlab_wizard.template.authoring.StaleEditError[source]#

Bases: 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.

exception exlab_wizard.template.authoring.TemplateAuthoringError[source]#

Bases: 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.

exception exlab_wizard.template.authoring.UnsafePathError[source]#

Bases: 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.

exlab_wizard.template.authoring.create_template_dir(base_dir, *, name, template_type, description='', run_scope=None)[source]#

Scaffold a new minimal Copier template under base_dir.

Writes <base_dir>/<name>/copier.yml (serialised from a TemplateManifest so the author-time and structured-edit paths emit byte-identical YAML) plus one notes.md.jinja content file. Both writes go through atomic_write_bytes(). The result is immediately loadable by TemplateEngine.

Parameters:
  • base_dir (Path) – The templates_dir the new template is created under.

  • name (str) – The template directory name. Stripped of surrounding whitespace, then validated as a single safe filesystem segment via project_name_violations().

  • template_type (str) – One of TemplateType values.

  • description (str) – Free-form _exlab_description text (stripped).

  • run_scope (str | None) – Required for run templates; one of RunScope values. Must be None / unused otherwise.

Return type:

Path

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.

  • UnsafePathErrorname is not a safe single filesystem segment.

exlab_wizard.template.authoring.delete_path(template_dir, rel)[source]#

Delete an in-template file or directory at rel.

Resolved through _safe_target(). A file is unlink-ed, a directory is removed recursively with shutil.rmtree() (only ever within the template root). copier.yml may not be deleted.

Parameters:
  • template_dir (Path) – The template root.

  • rel (str) – The in-template path to remove.

Raises:
Return type:

None

exlab_wizard.template.authoring.is_editable(path)[source]#

Return True if path is text the GUI may edit inline.

The decision is purely by suffix against 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.

Parameters:

path (Path)

Return type:

bool

exlab_wizard.template.authoring.list_files(template_dir)[source]#

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 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.

Parameters:

template_dir (Path) – The template root.

Return type:

list[dict]

Returns:

The sorted entry list (empty if template_dir is not a directory).

exlab_wizard.template.authoring.read_content(path)[source]#

Read an editable text file as UTF-8 and return its content + stat.

Parameters:

path (Path) – The file to read. Its suffix must be in EDITABLE_SUFFIXES.

Return type:

tuple[str, tuple[float, int]]

Returns:

(text, (st_mtime, st_size)) – the stat is the optimistic-concurrency signature for a later write_content_file().

Raises:

TemplateAuthoringError – The suffix is not editable, or the file is missing / unreadable / not valid UTF-8.

exlab_wizard.template.authoring.read_manifest(template_dir)[source]#

Read copier.yml and return the parsed manifest + its stat signature.

The returned (st_mtime, st_size) tuple is passed back to write_manifest() as expected_stat to detect a concurrent edit. The manifest is parsed via TemplateManifest.from_yaml(), which is tolerant of missing _exlab_* keys.

Parameters:

template_dir (Path) – The template root.

Return type:

tuple[TemplateManifest, tuple[float, int]]

Returns:

(manifest, (st_mtime, st_size)).

Raises:

TemplateAuthoringErrorcopier.yml is missing or unreadable.

exlab_wizard.template.authoring.rename_path(template_dir, src_rel, dst_rel)[source]#

Move an in-template path from src_rel to dst_rel.

Both ends are resolved through _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.

Parameters:
  • template_dir (Path) – The template root.

  • src_rel (str) – The existing in-template path.

  • dst_rel (str) – The new in-template path.

Return type:

Path

Returns:

The resolved absolute destination path.

Raises:
exlab_wizard.template.authoring.upload_file(template_dir, filename, data, *, render_as_template=False)[source]#

Write uploaded data into the template as filename.

The filename is resolved through _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 TEMPLATE_UPLOAD_MAX_BYTES;

  • the template may not already hold 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 atomic_write_bytes().

Parameters:
  • template_dir (Path) – The template root.

  • filename (str) – The upload’s in-template name (single path, may nest).

  • data (bytes) – The raw bytes to write.

  • render_as_template (bool) – Append .jinja so Copier renders the file.

Return type:

Path

Returns:

The resolved absolute path written.

Raises:
  • UnsafePathErrorfilename 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.

exlab_wizard.template.authoring.write_content_file(template_dir, rel, text, *, expected_stat=None)[source]#

Write text to an in-template content file at rel.

The target is resolved through _safe_target(). When rel ends in .jinja the text is parsed with Jinja2 first; a syntax error refuses the save with a TemplateAuthoringError (so a broken template never lands on disk). The write itself goes through atomic_write_bytes().

Parameters:
  • template_dir (Path) – The template root.

  • rel (str) – The in-template relative path to write.

  • text (str) – The UTF-8 content to write.

  • expected_stat (tuple[float, int] | None) – Optional (st_mtime, st_size) from read_content(); if given and the on-disk file differs, StaleEditError is raised before any write.

Return type:

Path

Returns:

The resolved absolute path written.

Raises:
exlab_wizard.template.authoring.write_manifest(template_dir, manifest, *, expected_stat=None)[source]#

Serialise manifest to copier.yml, gated on the lint rule set.

The manifest is rendered with TemplateManifest.to_yaml(), then re-parsed and run through exlab_wizard.template.lint.lint_manifest_dict(). If any finding is an ERROR the file is not written and a 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 atomic_write_bytes().

Parameters:
Raises:
Return type:

None