Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 29 additions & 30 deletions logfire/variables/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'(?<!\\)@\{')
# Cheap gate used to skip strings with no composition syntax at all. Any
# `@{` (even escaped) routes through pydantic-handlebars, which is where the
# escape semantics live — under `pydantic-handlebars >= 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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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('\\\\@{', '@{')
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = [
Expand Down
57 changes: 53 additions & 4 deletions tests/test_variable_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -495,10 +503,17 @@ def test_referenced_html_entities_are_preserved(self):
assert json.loads(expanded) == 'literal &#123; and &#125;'

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:
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 6 additions & 6 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading