Pure Python, lightweight, extensively tested, full-featured text template library. Supports conditional logic, lists, expressions, macros, and more. Well suited for dynamic prompts for AI agents, IoT web pages, and general template use, where templates may need to be dynamically composed, include conditional sections, loops, macros, lists and more.
- Tight single-file core with a single dependency (parsek, a tiny, single-file pure-Python parser).
- Simple API: for most use cases, a single
evaluate()function call is enough. - Low impact template syntax: only directives
{% ... %}and comments{# ... #} - Expressions and Python statements in templates
- Control flow:
if/elif/else,foreach - Lists: inline, named, nested; built-in
ol(...)/ul(...) - Macros with arguments
- Imports from files, packages/resources, or substitutions
- Helpful error messages with source/position and import-trace context
pip install flextexfrom flextex import evaluate
print(evaluate("Hello, {% name %}!", {"name": "World"}))
# -> Hello, World!Or from command line:
python -m flextex -e "Hello, {% name %}\!" <<< '{"name": "World"}'- Quick Start
- Core Concepts
- Directives
- Built-in Functions
- Python API Reference
- Error Reporting
- Command Line Interface
- Performance & Thread Safety
- Security Notes
- Development & License
from flextex import evaluate
PROMPT_TEMPLATE = """\
# IDENTITY
You are a Friendly Assistant. Your job is to engage in friendly and
helpful conversations with the USER.
# CONTEXT
User name: {% user_name %}
User's age: {% user_age %}
User's hobbies: {% user_hobbies %}
{% if chat_history %}
Chat history:
{% chat_history %}
{% endif %}
...
"""
chat_history = [
"[ASSISTANT] Hello! How can I help you today?"
]
prompt = evaluate(PROMPT_TEMPLATE, {
"user_name": "Bob",
"user_age": 107,
"user_hobbies": ["spear fishing", "skydiving", "beach"],
"chat_history": chat_history
})The rendered prompt will look like this:
# IDENTITY
You are a Friendly Assistant. Your job is to engage in friendly and
helpful conversations with the USER.
# CONTEXT
User name: Bob
User's age: 107
User's hobbies: spear fishing, skydiving, and beach.
Chat history:
[ASSISTANT] Hello! How can I help you today?
❇️ You can precompile once and evaluate many times:
from flextex import precompile, evaluate prompt_ast = precompile(PROMPT_TEMPLATE) # precompile once while chatting: ... prompt = evaluate(prompt_ast, prompt_substitutions) # fast subsequent evaluations ...
FlexTex uses only two syntax elements: the {% ... %} directive and the {# ... #} comment (comments are removed from the final output). This minimal syntax ensures that your source text can be in any format — Markdown, HTML, plain text, or anything else — without interference.
- Directives (
{% ... %}) and comments ({# ... #}) can appear anywhere. - A directive may span multiple lines.
- Multiple directives can be on a single line.
➰ If you need to escape an opening
{, use{{. This is rarely necessary, since only the full{%or{#sequences are treated as special.
➰}}is accepted for symmetry yielding}. A single}is literal unless forming%}or#}.
Careful consideration was given to how FlexTex handles indentation - treating it as intent. Structural indentation is removed from output, but deliberate indentation is preserved. This allows you to clearly see the conditional flow in your template source while keeping the intended output layout.
- Indentation and new lines have no semantic meaning (unlike Python); they are only for readability or output formatting.
- The default structural indentation (e.g., inside
iforforeachblocks) is 4 spaces (configurable). It's optional but helps readability, and is removed from final output. - Lines within a directive block are dedented to the outer block level + any additional structural indentation. Any surplus indentation is kept.
- To preserve indentation in output (using default 4 here as an example):
- indent less than 4 spaces at the block level (keeps those spaces), or
- indent more than 4 spaces to keep the surplus beyond the structural 4.
- Trailing whitespace on lines is removed.
- Tabs are ignored for structural indentation (use spaces).
- Only
\nline endings are interpreted for structural indentation.
In short: FlexTex keeps templates readable while preserving your intended layout in the output. Structural indentation is removed - intentional indentation stays.
Examples:
| Input Template | Output | Notes |
|---|---|---|
Hello
{% if show %}
Inner
Still inner
{% endif %}
World |
| when `show = True` |
My To-Do List:
{% foreach items %}
- {% item %}
{% /foreach %}
No indent:
{% foreach items %}
- {% item %}
{% /foreach %}
2-space indent:
{% foreach items %}
- {% item %}
{% /foreach %}
4-space indent:
{% foreach items %}
- {% item %}
{% /foreach %} |
|
bullet indent preserved (2-space indent) since item is at less than structural indent (4 spaces) bullet indent removed completely since item is at exactly structural indent (4 spaces) bullet indent is at 2 spaces since item is 2 spaces more than structural indent (4 spaces) the whole block is indented 2 spaces. and items are at 2-space indent |
Quick reference of all available directives {% ... %} in FlexTex templates:
| Directive | Purpose | Notes |
|---|---|---|
{% expr %} |
Substitution | Identifier, expression, or statement(s) (; separated) |
{% var name %}{% var name = initializer %} |
Template variables | Only vars created via var are mutable |
{% def macro(args) %}. . . {% /def %} |
Define macro | Call via {% macro(arg1, x=2) %} |
{% <source> %} |
Import / include | source can be path, packageresource, or a substitution name |
{% if cond %}{% elif cond %}{% else %}{% endif %} or {% /if %} |
Conditional branching | cond can be identifier or expression |
{% foreach items %}. . . {% /foreach %} |
Loop over iterable | Alias: forLoop vars: item, i (0‑based), I (1‑based),count, is_last |
{% list name %}. . . {% /list %} |
Define a reusable list | Use later anywhere a list is expected |
{% list %}. . . {% /list %} |
Inline list render | Renders immediately (like <ul>/<ol>) |
{% li %} ... {% /li %} |
List item | Closing tag {% /li %} is optional |
{% meta key=value %} |
Template metadata | Set template-level options, e.g. indent=2 |
In addition to the above directives, the following utility functions are available in all expressions by default:
alpha, ordinal, roman, cardinal, ol, ul, en_and, en_comma_and, en_or, en_comma_or, is_list, is_nested_list, outer.
Substitution is any directive {% ... %} that is not a control or data structure. The result of the substitution is inserted into the output text.
{% user_name %} {# simple substitution #}
{% user_age + 1 %} {# expression evaluation #}
{% user_hobbies | join(", ") %} {# list to string conversion using Python features #}
{% f"i:02" %} {# Python f-string (assuming i is provided in subs or is a var) #}
{% x += 1 %} {# increment a variable; x must be defined before hand: #}
{# e.g., with {% var x = 0 %} #}- You can use any Python expression or statement (statements must be single-line; use
;to separate multiple statements). - Statements evaluate to
Noneand render as empty string. - Callables in substitutions are invoked automatically and their return value is rendered.
- Non-strings that are iterable render as inline lists in span contexts, or as block lists otherwise.
- Names used in substitutions/expressions can be provided by your host code via the
subsargument toevaluate(). They can be:- Simple values (strings, numbers, booleans)
- Callables (auto-invoked for simple substitutions and their return value is rendered)
- Iterables (auto-rendered as inline lists in span contexts, or block lists otherwise)
- Mappings with
header/itemsfor nested lists - Modules/classes (for use in expressions) (requires eval enabled)
- Any object since attributes/methods can be accessed in expressions (requires eval enabled)
Providing substitutions:
from flextex import evaluate
tmpl = "Hello, {% name %}! You turn {% age + 1 %} tomorrow."
print(evaluate(tmpl, {"name": "Alice", "age": 41}))
# -> Hello, Alice! You turn 42 tomorrow.Layered substitutions (tuple precedence):
from flextex import evaluate
app = {"app_name": "FlexBoard"}
user = {"name": "Bob"}
tmpl = "Welcome to {% app_name %}, {% name %}!"
print(evaluate(tmpl, (user, app))) # `user` overrides keys in `app` if duplicated
# -> Welcome to FlexBoard, Bob!Callables (auto-invoked vs. expressions):
from datetime import datetime
from flextex import evaluate
def now_iso():
return datetime.now().isoformat(timespec="seconds")
print(evaluate("Generated at {% now_iso %}", {"now_iso": now_iso}))
# -> Generated at 2004-09-04T12:34:56
# the following relies on eval being enabled:
print(evaluate("Generated at {% now_iso() %}", {"now_iso": now_iso}))
# -> Generated at 2004-09-04T12:34:56Lists (inline vs. block):
from flextex import evaluate
tmpl = "Hobbies: {% hobbies %} (span)\nBlock:\n{% hobbies %}"
print(evaluate(tmpl, {"hobbies": ["sailing", "skiing", "chess"]}))
# -> Hobbies: sailing, skiing, and chess (span)
# -> Block:
# -> sailing
# -> skiing
# -> chessProviding modules/classes for expressions:
from datetime import datetime, timezone
from flextex import evaluate
tmpl = "UTC now: {% datetime.now(timezone.utc).isoformat(timespec='seconds') %}"
print(evaluate(tmpl, {"datetime": datetime, "timezone": timezone}))Safe mode (disable Python eval, still allow identifiers and callables):
from flextex import precompile, evaluate
def now_iso():
# still works in noeval mode because it's a simple identifier substitution
from datetime import datetime
return datetime.now().isoformat(timespec="seconds")
ast = precompile("Now: {% now_iso %}", noeval=True)
print(evaluate(ast, {"now_iso": now_iso})) # OK
# Expressions will fail in noeval mode:
# precompile("Now: {% now_iso() %}", noeval=True) -> FlextexErrorYour templates can define variables with the {% var ... %} directive. Only variables created via {% var ... %} are mutable in later expressions/statements. Variables are strongly scoped to the enclosing block (unlike Python's scoping). A variable defined inside a def, if, foreach, or any other block is not visible outside that block, but is visible in nested inner blocks. A variable with the same name in the inner scope 'hides' the outer one. Use outer(var_name, level=1) to access an outer-scope variable with the same name from an inner block (e.g., in nested foreach). Of course, you can just copy the outer variable to a new inner variable with a different name.
{% var x = 0 %} {# define a mutable variable x with initializer expression 0 #}
{% x += 1 %} {# increments x by 1, outputs nothing, var is now 1 #}
{% x %} {# simple substitution, outputs 1 #}
{% (x := x + 1) %} {# increments x by 1 and outputs 2 #}
{% x %} {# also outputs 2 #}
{% var l = ['Alice', 'Bob'] %} {# define a list variable l #}
{% l.append('Charlie') %} {# modify the list in place #}
Hello, {% l %} {# outputs: Hello, Alice, Bob, and Charlie #}
{% foreach l %} {# iterate over list l #}
- {% item %}
{% /foreach %} {# outputs: - Alice\n- Bob\n- Charlie #}❇️ Use the walrus operator
:=and enclosing parentheses()to assign a value to a variable and have it output at the same time
Macros are like functions where the whole rendered body is the return value. They can take positional and keyword arguments. Macros are defined with the {% def macro_name(...) %} ... {% /def %} directive and called via a substitution {% macro_name(...) %}.
{% def greet(name, times) %}
{% var k = 0 %} {# local var k; we could just use foreach's I #}
{% foreach range(times) %}
{% I %}: Hello, {% name %} ({% ordinal(k := k + 1) %})!
{% /foreach %}
{% /def %}
{# Call (as expression): #}
{% greet("Alice", times=2) %}
{% greet("Bob", times=1) %}Output:
1: Hello, Alice (1st)!
2: Hello, Alice (2nd)!
1: Hello, Bob (1st)!
- Argument declaration can be omitted (no-arg macro):
{% def time_now %} ... {% /def %} - Macros can be recursive
Import other templated text with {% <source> %} directive. The import source will be parsed and compiled as if it were part of the original text. Imports are always resolved during the compilation phase, so the imported content is parsed/compiled even if inside a conditional branch that doesn’t execute later. This ensures that precompilation works correctly and that all imports are available regardless of control flow during evaluation/rendering. It also ensures that subsequent evaluations will be fast since all imports are already compiled.
{% if is_prod %}
{% <prod_preamble.txt> %} {# must be available regardless of is_prod value #}
{% else %}
{% <dev_preamble.txt> %} {# must be available regardless of is_prod value #}
{% endif %}
... Other templated text here ...
{% <resource://your.pkg/templates/footer.txt> %}Import sources:
- File path:
file:/path/to/file.txtorfile:///abs/path/file.txtor just/path/to/file.txt - Package resource:
resource://package.module/path/in/package.txt(also supportsresource:package.module/path/...) - Substitution: if a substitution with the exact import name exists (in
subsarg toevaluate/precompile), its value is used as the import's source text (callables are invoked; non-strings are stringified)
Example importing from a substitution:
from flextex import evaluate
partials = {
"header.txt": "# Hi, {% name %}!\n",
}
tmpl = "{% <header.txt> %}Welcome."
print(evaluate(tmpl, {"name": "Alice", **partials}))
# -> "# Hi, Alice!\nWelcome."Conditional branching is done with the if / elif / else / endif (or /if) directives. The condition can be any identifier or expression that evaluates to a boolean value.
{% if condition1 %} {# simple substitution as condition #}
Output if condition1 is true.
{% elif len(some_list) > 1 %} {# expression as condition #}
Output if condition1 is false and some_list has more than one item.
{% else %}
Output if both condition1 and some_list are false.
{% endif %} {# or {% /if %} #}Branches can be as deeply nested as needed.
Foreach is a looping construct similar to Python's for loop, allowing you to iterate over a list of items and output them in a formatted way.
Inside the body of the foreach block, you have access to these special loop variables:
| Variable | Meaning |
|---|---|
item |
current item |
i |
0‑based index |
I |
1‑based index |
count |
total items |
is_last |
True if last |
Foreach source can be any substitution or expression that evaluates to an iterable.
- strings become single-item lists
- callables are invoked; result treated as list
- mappings with
header/itemsare treated as nested lists. Notebodyis an alias foritems.
Example: simple ordered list where items = ["Apple", "Banana", "Cherry"]:
| Template | Output |
|---|---|
{% foreach items %}
{% I %}. {% item %}
{% /foreach %} |
|
Nested lists can be processed generically with foreach with the help of built-in utilities (or provide your own):
- check if
itemis a list:is_list(item)is simplyisinstance(item, list) - check if
itemis a nested list:is_nested_list(item)is simplyisinstance(item, NestedList) - access outer loop's index variable with
outer('I'),outer('item'), etc. or just save to a new variable before the inner loop:{% var outer_I = I %}See here for more details onouter().
Example: nested ordered list
{% foreach l %}
{% if is_nested_list(item) %}
{% I %}. {% item.header %}
{% foreach item.items %}
{% outer('I') %}.{% alpha(i).lower() %} {% item %}
{% /foreach %}
{% else %}
{% I %}. {% item %}
{% endif %}
{% /foreach %}If above template is evaluated with l = [{"header": "Fruits", "items": ["Orange", "Lemon"]}, "Vegetables", "Grains"] we get:
1. Fruits
1.a Orange
1.b Lemon
2. Vegetables
3. Grains
In addition to relying on the hosting application to provide your template with lists, or using the var directives, e.g., {% var l=['Alice', 'Bob', 'Charlie'] %}, you can define a list using the {% list %} directive.
Example:
{% list items %}
{% li %}Apples{% /li %}
{% li %}Bananas {# closing /li tags are optional #}
{% li %}Cherries
{% /list %}❇️ Closing tags
{% /li %}are optional. Formatting/indentation inside the named list block is ignored/removed; only the content inside each{% li %}matters.
You then can use the list whenever a list is expected, e.g. in a foreach loop:
{% foreach items %}
{% I %}. {% item %}
{% /foreach %}Of course, the real flexibility comes from being able to define lists with dynamic content using conditionals, loops, etc:
{% list instructions %}
{% if initial %} {# conditional list item(s) #}
{% li %}Start with a greeting.
{% li %}Ask for a name.
{% else %}
{% li %}Welcome back, {% user_name %}.
{% endif %}
{% foreach steps %} {# copy items from another iterable #}
{% li %}{% item %}{% /li %}
{% /foreach %}
{% li %}Say goodbye.{% /li %}
{% /list %}By omitting the list's name, just {% list %}, a render-in-place (render immediately) list is created, similar to HTML's <ul> or <ol> tags, the list is rendered immediately where it is defined.
Example:
{% list %}
{% li %}{% I %} - London
{% li %}{% I %} - Paris
{% if include_us %}
{% li %}{% I %} - New York
{% /if %}
{% li %}{% I %} - Tokyo
{% /list %}Output:
if include_us = True | if include_us = False |
|---|---|
|
|
The only reason, it seems, to use a render-in-place list block instead of just plain text is when you want to take advantage of the automatic i/I indexing variables inside the list items.
The {% list %} block (both render-in-place and named) has these special loop variables available:
| Variable | Meaning |
|---|---|
i |
0‑based index of the current list item |
I |
1‑based index of the current list item |
For nested lists, either save the outer index to a new variable before the inner list, or use the convenient outer('i') / outer('I') to access the outer list's index variable from the inner list. See outer() for details.
Since the templates can contain any arbitrary Python expressions, the objects your template receives can be anything you like. That includes nested lists - you can define them any way you like. However, when a nested list is defined like this:
landmarks = [
{'header': "France",
'items': [{'header': "Paris", 'items': ["Eiffel Tower", "Louvre Museum", "Notre-Dame Cathedral"]},
{'header': "Lyon", 'items': ["Basilica of Notre-Dame de Fourvière", "Parc de la Tête d'Or"]}]},
{'header': "Italy",
'items': [{'header': "Rome", 'items':["Colosseum", "Trevi Fountain", "Pantheon"]},
{'header': "Venice", 'items':["St. Mark's Basilica", "Grand Canal"]}]},
]It has the benefit of being easily rendered with ul()/ol() built-in functions or processed with foreach loops. Here, the nested lists are just mappings with header and items keys. header is used to define the sublist's title, and items is a list of either strings (list items) or further nested lists (mappings with header and items). Then inside the template, in your foreach loops you can access the header and items keys simply as attributes: item.header and item.items. And the built-in is_nested_list(item) function can be used to check if the current item is a nested list or a simple list item. In addition, the {% list ... %} directive defines nested lists in the same way.
Define the landmarks list directly in your template with {% list %} directive:
{# Define the landmarks list #}
{% list landmarks %}
{% list %} France {# any text outside li is treated as header #}
{% list %} Paris
{% li %}Eiffel Tower
{% li %}Louvre Museum
{% li %}Notre-Dame Cathedral
{% /list %}
{% list %} Lyon
{% li %}Basilica of Notre-Dame de Fourvière
{% li %}Parc de la Tête d'Or
{% /list %}
{% /list %}
{% list %} Italy
{% list %} Rome
{% li %}Colosseum
{% li %}Trevi Fountain
{% li %}Pantheon
{% /list %}
{% list %} Venice
{% li %}St. Mark's Basilica
{% li %}Grand Canal
{% /list %}
{% /list %}
{% /list %}This nested list then can be handed to foreach to produce custom-formatted output:
| Template | Output |
|---|---|
{% foreach landmarks %}
{% I %}. {% item.header %} {# Country #}
{% foreach item.items %} {# Cities #}
<i>{% item.header %}</i>
{% foreach item.items %} {# Landmarks #}
- {% item %}
{% /foreach %}
{% /foreach %}
{% /foreach %} |
1. France
<i>Paris</i>
- Eiffel Tower
- Louvre Museum
- Notre-Dame Cathedral
<i>Lyon</i>
- Basilica of Notre-Dame de Fourvière
- Parc de la Tête d'Or
2. Italy
<i>Rome</i>
- Colosseum
- Trevi Fountain
- Pantheon
<i>Venice</i>
- St. Mark's Basilica
- Grand Canal |
🔸
ol(items, item_format=None, nested_indent=2, separator=None)
🔸ul(items, item_format=None, nested_indent=2, separator=None)
Built-in ol and ul functions output ordered and unordered lists respectively.
{% ol(items) %} {# defaults to numbered ordered list #}
===========
{% ul(items) %} {# defaults to bulleted unordered list #}If above template is evaluated with items = ["Apple", "Banana", "Cherry"], the output will be:
1. Apple
2. Banana
3. Cherry
===========
- Apple
- Banana
- Cherry
Note: since we indented the {% ul(items) %} statement, the entire output list block is also indented accordingly in the output. This is not special to ol/ul, but rather how indentation works in FlexTex templates in general. FlexTex works hard to preserve your intended output formatting while allowing you to write readable templates.
ol and ul only differ in the default value for item_format argument, otherwise they behave identically.
-
items: list of items to render; can be an iterable or any expression that evaluates to an iterable -
item_format: format string for each item, which defaults to:ol:"{i}. {li}"ul:"- {li}"- for inline lists (rendered in a span context) defaults are:
ol:"({i:I|.I}) {li}"produces →"(1) Item 1, (2) Item 2, (3) Item 3"ul:"(*) {li}"produces →"(*) Item 1, (*) Item 2, (*) Item 3"
available format placeholders:
-
{i}: current item's index object with flexible formatting for nested indices(click to expand)
Format specifier for
{i}contains the following pattern for each nesting level; the pattern can be repeated to specify deeper levels:( [~][prefix] (type | bullet ) [suffix][|] )+~tilde, at the beginning of the item suppresses this item's index if there are subitems present.prefix/suffix: any literal string (e.g., punctuation.) to appear before/after the index at that level. Note these cannot containtypespecifiers or|.type:A- capitalized alpha-index,a- lowercase alpha-indexI- numeric index 1-based,i- numeric index 0-based; zero-pad with0(e.g.,0Ior00i)R/r- Roman numeral upper/lower
bullet: any sequence in backticks..represents a 'bullet' used instead of atype, e.g., '*' or '-'. Empty backticks suppress the index at that level.|ends the item format, e.g.,"i:I|.A"results in no trailing period, as opposed to"i:I.A."which always has a trailing period- Repeat the last spec for deeper levels automatically
Examples:
Specifier Output {i}Defaults to {i:I.}→1.1.2.{i:I.}Numeric 1-based with trailing dot each level → 1.1.2.{i:I|.A}Level 1 → 12(no trailing period)
Level 2+ →1.A1.B.A{i:A.a.I}Level 1 → A.B.
Level 2 →A.a.A.b.
Level 3+ →A.a.1A.a.2{i:(A)/(a)/}Wrap in ( )with/as separators
→(A)/(b)/(A)/(a)/(b){i:~A.a}Suppress 1st level output if deeper nesting present
Level 1 →A.
Level 2 →a{i:`*`}Use literal bullet (any sequence really) → ******{i:0I.00I}Zero pad → 01.01.002{i:R.r}Upper / lower Roman numerals → I.II.iv -
{I}: 1-based index of the current item -
{li}: list item content.
-
nested_indent(int or string): number of spaces (or any filling string) to use for nested lists (default is 2). The nested lists are handled automatically based on the type of items in the list (if an item is itself a list orNestedList, it is treated as a nested list). -
separator(string or callable): string to use to separate items (default is", "for spans,"\n"for block elements); or a callable that joins preformatted item strings.
Consider this example rendering twice the nested list of landmarks defined previously:
default = evaluate("\n{% ol(landmarks) %}", {'landmarks': landmarks})
custom = evaluate("\n{% ol(landmarks, item_format='{i:~I. |~`<i>`|`- `}{li}{i:``|~`</i>`|``}') %}",
{'landmarks': landmarks})Output:
| default | custom |
|---|---|
|
1. France
<i>Paris</i>
- Eiffel Tower
- Louvre Museum
- Notre-Dame Cathedral
<i>Lyon</i>
- Basilica of Notre-Dame de Fourvière
- Parc de la Tête d'Or
2. Italy
<i>Rome</i>
- Colosseum
- Trevi Fountain
- Pantheon
<i>Venice</i>
- St. Mark's Basilica
- Grand Canal |
Inline (span) list serialization is automatic in some contexts and also available via helpers:
en_and(items)→ "a, b and c"en_comma_and(items)→ "a, b, and c"en_or(items)→ "a, b or c"en_comma_or(items)→ "a, b, or c"
You can also render inline lists with {% ol(items) %} or foreach in inline (span) contexts:
{# inline list of items, using span foreach #}
{% foreach items %}{% item %}{% if not is_last %}, {% endif %}{% /foreach %}
Outputs: a, b, cTemplate-level metadata can be specified using the {% meta key=value %} directive. This allows you to set options that affect the entire template. For example, you can set the structural indentation level for the template using:
{% meta indent=2 %}This sets the structural indentation to 2 spaces instead of the default 4.
- Meta settings apply to the template text following the directive.
- Meta is processed at compile time and should be used at the top level (this is enforced).
- Meta key without a value (e.g.,
{% meta indent %}) resets to the default value. - Meta does not cross import boundaries; each imported template can have its own meta settings.
- Supported metadata keys:
indentis the only supported key currently; value must be a non-negative integer specifying the number of spaces for structural indentation; default is 4.
The following functions are injected automatically into the expression scope and can be used to format numbers and lists:
| Function | Description |
|---|---|
alpha(n) |
Spreadsheet-style letters (A, B, …, Z, AA, AB, …) |
ordinal(n) |
English ordinal string (1st, 2nd, …) |
roman(n) |
Roman numerals (I, II, … XII, …)Use roman(n).lower() for lowercase. |
cardinal(n, sep=' ') |
English cardinal words (twenty one)Use sep='_' for snake form (twenty_one). |
en_and(items)en_comma_and(items) |
Inline list joined with commas + andSecond form uses Oxford comma. |
en_or(items)en_comma_or(items) |
Inline list joined with commas + orSecond form uses Oxford comma. |
ol(items,item_format=None, nested_indent=2) |
Ordered list renderer. |
ul(items, item_format=None, nested_indent=2) |
Unordered list renderer. |
is_list(v) |
Type check for Python list. Strictly checks for list (not any sequence). |
is_nested_list(v) |
Check for FlexTex nested list. True for NestedList items. |
outer(var_name, level=1) |
Access outer-scope variable. Only needed when shadowed |
Examples:
{% alpha(28) %} {# -> "AB" #}
{% alpha(28).lower() %} {# -> "ab" #}
{% ordinal(23) %} {# -> "23rd" #}
{% roman(1999) %} {# -> "MCMXCIX" #}
{% roman(1999).lower() %} {# -> "mcmxcix" #}
{% cardinal(42, '-') %} {# -> "forty-two" #}
{% cardinal(42).title() %} {# -> "Forty Two" #}
{% is_list([a,2,3]) %} {# -> True #}
{% en_and(["apples", "bananas", "cherries"]) %} {# -> "apples, bananas and cherries" #}
{% en_comma_and(["apples", "bananas", "cherries"]) %} {# -> "apples, bananas, and cherries" #}Access a variable from an enclosing scope when an inner block shadows it (common in nested foreach/list loops for automatic loop variables like item, i, I).
-
Args:
var_name(str): Variable name to fetch (e.g.,"I").level(int, default 1): Scopes to climb; 1 = immediate outer.
-
Returns: The value of the outer variable.
-
Notes:
- Not needed unless a same‑named inner variable hides the outer one.
- Alternative: save the outer var to a new name before the inner block.
Example (both approaches produce the same output):
| using differently named var | using outer() |
|---|---|
{% foreach categories %}
{% I %}. {% item.header %}
{% var cI = I %} {# save outer I #}
{% foreach item.items %}
{% cI %}.{% I %} - {% item %}
{% /foreach %}
{% /foreach %} |
{% foreach categories %}
{% I %}. {% item.header %}
{% foreach item.items %}
{% outer('I') %}.{% I %} - {% item %}
{% /foreach %}
{% /foreach %} |
| |
Compile (if tmpl is text) or reuse a precompiled AST, then evaluate and render to text.
- Args:
tmpl(str | AST): Template text or a precompiledAST. You can directly process a file or resource by passingtmplcontaining a single import directive:txt = evaluate("{% <path/to/file.txt> %}", subs) # file OR resource txt = evaluate("{% <resource://module.sub-module/resource_name> %}", subs)
subs(dict | tuple[dict, ...], optional): Substitutions available at evaluation time. If a tuple, earlier dicts take precedence. Substitutions may contain simple strings, callables, or any other objects. If you use expressions in the template, thesubsmust contain the necessary context for those expressions to evaluate correctly.
- Returns:
str: Rendered template text.
- Exceptions:
- Raises
FlextexErrorwith detailed source context on compile/render failures.
- Raises
Examples:
from flextex import evaluate
# render string template
txt = evaluate("Hi {% name %}", {"name": "Alice"})
# evaluate a file via import:
txt = evaluate("{% </abs/path/template.txt> %}", {"name": "Alice"})
# package resource:
txt = evaluate("{% <resource://mypkg.templates/welcome.txt> %}", {"name": "Ada"})Substitutions vs. expressions:
from datetime import datetime
from flextex import evaluate
def now_iso(): # return current time in ISO 8601 format
return datetime.now().isoformat(timespec="seconds")
# simple substitution: does NOT use eval(), flextex auto-invokes callables
tmpl = "Generated at {% now_iso %}"
print(evaluate(tmpl, {"now_iso": now_iso}))
# -> Generated at 2004-09-04T12:34:56
# Python expression: uses eval() to call now_iso()
tmpl = "Generated at {% now_iso() %}"
print(evaluate(tmpl, {"now_iso": now_iso}))
# -> Generated at 2004-09-04T12:34:56
# Python expression with more complex logic:
tmpl = "Generated at {% datetime.now().isoformat(timespec='seconds') %}"
print(evaluate(tmpl, {"datetime": datetime}))
# -> Generated at 2004-09-04T12:34:56Compile the template into an AST with imports resolved. Use the result with evaluate for fast repeated rendering.
- Args:
tmpl(str | Source | tuple[name, data]): Template text, aSource, or(name, data). You may also pass a single import directive to compile a file/resource:ast = precompile("{% </abs/path/file.txt> %}", subs) ast = precompile("{% <resource://pkg.mod/template.txt> %}", subs)
subs(dict | tuple[dict, ...], optional): Substitutions used by the template's import{% <inc_this> %}directives. If a tuple, earlier dicts take precedence. Substitutions should contain strings, callables, or any other objects convertible to import's source text.noeval(bool, default False): Disable expression evaluation for this template. GlobalNOEVAL(seeset_noeval) takes precedence.
- Returns:
- Compiled template (
AST). Use withevaluateto render.
- Compiled template (
- Exceptions:
- Raises
FlextexErrorwith detailed source context on failures.
- Raises
- Notes:
- Imports are always resolved during precompile (even inside branches), so later evaluations are fast.
Typical reuse pattern (precompile once, render many):
from flextex import precompile, evaluate
tmpl = precompile("{% <templates/welcome.txt> %}")
for name in ["Sneezy", "Sleepy", "Dopey", "Grumpy"]:
print(evaluate(tmpl, {"name": name}))Sets the global indentation level for all templates. Returns previous value.
- Args:
- indent (int|None):
- If None, leaves INDENT_SIZE unchanged; returns current value.
- If int, sets INDENT_SIZE to given value (must be >=0).
- indent (int|None):
➰ Notes:
- Default global indent is 4 spaces; check current value with:
print(set_indent(None))- Template
{% meta indent=<value> %}directive sets template-level indentation. Template's meta settings take precedence over the global setting.
Example:
from flextex import set_indent, evaluate
tmpl = """
{% if True %}
hello
{% endif %}"""
print(evaluate(tmpl)) # -> " hello" (structural indent is 4 spaces by default)
set_indent(2) # set global structural indent to 2 spaces
print(evaluate(tmpl)) # -> "hello" (structural indent is 2 spaces now)Disables evaluation of expressions globally (security/safe-mode). Returns previous value.
- Args:
- noeval (bool|None):
- If None, leaves NOEVAL unchanged; returns current value.
- If
Truedisables eval() in all templates. - If
Falseenables eval() globally; you can still disable per-template eval(). (default)
- noeval (bool|None):
➰ Notes:
- See
precompile(..., noeval=True)to disable eval() on per-template basis. The global setting always takes precedence.
Example:
from flextex import set_noeval, evaluate
set_noeval(True) # disable eval for all templates
# Any attempt to compile an expression will fail with FlextexError
# with exact source location of the offending expression.
text = evaluate("Value: {% 1 + 2 %}")
# -> FlextexError: Failed to compile `substitution`: Evaluation is disabled
# -> <template>:1:11: `Value: {% 1̲ + 2 %}`Whenever there is an error, FlextexError is raised providing context-rich information to help locate and fix the problem. Error reports include:
- Precise line/column with underlined character
- Import chain trace (shows originating import site)
- Differentiates compile vs evaluation vs conversion vs render failures
- Provides specific messages (e.g., invalid macro arg, unclosed block, unknown variable)
Example:
FlextexError: Reference 'foo' not found
<foo.txt>:12:7: ` {% foo̲ %}`
Imported from <common.ftx>:3:5: ` {% <foo.txt> %}`
Flextex module can be used as a command-line tool to render/compile simple templates. The substitutions can be provided as a JSON object via standard input.
python -m flextex --version # show version
python -m flextex -e "Hello {% name %}" <<< '{"name":"World"}' # evaluate inline template
python -m flextex -e example.flex # if existing file, treat as "{% <example.flex> %}" otherwise as template text
python -m flextex -c "Hello {% name " # compile inline template, see if there are errors- Evaluating a template is a two-step process: compile the template into AST (
precompile) and then evaluate or render the resulting AST. Precompile once, render many: parsing, AST building, and import resolution happen duringprecompile, so reusing the returnedASTavoids a lot of repeated work. - Expression bytecode cache: non-identifier expressions are compiled once (on first evaluation) and cached on the AST/node, so repeated uses (e.g., inside
foreach) are fast. This applies to:- Direct
evaluatecalls when the same expression appears multiple times. - All subsequent
evaluate(ast, ...)calls for a precompiled AST (cache is reused across runs).
- Direct
- FlexTex keeps no global state and is safe to use from multiple threads concurrently. In particular: It is safe to precompile a template and use the same compiled template from multiple threads.
- Global flag caveat:
set_noeval(True/False)changes a process‑wide flag. Avoid flipping it while other threads are evaluating. Prefer per‑ASTnoeval=True.
- Do not evaluate untrusted templates or untrusted substitutions.
- If you use Python expressions and statements and not simple substitution identifiers inside
{% ... %}directives these are evaluated using Python’seval/compilein a restricted environment that exposes only the substitutions you provide and the built-ins listed above. Only when the substitutions are not identifierseval/compileis used; simple identifier substitutions are looked up directly. - Disable evaluation when needed:
- Per individual template:
precompile(..., noeval=True)thenevaluate(ast, ...)as usual. - Globally:
set_noeval(True)before any precompilation/evaluation.
- Per individual template:
- In
noevalmode, only simple identifier substitutions are allowed (substitutions can still be callables that you define and provide so it's not all doom and gloom); any other expression or statement will result in an error. Of course, this means your hosting application will have to provide all the necessary substitutions directly. Note that you still have full flow control withif,foreach, lists, macros, imports, etc.; only Python expression evaluation is disabled.
See DEV.md for guidelines. See also CHANGELOG.md.
MIT (see LICENSE).