diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index 98be6aa0b..3ed71b0dc 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -34,17 +34,14 @@ 'has_references', ) -# Matches unescaped @{ (not preceded by \). Used as a cheap gate so we only -# parse strings that actually contain composition syntax. Real reference -# extraction goes through `pydantic_handlebars.extract_dependencies` so block -# helpers, dotted paths, and subexpressions are all handled AST-correctly. -# -# NOTE: this lookbehind encodes pydantic-handlebars' current escape semantics -# (any `\` immediately before `@{` escapes it). Handlebars.js distinguishes -# odd vs even backslash runs (e.g. `\\@{x}@` should render as `\X`). If -# pydantic-handlebars adopts the spec behaviour, this regex needs to count -# preceding backslashes rather than just check for one. -_HAS_REFERENCE = re.compile(r'(?= 0.2.1` a run of N +# backslashes before `@{` contributes `N // 2` literal `\`s and lets parity +# decide whether the mustache renders. Doing that count in a regex would +# need a variable-width lookbehind we can't portably write on 3.10/3.11, and +# is unnecessary now that the renderer is the single source of truth. +_HAS_OPEN_DELIM = '@{' # Dotted-reference matcher used by the unresolved-reference protection # helpers — those need a textual hook so the literal `@{name.field}@` source @@ -92,8 +89,18 @@ class ComposedReference: def has_references(serialized_value: str) -> bool: - """Quick check for any unescaped `@{` in a serialized value.""" - return _HAS_REFERENCE.search(serialized_value) is not None + r"""Quick check for any `@{` in a serialized value. + + Returns `True` whenever the string contains the composition open + delimiter, regardless of preceding backslashes. The actual + escape-or-real decision is made by `pydantic_handlebars` at render time + — distinguishing an escaped `\@{x}@` from an unescaped `@{x}@` here + would require a variable-width lookbehind (the renderer counts + backslash parity to match Handlebars.js semantics) and is unnecessary: + `extract_composition_dependencies` returns an empty set for escaped-only + strings, and the renderer correctly leaves the literal text in place. + """ + return _HAS_OPEN_DELIM in serialized_value def expand_references( @@ -146,12 +153,11 @@ def expand_references( # render an invalid value. return serialized_value, composed - # Collect all unique base variable names referenced anywhere in the decoded value. + # Collect all unique base variable names referenced anywhere in the decoded + # value. If there are none we still walk the structure through `_render_value` + # — the value may contain only escape sequences (`\@{x}@` etc.) that need + # to be processed through the renderer to produce the literal output. all_ref_names = _collect_ref_names(decoded) - if not all_ref_names: - # No references at all — return unchanged (but still unescape \@{ → @{). - expanded = _unescape_serialized(serialized_value) - return expanded, composed # Resolve each unique variable name and recursively expand nested references. context: dict[str, Any] = {} @@ -343,15 +349,17 @@ def _render_value(value: Any, context: dict[str, Any], unresolved_names: set[str ``@{name}@`` text so the renderer preserves them in the output. Dotted accesses against unresolved names are pre-protected so they retain the full ``@{name.field}@`` source rather than rendering as empty. + Strings without any composition delimiter pass straight through; strings + that contain `@{` (escaped or not) route through the renderer, which + handles the backslash-parity escape rule. """ if isinstance(value, str): if not has_references(value): - # Unescape \@{ to @{ for non-reference strings. - return value.replace('\\@{', '@{') + return value from logfire.variables.reference_syntax import render_once protected_value, protected_refs = _protect_unresolved_dotted_refs(value, unresolved_names) - rendered = render_once(protected_value, context) if has_references(protected_value) else protected_value + rendered = render_once(protected_value, context) return _restore_unresolved_refs(rendered, protected_refs) if isinstance(value, dict): return { @@ -393,12 +401,3 @@ def _restore_unresolved_refs(value: str, protected_refs: dict[str, str]) -> str: for sentinel, ref in protected_refs.items(): value = value.replace(sentinel, ref) return value - - -def _unescape_serialized(serialized: str) -> str: - r"""Unescape `\@{` to `@{` in a JSON-serialized string. - - In JSON encoding, a literal backslash is `\\`, so `\@{` in user content - appears as `\\@{` in the serialized JSON. - """ - return serialized.replace('\\\\@{', '@{') diff --git a/pyproject.toml b/pyproject.toml index c3f4cf10b..87c898672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ google-genai = ["opentelemetry-instrumentation-google-genai >= 0.4b0"] litellm = ["openinference-instrumentation-litellm >= 0"] dspy = ["openinference-instrumentation-dspy >= 0"] datasets = ["httpx>=0.27.2", "pydantic>=2", "pydantic-evals>=1.0.0"] -variables = ["pydantic>=2", "pydantic-handlebars>=0.2.0"] +variables = ["pydantic>=2", "pydantic-handlebars>=0.2.1"] [project.urls] Homepage = "https://pydantic.dev/logfire" @@ -201,7 +201,7 @@ dev = [ "pytest-examples>=0.0.18", "pytest-timeout>=2.4.0", "pytest-asyncio>=0.24.0", - "pydantic-handlebars>=0.2.0", + "pydantic-handlebars>=0.2.1", "claude-agent-sdk>=0", ] docs = [ diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 9912ee253..4e1dec7a3 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -292,10 +292,18 @@ def test_list_with_references(self): assert [ref.name for ref in composed] == ['greeting', 'name'] def test_keyword_block_references_are_ignored(self): - """Handlebars built-in names are not treated as variable references.""" + """Handlebars built-in names (`this`, helpers, `else`) aren't treated as variable references. + + `@{#if this}@yes@{/if}@` is a real Handlebars `if` block whose + condition is the current context (`this`). It evaluates normally — + with an empty context object (truthy in JS-style truthiness, which + pydantic-handlebars follows) the body renders as `yes`. The + important property under test is that no `composed` entries are + produced — `this` and `if` are not resolved as variable lookups. + """ expanded, composed = expand_references(json.dumps('@{#if this}@yes@{/if}@'), 'my_var', _make_resolve_fn({})) - assert json.loads(expanded) == '@{#if this}@yes@{/if}@' + assert json.loads(expanded) == 'yes' assert composed == [] def test_json_encoding_newlines(self): @@ -495,10 +503,17 @@ def test_referenced_html_entities_are_preserved(self): assert json.loads(expanded) == 'literal { and }' def test_referenced_escaped_reference_is_preserved(self): - r"""Escaped reference syntax inside referenced values keeps its backslash.""" + r"""Escaped reference syntax inside referenced values becomes the literal text post-render. + + Per `pydantic-handlebars >= 0.2.1` (and the Handlebars.js spec it + matches), the escape `\@{...}@` consumes the backslash and emits + the literal `@{...}@` in the output. The inner content is preserved + — just unescaped of its backslash — so callers can author "this + looks like a ref but render it literally" payloads. + """ resolve_fn = _make_resolve_fn({'ref': json.dumps(r'\@{not_a_ref}@')}) expanded, _ = expand_references('"@{ref}@"', 'my_var', resolve_fn) - assert json.loads(expanded) == r'\@{not_a_ref}@' + assert json.loads(expanded) == '@{not_a_ref}@' class TestExpandReferencesNativeHandlebarsSyntax: @@ -642,6 +657,40 @@ def test_escape_only_value_is_unescaped_consistently(self, config_kwargs: dict[s # by referencing `baz` once. assert baz.get().value == 'BAZ' + def test_backslash_run_parity_under_composition(self, config_kwargs: dict[str, Any]): + r"""Even-length backslash runs render the mustache; odd-length escape it. + + Regression for the bug exposed by pydantic-handlebars 0.2.1 — the + previous logfire-side `has_references` regex treated *any* preceding + backslash as the escape marker, so `\\@{x}@` (two backslashes) was + seen as "no refs" and rendered as-is. With 0.2.1's spec-compliant + renderer plus the simplified `'@{' in v` gate, both odd and even + runs route through the renderer and resolve per Handlebars.js rules: + + N backslashes contributes N // 2 literal `\` characters; parity + decides whether the mustache renders (even) or stays literal (odd). + """ + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + + lf.var(name='x', default='X', type=str) + + # 1 backslash → escape mustache, no literal backslash in output. + one = lf.var(name='one', default=r'\@{x}@', type=str) + assert one.get().value == '@{x}@' + + # 2 backslashes → one literal backslash, then mustache renders. + two = lf.var(name='two', default=r'\\@{x}@', type=str) + assert two.get().value == r'\X' + + # 3 backslashes → one literal backslash, then escape mustache. + three = lf.var(name='three', default=r'\\\@{x}@', type=str) + assert three.get().value == r'\@{x}@' + + # 4 backslashes → two literal backslashes, then mustache renders. + four = lf.var(name='four', default=r'\\\\@{x}@', type=str) + assert four.get().value == r'\\X' + def test_composition_exception_falls_back(self, config_kwargs: dict[str, Any], monkeypatch: pytest.MonkeyPatch): """Composition engine failures fall back to the code default.""" variables_config = _make_variables_config( diff --git a/uv.lock b/uv.lock index 6d0f45f75..40d427a6d 100644 --- a/uv.lock +++ b/uv.lock @@ -1210,7 +1210,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2517,7 +2517,7 @@ requires-dist = [ { name = "pydantic", marker = "extra == 'datasets'", specifier = ">=2" }, { name = "pydantic", marker = "extra == 'variables'", specifier = ">=2" }, { name = "pydantic-evals", marker = "extra == 'datasets'", specifier = ">=1.0.0" }, - { name = "pydantic-handlebars", marker = "extra == 'variables'", specifier = ">=0.2.0" }, + { name = "pydantic-handlebars", marker = "extra == 'variables'", specifier = ">=0.2.1" }, { name = "rich", specifier = ">=13.4.2" }, { name = "starlette", marker = "extra == 'gateway'", specifier = ">=0.37.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1" }, @@ -2595,7 +2595,7 @@ dev = [ { name = "pydantic", specifier = ">=2.11.0" }, { name = "pydantic-ai-slim", specifier = ">=0.0.39" }, { name = "pydantic-evals", specifier = ">=1.0.0" }, - { name = "pydantic-handlebars", specifier = ">=0.2.0" }, + { name = "pydantic-handlebars", specifier = ">=0.2.1" }, { name = "pymongo", specifier = ">=4.10.1" }, { name = "pymysql", specifier = ">=1.1.1" }, { name = "pyright", specifier = "!=1.1.407" }, @@ -4972,14 +4972,14 @@ wheels = [ [[package]] name = "pydantic-handlebars" -version = "0.2.0" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/a3/13b1f17648605d1872bbc6cc56f24d9a2f4151bbf0623b9f731282a061be/pydantic_handlebars-0.2.0.tar.gz", hash = "sha256:11ee67abddefcb624ede8c690bc0210248ac235a150d9423908a89630c9a4e98", size = 175652, upload-time = "2026-05-22T06:06:38.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/73/e55a1fe1a8788a5fa82d9209e796f4111e28f2d2fecab7173aa6d80516ad/pydantic_handlebars-0.2.1.tar.gz", hash = "sha256:d4124cfbf7d6e3bded9331a08ccccf6f29f3e3a93665b35b5d6061650aeeb49f", size = 176949, upload-time = "2026-05-25T01:24:38.354Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/f1/a27154170818efe3cb38af1eb54e0f7fc155873bd3b54f39a672a918e6cb/pydantic_handlebars-0.2.0-py3-none-any.whl", hash = "sha256:e5accc8ed0dc1bd953daa2eea2c0ee1eab7a6a27029da2439abacdf4ed46a4ae", size = 49954, upload-time = "2026-05-22T06:06:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/55/11/364bc401f1d8fdb3947079fc43ffdfbfc9132d065981a03a95d2e87440c4/pydantic_handlebars-0.2.1-py3-none-any.whl", hash = "sha256:c713427d6498cf4b66814447d54753a2748f8a8d3a9f00c194192ddb3df61e52", size = 50476, upload-time = "2026-05-25T01:24:37.104Z" }, ] [[package]]