diff --git a/docs/template-variables.md b/docs/template-variables.md index 9121d9b..3ea9335 100644 --- a/docs/template-variables.md +++ b/docs/template-variables.md @@ -2,6 +2,16 @@ Template variables allow you to create dynamic content in your STRUCT configurations. This page covers all aspects of working with variables. +## Custom Delimiters (STRUCT) + +STRUCT uses custom Jinja2 delimiters to avoid conflicts with YAML and other content: + +- Variables: `{{@` and `@}}` +- Blocks: `{%@` and `@%}` +- Comments: `{#@` and `@#}` + +Examples are shown below and throughout this page. + ## Basic Syntax Use template variables by enclosing them in `{{@` and `@}}`: @@ -119,10 +129,86 @@ variables: env: MY_TOKEN ``` -## Custom Jinja2 Filters +## Custom Jinja2 Filters and Globals STRUCT includes custom filters for common tasks: +### `uuid()` (global) + +Generate a random UUID v4 string. + +```yaml +files: + - id.txt: + content: | + id: {{@ uuid() @}} +``` + +### `now()` (global) + +Return the current UTC time in ISO 8601 format. + +```yaml +files: + - stamp.txt: + content: | + generated_at: {{@ now() @}} +``` + +### `env(name, default="")` (global) + +Read an environment variable with an optional default. + +```yaml +files: + - .env.example: + content: | + TOKEN={{@ env("TOKEN", "changeme") @}} +``` + +### `read_file(path)` (global) + +Read the contents of a file on disk. Returns empty string on error. + +```yaml +files: + - README.md: + content: | + {{@ read_file("INTRO.md") @}} +``` + +### `to_yaml` / `from_yaml` (filters) + +Serialize and parse YAML. + +```yaml +files: + - data.yml: + content: | + {{@ some_dict | to_yaml @}} +``` + +```yaml +# Assume str_var holds YAML string +{%@ set obj = str_var | from_yaml @%} +``` + +### `to_json` / `from_json` (filters) + +Serialize and parse JSON. + +```yaml +files: + - data.json: + content: | + {{@ some_dict | to_json @}} +``` + +```yaml +# Assume str_var holds JSON string +{%@ set obj = str_var | from_json @%} +``` + ### `latest_release` Fetch the latest release version from GitHub: diff --git a/struct_module/filters.py b/struct_module/filters.py index fb70b56..35ad822 100644 --- a/struct_module/filters.py +++ b/struct_module/filters.py @@ -1,5 +1,11 @@ import os import re +import json +from uuid import uuid4 +from datetime import datetime, timezone +from typing import Any + +import yaml from github import Github from cachetools import TTLCache, cached @@ -52,3 +58,55 @@ def slugify(value): # Remove any non-alphanumeric characters (except hyphens) value = re.sub(r'[^a-z0-9-]', '', value) return value + +# ----------------------------- +# Additional helpers/filters +# ----------------------------- + +def gen_uuid() -> str: + return str(uuid4()) + + +def now_iso() -> str: + # UTC ISO8601 string + return datetime.now(timezone.utc).isoformat() + + +def env(name: str, default: str = "") -> str: + return os.getenv(name, default) + + +def read_file(path: str, encoding: str = "utf-8") -> str: + try: + with open(path, "r", encoding=encoding) as f: + return f.read() + except Exception: + return "" + + +def to_yaml(obj: Any) -> str: + try: + return yaml.safe_dump(obj, sort_keys=False) + except Exception: + return "" + + +def from_yaml(s: str) -> Any: + try: + return yaml.safe_load(s) + except Exception: + return None + + +def to_json(obj: Any, indent: int | None = None) -> str: + try: + return json.dumps(obj, indent=indent) + except Exception: + return "" + + +def from_json(s: str) -> Any: + try: + return json.loads(s) + except Exception: + return None diff --git a/struct_module/template_renderer.py b/struct_module/template_renderer.py index 63a4c3f..30a3c97 100644 --- a/struct_module/template_renderer.py +++ b/struct_module/template_renderer.py @@ -3,7 +3,19 @@ import os import sys from jinja2 import Environment, meta -from struct_module.filters import get_latest_release, slugify, get_default_branch +from struct_module.filters import ( + get_latest_release, + slugify, + get_default_branch, + gen_uuid, + now_iso, + env as env_get, + read_file, + to_yaml, + from_yaml, + to_json, + from_json, +) from struct_module.input_store import InputStore from struct_module.utils import get_current_repo @@ -28,11 +40,19 @@ def __init__(self, config_variables, input_store, non_interactive, mappings=None custom_filters = { 'latest_release': get_latest_release, 'slugify': slugify, - 'default_branch': get_default_branch + 'default_branch': get_default_branch, + 'to_yaml': to_yaml, + 'from_yaml': from_yaml, + 'to_json': to_json, + 'from_json': from_json, } globals = { - 'current_repo': get_current_repo + 'current_repo': get_current_repo, + 'uuid': gen_uuid, + 'now': now_iso, + 'env': env_get, + 'read_file': read_file, } self.env.globals.update(globals) @@ -110,21 +130,17 @@ def prompt_for_missing_vars(self, content, vars): if enum: # Build options list string like "(1) dev, (2) prod)" options = ", ".join([f"({i+1}) {val}" for i, val in enumerate(enum)]) - while True: - raw = input(f"❓ Enter value for {var} [{default}] {options}: ") - raw = raw.strip() - if raw == "": - user_input = default - elif raw.isdigit() and 1 <= int(raw) <= len(enum): - user_input = enum[int(raw) - 1] - else: - # accept exact match from enum - if raw in enum: - user_input = raw - else: - print(f"Invalid choice. Please enter one of: {options} or a valid value.") - continue - break + raw = input(f"❓ Enter value for {var} [{default}] {options}: ") + raw = raw.strip() + if raw == "": + user_input = default + elif raw.isdigit() and 1 <= int(raw) <= len(enum): + user_input = enum[int(raw) - 1] + elif raw in enum: + user_input = raw + else: + # For invalid enum input, raise immediately instead of re-prompting + raise ValueError(f"Variable '{var}' must be one of {enum}, got: {raw}") else: user_input = input(f"❓ Enter value for {var} [{default}]: ") or default # Coerce and validate according to schema diff --git a/tests/test_template_helpers.py b/tests/test_template_helpers.py new file mode 100644 index 0000000..b94a3a1 --- /dev/null +++ b/tests/test_template_helpers.py @@ -0,0 +1,65 @@ +import json +import os +import re +from uuid import UUID +from datetime import datetime + +import pytest + +from struct_module.template_renderer import TemplateRenderer + + +@pytest.fixture +def renderer(tmp_path): + # minimal renderer with non_interactive to avoid prompts + return TemplateRenderer(config_variables=[], input_store=str(tmp_path / "inputs.json"), non_interactive=True) + + +def test_uuid_global(renderer): + tmpl = "ID: {{@ uuid() @}}" + out = renderer.render_template(tmpl, {}) + # Extract UUID part and validate format + uid = out.split("ID: ")[-1].strip() + # Will raise if invalid + UUID(uid) + + +def test_now_global(renderer): + tmpl = "TS: {{@ now() @}}" + out = renderer.render_template(tmpl, {}) + ts = out.split("TS: ")[-1].strip() + # Should be ISO 8601 parseable + parsed = datetime.fromisoformat(ts.replace("Z", "+00:00")) + assert isinstance(parsed, datetime) + + +def test_env_global(monkeypatch, renderer): + monkeypatch.setenv("FOO_BAR", "baz") + tmpl = "{{@ env('FOO_BAR', 'default') @}}|{{@ env('MISSING_VAR', 'fallback') @}}" + out = renderer.render_template(tmpl, {}) + left, right = out.split("|") + assert left == "baz" + assert right == "fallback" + + +def test_read_file_global(tmp_path, renderer): + p = tmp_path / "hello.txt" + p.write_text("hello world", encoding="utf-8") + tmpl = "{{@ read_file('" + str(p) + "') @}}|{{@ read_file('nonexistent') @}}" + out = renderer.render_template(tmpl, {}) + left, right = out.split("|") + assert left == "hello world" + assert right == "" + + +def test_yaml_filters(renderer): + # Render a dict into YAML and parse back + tmpl = "{%@ set y = data | to_yaml @%}{%@ set back = y | from_yaml @%}{{@ back.name @}}:{{@ back.value @}}" + out = renderer.render_template(tmpl, {"data": {"name": "item", "value": 42}}) + assert out == "item:42" + + +def test_json_filters(renderer): + tmpl = "{%@ set j = data | to_json @%}{%@ set back = j | from_json @%}{{@ back.kind @}}:{{@ back.count @}}" + out = renderer.render_template(tmpl, {"data": {"kind": "k", "count": 7}}) + assert out == "k:7"