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
88 changes: 87 additions & 1 deletion docs/template-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `@}}`:
Expand Down Expand Up @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions struct_module/filters.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
52 changes: 34 additions & 18 deletions struct_module/template_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions tests/test_template_helpers.py
Original file line number Diff line number Diff line change
@@ -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"
Loading