Improve render()
Done- Plan (done)
- Code (done)
- Test (done)
- Review (done)
- Finish (done)
Cost
$0.0056
Tests
Passed
Iterations
1
Re-plans
0
LLM calls
11
Prompt read from cache
77%
Usage by agent
- Cache read
- Cache write
- Input
- Output
View as table
| Agent | Cost | Cache read | Cache write | Input | Output |
|---|---|---|---|---|---|
| planner | $0.0013 | 6,099 | 982 | 8 | 2,318 |
| coder | $0.0023 | 22,014 | 5,714 | 12 | 2,673 |
| tester | $0.0020 | 14,384 | 5,895 | 8 | 2,282 |
Timeline
-
planner iteration 1 Finished 3 calls · 9,407 tokens · $0.0013 · 12.4s
Loading…
-
coder iteration 1 Finished 5 calls · 30.4K tokens · $0.0023 · 17.8s
Loading…
-
tester iteration 1 Finished 3 calls · 22.6K tokens · $0.0020 · 12.9s
Loading…
-
verify iteration 1 Passed 0.5s
..................... [100%] 21 passed in 0.04s
Final diff
diff --git a/template.py b/template.py index 8cfdd25..b567e26 100644 --- a/template.py +++ b/template.py @@ -1,5 +1,31 @@ import re +_PATTERN = re.compile(r"(\\)?\{\{\s*(\w+(?:\.\w+)*)\s*\}\}") + + +class MissingVariable(KeyError): + def __str__(self) -> str: + return str(self.args[0]) + + +def _lookup(name: str, context: dict): + parts = name.split(".") + if parts[0] not in context: + raise MissingVariable(f"missing variable: {name}") + value = context[parts[0]] + for part in parts[1:]: + if not isinstance(value, dict) or part not in value: + raise MissingVariable(f"missing variable: {name}") + value = value[part] + return value + 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[1]: + # Escaped opening: drop the backslash and leave the rest literal. + return match[0][1:] + value = _lookup(match[2], context) + return "" if value is None else str(value) + + return _PATTERN.sub(replace, template) diff --git a/test_template.py b/test_template.py index 9cfb1dc..c4393fb 100644 --- a/test_template.py +++ b/test_template.py @@ -1,5 +1,102 @@ -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("Hi {{ name }}!", {"name": "Ada"}) == "Hi Ada!" + + +def test_dotted_lookup(): + assert render("{{ user.name }}", {"user": {"name": "Ada"}}) == "Ada" + + +def test_escaped_opening(): + assert render("\\{{ name }}", {"name": "Ada"}) == "{{ name }}" + + +def test_missing_top_level_name(): + with pytest.raises(MissingVariable) as exc: + render("{{ x }}", {}) + assert "x" in str(exc.value) + assert isinstance(exc.value, KeyError) + + +def test_missing_nested_key(): + with pytest.raises(MissingVariable) as exc: + render("{{user.age}}", {"user": {"name": "Ada"}}) + assert "user.age" in str(exc.value) + assert isinstance(exc.value, KeyError) + + +@pytest.mark.parametrize("user", ["Ada", 42]) +def test_dotted_path_through_non_dict(user): + with pytest.raises(MissingVariable) as exc: + render("{{ user.name }}", {"user": user}) + assert "user.name" in str(exc.value) + + +def test_none_renders_as_empty(): + assert render("[{{ x }}]", {"x": None}) == "[]" + + +def test_substituted_value_not_expanded(): + assert render("{{ a }}", {"a": "{{ b }}", "b": "X"}) == "{{ b }}" + + +def test_missing_message_is_plain_not_repr_quoted(): + with pytest.raises(MissingVariable) as exc: + render("{{ user.age }}", {"user": {}}) + assert not str(exc.value).startswith(("'", '"')) + assert "user.age" in str(exc.value) + + +def test_missing_top_level_message_contains_dotted_name(): + with pytest.raises(MissingVariable) as exc: + render("{{ nope.deep }}", {}) + assert "nope.deep" in str(exc.value) + + +def test_missing_variable_is_key_error_subclass(): + assert issubclass(MissingVariable, KeyError) + + +def test_intermediate_none_raises_missing(): + with pytest.raises(MissingVariable) as exc: + render("{{ user.name }}", {"user": None}) + assert "user.name" in str(exc.value) + + +def test_final_none_in_nested_dict_renders_empty(): + assert render("[{{ user.name }}]", {"user": {"name": None}}) == "[]" + + +def test_dict_and_list_values_are_str_ed(): + assert render("{{ d }}", {"d": {"k": 1}}) == str({"k": 1}) + assert render("{{ l }}", {"l": [1, 2]}) == str([1, 2]) + + +def test_lone_backslash_left_alone(): + assert render("C:\\path {{ a }}", {"a": "x"}) == "C:\\path x" + assert render("a\\b", {}) == "a\\b" + + +def test_escaped_dotted_name_untouched(): + assert render("\\{{ user.age }}", {}) == "{{ user.age }}" + + +def test_escape_only_affects_its_own_placeholder(): + assert render("\\{{ a }} and {{ a }}", {"a": "X"}) == "{{ a }} and X" + + +def test_empty_segment_passes_through_unchanged(): + assert render("{{ a..b }}", {"a": {"b": 1}}) == "{{ a..b }}" + assert render("{{ a. }}", {"a": 1}) == "{{ a. }}" + + +def test_value_containing_escape_sequence_not_reprocessed(): + assert render("{{ a }}", {"a": "\\{{ b }}", "b": "X"}) == "\\{{ b }}"