From bf86199377e4e0c71a4281a8dcb639e7b7db0933 Mon Sep 17 00:00:00 2001 From: Rickard von Haugwitz Date: Mon, 13 Jul 2026 17:30:55 +0200 Subject: [PATCH] Make text view template substitution single pass --- docs/output-emit-text-views.md | 2 +- src/command_generation/primitive_executor.py | 24 +++++--------------- tests/test_public_api.py | 8 +++++++ 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/docs/output-emit-text-views.md b/docs/output-emit-text-views.md index a966f40..994412e 100644 --- a/docs/output-emit-text-views.md +++ b/docs/output-emit-text-views.md @@ -41,7 +41,7 @@ Missing `for_each` values, `null`, and empty strings render no lines. Present no ## Placeholders And Filters -Templates replace `{path}` placeholders with scalar values. Empty `{}` and `{.}` refer to the current item. `root.` paths resolve against the root result payload. +Templates replace `{path}` placeholders with scalar values in a single pass over the original template. Placeholder-looking text introduced by a replacement is emitted literally and is not expanded again. Empty `{}` and `{.}` refer to the current item. `root.` paths resolve against the root result payload. Direct placeholders accept JSON scalars only. Arrays and objects must use the explicit `{"json": "path"}` line form. Missing values and null render as an empty string. diff --git a/src/command_generation/primitive_executor.py b/src/command_generation/primitive_executor.py index 11073f6..e8cb7af 100644 --- a/src/command_generation/primitive_executor.py +++ b/src/command_generation/primitive_executor.py @@ -3,6 +3,7 @@ import importlib import json import math +import re import tomllib from collections.abc import Callable, Mapping, Sequence from dataclasses import asdict, dataclass, field, is_dataclass @@ -18,6 +19,7 @@ class PrimitiveExecutionError(RuntimeError): _DECLARED_TEXT_MAX_SAFE_INTEGER = 9_007_199_254_740_991 +_DECLARED_TEXT_TEMPLATE_PATTERN = re.compile(r"\{([^}]*)\}") PrimitiveHandler = Callable[[dict[str, Any], dict[str, Any], "PrimitiveContext"], Any] @@ -784,26 +786,12 @@ def _render_declared_text_line(line: Any, *, current: Any, root: dict[str, Any]) def _render_declared_text_template(template: str, *, current: Any, root: dict[str, Any]) -> str: - rendered = template - for token in _declared_text_template_tokens(template): + def replace(match: re.Match[str]) -> str: + token = match.group(1) found, value = _declared_text_placeholder_value(token, current=current, root=root) - rendered = rendered.replace("{" + token + "}", _declared_text_format(value if found else "")) - return rendered - + return _declared_text_format(value if found else "") -def _declared_text_template_tokens(template: str) -> list[str]: - tokens: list[str] = [] - index = 0 - while index < len(template): - start = template.find("{", index) - if start == -1: - break - end = template.find("}", start + 1) - if end == -1: - break - tokens.append(template[start + 1 : end]) - index = end + 1 - return tokens + return _DECLARED_TEXT_TEMPLATE_PATTERN.sub(replace, template) def _declared_text_placeholder_value(token: str, *, current: Any, root: dict[str, Any]) -> tuple[bool, Any]: diff --git a/tests/test_public_api.py b/tests/test_public_api.py index a197a41..ecdf06d 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1789,6 +1789,8 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p {"name": "active", "arg": "active", "default": False}, {"name": "score", "arg": "score", "default": 0}, {"name": "flags", "arg": "flags", "default": []}, + {"name": "first", "arg": "first", "default": ""}, + {"name": "second", "arg": "second", "default": ""}, ] ) operation_path = tmp_path / "contracts" / "operations" / "todo.list.report.json" @@ -1811,6 +1813,8 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "active": {"$value": "active"}, "score": {"$value": "score"}, "flags": {"$value": "flags"}, + "first": {"$value": "first"}, + "second": {"$value": "second"}, } } }, @@ -1833,6 +1837,7 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "Profile: {profile}", "Active: {active}", "Score: {score}", + "Single pass: {first} {second}", "Flags: {flags|join:/}", "Items: {items|join:, |empty:(none)}", "Missing: {missing|join:, |empty:(none)}", @@ -1885,6 +1890,8 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "active": True, "score": 1.0, "flags": [True, False, "ok", 2.0], + "first": "{second}", + "second": "resolved", } py_text = py_executor.run_operation_callable(py_contract, values) finally: @@ -1910,6 +1917,7 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "Profile: compact\n" "Active: true\n" "Score: 1\n" + "Single pass: {second} resolved\n" "Flags: true/false/ok/2\n" "Items: alpha, beta\n" "Missing: (none)\n"