Improve render()
Done- Plan (done)
- Code (done)
- Test (done)
- Review (done)
- Finish (done)
Cost
$0.0089
Tests
Passed
Iterations
1
Re-plans
0
LLM calls
15
Prompt read from cache
76%
Usage by agent
- Cache read
- Cache write
- Input
- Output
View as table
| Agent | Cost | Cache read | Cache write | Input | Output |
|---|---|---|---|---|---|
| planner | $0.0022 | 8,839 | 3,095 | 12 | 3,506 |
| coder | $0.0024 | 27,899 | 4,663 | 14 | 3,034 |
| tester | $0.0030 | 23,329 | 7,635 | 10 | 3,613 |
| reviewer | $0.0013 | 1,727 | 4,137 | 4 | 1,571 |
Timeline
-
planner iteration 1 Finished 4 calls · 15.5K tokens · $0.0022 · 17.8s
Loading…
-
coder iteration 1 Finished 6 calls · 35.6K tokens · $0.0024 · 16.9s
Loading…
-
tester iteration 1 Finished 4 calls · 34.6K tokens · $0.0030 · 23.0s
Loading…
-
verify iteration 1 Passed 0.5s
........................ [100%] 24 passed in 0.04s
-
reviewer iteration 1 Finished 1 calls · 7,439 tokens · $0.0013 · 8.0s
Loading…
Final diff
diff --git a/template.py b/template.py index 8cfdd25..b0c383f 100644 --- a/template.py +++ b/template.py @@ -1,5 +1,34 @@ import re +class MissingVariable(KeyError): + """Raised when a template refers to a name that cannot be found in the context.""" + + def __str__(self) -> str: + # KeyError.__str__ would repr() the message; show it plainly instead. + return str(self.args[0]) if self.args else "" + + +# Alternative 1: \{{ ... }} is an escape and is emitted literally (minus the backslash). +# Alternative 2: {{ dotted.name }} is a variable, with optional inner whitespace. +_PATTERN = re.compile(r"\\\{\{.*?\}\}|\{\{\s*(\w+(?:\.\w+)*)\s*\}\}") + + +def _lookup(context: dict, dotted: str): + current = context + for part in dotted.split("."): + if not isinstance(current, dict) or part not in current: + raise MissingVariable(dotted) + current = current[part] + return current + + def render(template: str, context: dict) -> str: - return re.sub(r"\{\{(\w+)\}\}", lambda match: str(context[match[1]]), template) + def replace(match: re.Match) -> str: + if match.group(0).startswith("\\"): + return match.group(0)[1:] + value = _lookup(context, match.group(1)) + return "" if value is None else str(value) + + # Single pass over the template: substituted values are never rescanned. + return _PATTERN.sub(replace, template) diff --git a/test_template.py b/test_template.py index 9cfb1dc..38a0744 100644 --- a/test_template.py +++ b/test_template.py @@ -1,5 +1,121 @@ -from template import render +import pytest + +from template import MissingVariable, render def test_simple(): assert render("Hi {{name}}!", {"name": "Ada"}) == "Hi Ada!" + + +def test_spaces_inside_braces(): + assert render("{{ name }}", {"name": "Ada"}) == "Ada" + + +def test_dotted_lookup(): + assert render("{{ user.name }}", {"user": {"name": "Ada"}}) == "Ada" + + +def test_backslash_escape(): + assert render(r"\{{ name }}", {"name": "Ada"}) == "{{ name }}" + + +def test_escape_stops_at_first_close_and_variable_still_renders(): + assert render(r"\{{a}} {{b}}", {"b": "X"}) == "{{a}} X" + + +def test_lone_backslash_unchanged(): + assert render(r"C:\path {{ x }}", {"x": "1"}) == r"C:\path 1" + + +def test_missing_dotted_name_raises_with_full_name(): + with pytest.raises(MissingVariable) as info: + render("{{ user.age }}", {"user": {}}) + assert isinstance(info.value, KeyError) + assert "user.age" in str(info.value) + + +def test_dotted_path_through_string_raises(): + with pytest.raises(MissingVariable) as info: + render("{{ user.name.first }}", {"user": {"name": "Ada"}}) + assert "user.name.first" in str(info.value) + + +def test_dotted_path_through_none_raises(): + with pytest.raises(MissingVariable): + render("{{ user.name }}", {"user": None}) + + +def test_missing_simple_name_raises(): + with pytest.raises(MissingVariable) as info: + render("{{ nope }}", {}) + assert "nope" in str(info.value) + + +def test_none_renders_empty(): + assert render("[{{ x }}]", {"x": None}) == "[]" + + +def test_substituted_values_are_not_expanded(): + assert render("{{ a }}", {"a": "{{b}}", "b": "X"}) == "{{b}}" + + +def test_escape_does_not_look_up_missing_name(): + assert render(r"\{{ nope }}", {}) == "{{ nope }}" + + +def test_escape_then_variable_with_other_name_on_same_line(): + assert render(r"\{{ a }} and {{ b }}", {"a": "1", "b": "2"}) == "{{ a }} and 2" + + +def test_double_backslash_before_braces_escapes_braces(): + # The second backslash escapes the opening braces, so nothing is substituted. + assert render(r"\\{{ x }}", {"x": "1"}) == "\\{{ x }}" + + +def test_escape_does_not_span_newline(): + # An escape is a single-line construct: a backslash before a multi-line + # {{...}} is left as-is and the braces are treated as a variable. + template = "\\{{ a\n }}" + with pytest.raises(MissingVariable): + render(template, {}) + + +def test_hyphen_name_stays_literal(): + assert render("{{ first-name }}", {"first-name": "Ada"}) == "{{ first-name }}" + + +def test_name_with_inner_space_stays_literal(): + assert render("{{ a b }}", {"a b": "x"}) == "{{ a b }}" + + +def test_unicode_name(): + assert render("{{ café }}", {"café": "ok"}) == "ok" + + +def test_falsy_values_render_as_text(): + assert render("{{ a }}|{{ b }}|{{ c }}", {"a": 0, "b": False, "c": ""}) == "0|False|" + + +def test_missing_name_caught_as_keyerror(): + try: + render("{{ zzz }}", {}) + except KeyError as exc: + assert "zzz" in str(exc) + else: + raise AssertionError("expected KeyError") + + +def test_intermediate_none_in_dotted_path_raises(): + with pytest.raises(MissingVariable) as info: + render("{{ a.b.c }}", {"a": {"b": None}}) + assert "a.b.c" in str(info.value) + + +def test_final_none_in_dotted_path_renders_empty(): + assert render("[{{ a.b }}]", {"a": {"b": None}}) == "[]" + + +def test_str_is_not_repr_quoted(): + with pytest.raises(MissingVariable) as info: + render("{{ user.age }}", {"user": {}}) + assert str(info.value) == "user.age"