Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,16 @@ When `safety_rules` is updated in the Logfire UI, all variables that reference `

### Composition Control Flow

The `@{}@` syntax supports a small Handlebars-compatible subset for composing variables. It supports simple references, dotted field reads, and block helpers that branch or iterate over a top-level referenced variable:
The `@{}@` syntax runs through the full Handlebars engine (just with `@{` / `}@` as the delimiter pair instead of the default `{{` / `}}`), so any expression form that works in Handlebars also works here — simple references, dotted field reads, block helpers, and helper sub-expressions:

| Syntax | Description |
|--------|-------------|
| `@{variable_name}@` | Insert a variable's value |
| `@{variable.field}@` | Access a nested field |
| `@{#if variable}@...@{else}@...@{/if}@` | Conditional on whether a variable is set |
| `@{#if user.active}@...@{/if}@` | Conditional on a dotted field |
| `@{#each items}@...@{/each}@` | Iterate over a list variable |

Block helper conditions and iterables must be top-level variable names. Use `@{#if user}@...@{user.active}@...@{/if}@` rather than `@{#if user.active}@`.
| `@{#each items}@@{../top}@@{/each}@` | Access an outer-scope value from inside a block |

### Composition Tracking

Expand Down
44 changes: 39 additions & 5 deletions logfire/_internal/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2622,6 +2622,17 @@ def var(

return variable

@overload
def template_var(
self,
name: str,
*,
default: T,
inputs_type: type[InputsT],
description: str | None = None,
) -> TemplateVariable[T, InputsT]: ...

@overload
def template_var(
self,
name: str,
Expand All @@ -2630,6 +2641,16 @@ def template_var(
default: T | ResolveFunction[T],
inputs_type: type[InputsT],
description: str | None = None,
) -> TemplateVariable[T, InputsT]: ...

def template_var(
self,
name: str,
*,
type: type[T] | None = None,
default: T | ResolveFunction[T],
inputs_type: type[InputsT],
description: str | None = None,
) -> TemplateVariable[T, InputsT]:
"""Define a managed template variable with integrated rendering.

Expand All @@ -2652,7 +2673,6 @@ class PromptInputs(BaseModel):

prompt = logfire.template_var(
'system_prompt',
type=str,
default='Hello {{user_name}}',
inputs_type=PromptInputs,
)
Expand All @@ -2663,16 +2683,30 @@ class PromptInputs(BaseModel):

Args:
name: Unique identifier for the variable.
type: Expected type for validation and JSON schema generation.
type: Expected type for validation and JSON schema generation. Can be a primitive
type or a Pydantic model. If not provided, the type is inferred from `default`.
Required when `default` is a resolve function.
default: Default value used when no remote configuration is found.
Can also be a callable with `targeting_key` and `attributes` parameters.
When `type` is not provided, the type is inferred from this value.
Can also be a callable with `targeting_key` and `attributes` parameters
(requires `type` to be set explicitly).
inputs_type: The type (typically a Pydantic `BaseModel`) describing the expected
template inputs. Used for type-safe `get(inputs)` calls and JSON schema generation.
description: Optional human-readable description of what the variable controls.
"""
import re

from logfire.variables.variable import TemplateVariable
from logfire.variables.variable import TemplateVariable, is_resolve_function

if type is None:
if is_resolve_function(default):
raise TypeError(
'When `default` is a resolve function (callable with targeting_key and attributes parameters), '
'`type` must be provided to specify the variable value type.'
)
tp = cast(Type[T], default.__class__) # noqa UP006
else:
tp = type

if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
raise ValueError(
Expand All @@ -2692,7 +2726,7 @@ class PromptInputs(BaseModel):

variable = TemplateVariable[T, InputsT](
name,
type=type,
type=tp,
default=default,
inputs_type=inputs_type,
description=description,
Expand Down
37 changes: 29 additions & 8 deletions logfire/variables/_handlebars.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from __future__ import annotations

from collections.abc import Callable
from functools import cache
from functools import cache, lru_cache
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from pydantic_handlebars import HandlebarsEnvironment
from pydantic_handlebars import CompiledTemplate, HandlebarsEnvironment


# The reference-syntax composition pass consumes ``@{...}@`` placeholders and
Expand Down Expand Up @@ -72,20 +72,41 @@ def get_handlebars_renderer() -> tuple[type[str], Callable[..., str]]:
return SafeString, render


def extract_composition_dependencies(template: str) -> set[str]:
"""Return the top-level `@{name}@` references in *template*.
@lru_cache(maxsize=1024)
def compile_composition_template(source: str) -> CompiledTemplate:
Comment thread
dmontagu marked this conversation as resolved.
"""Return a cached `CompiledTemplate` for *source* compiled with composition delimiters.

Delegates to `pydantic_handlebars.extract_dependencies` configured for
the composition delimiters, so block helpers / dotted paths / etc. are
handled AST-correctly.
Managed-variable values are typically stable across many resolutions —
the same `@{...}@` template is rendered once per `get()` call. Caching
the parsed program avoids reparsing each time
`HandlebarsEnvironment.render(source, context)` is called (which by
itself does the parse on every call). 1024 is large enough for any
realistic number of distinct templates in a single process while
staying bounded for long-running workers.
"""
return get_environment().compile(source)


@cache
def _extract_dependencies_fn() -> Callable[..., set[str]]:
"""Cached lookup of `pydantic_handlebars.extract_dependencies`."""
try:
from pydantic_handlebars import extract_dependencies
except ModuleNotFoundError as exc: # pragma: no cover
if exc.name == 'pydantic_handlebars':
raise _dependency_error() from exc
raise
Comment thread
dmontagu marked this conversation as resolved.
Outdated
return extract_dependencies(template, open_delim=COMPOSITION_OPEN_DELIM, close_delim=COMPOSITION_CLOSE_DELIM)
return extract_dependencies


def extract_composition_dependencies(template: str) -> set[str]:
Comment thread
dmontagu marked this conversation as resolved.
"""Return the top-level `@{name}@` references in *template*.

Delegates to `pydantic_handlebars.extract_dependencies` configured for
the composition delimiters, so block helpers / dotted paths / etc. are
handled AST-correctly.
"""
return _extract_dependencies_fn()(template, open_delim=COMPOSITION_OPEN_DELIM, close_delim=COMPOSITION_CLOSE_DELIM)


@cache
Expand Down
76 changes: 52 additions & 24 deletions logfire/variables/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,49 +536,78 @@ def _check_reference_errors(
) -> list[str]:
"""Check for reference errors: non-existent refs and cycles.

Scans local variable defaults and server label values for @{references}@
and validates that referenced variables exist and there are no cycles.
Walks the full composition graph starting from each locally-registered
variable, transitively following `@{ref}@` edges into server-only
variables — so a missing reference reachable only through a chain that
passes through a server-only node still surfaces, as does a cycle whose
midpoints are server-only.

`VariablesConfig` is treated as self-contained for substitution: any
`@{name}@` whose `name` isn't in either the local registration set or
`server_config` is reported as a non-existent reference, the same way
a registration miss is.
"""
from logfire.variables.composition import find_references
from logfire.variables.config import LabeledValue
from logfire.variables.variable import is_resolve_function

warnings_list: list[str] = []

# Collect all known variable names (local + server)
all_names: set[str] = {v.name for v in variables} | set(server_config.variables.keys())
locals_by_name = {v.name: v for v in variables}

# Build a reference graph: variable_name -> set of referenced names
ref_graph: dict[str, set[str]] = {}
def _refs_of(name: str) -> set[str]:
"""Collect refs from every serialized value reachable for *name*.

# Scan local variable defaults for references
for variable in variables:
That's the local code default (if registered locally) plus every
labeled server value plus the `latest_version`. Failures to
serialize the local default are tolerated — we want the walker to
keep going.
"""
refs: set[str] = set()
if not is_resolve_function(variable.default):
local = locals_by_name.get(name)
if local is not None and not is_resolve_function(local.default):
try:
serialized_default = variable.type_adapter.dump_json(variable.default).decode('utf-8')
serialized_default = local.type_adapter.dump_json(local.default).decode('utf-8')
refs.update(find_references(serialized_default))
except Exception:
pass

# Also scan server label values for this variable
server_var = server_config.variables.get(variable.name)
server_var = server_config.variables.get(name)
if server_var is not None:
for _, labeled_value in server_var.labels.items():
if isinstance(labeled_value, LabeledValue):
refs.update(find_references(labeled_value.serialized_value))
for labeled in server_var.labels.values():
if isinstance(labeled, LabeledValue):
refs.update(find_references(labeled.serialized_value))
if server_var.latest_version is not None:
refs.update(find_references(server_var.latest_version.serialized_value))
return refs

if refs:
ref_graph[variable.name] = refs

# Check for non-existent references
for ref_name in refs:
if ref_name not in all_names:
warnings_list.append(f"Variable '{variable.name}' references '@{{{ref_name}}}@' which does not exist.")
# BFS the composition graph from every local variable in declaration
# order. Each node we visit contributes its outgoing edges to
# `ref_graph` and, if any point at an unknown name, a
# non-existent-reference warning. Visited names are gated on `seen` so
# a shared sub-tree is walked once.
from collections import deque

# Check for cycles using DFS
ref_graph: dict[str, set[str]] = {}
seen: set[str] = set()
frontier: deque[str] = deque(v.name for v in variables)
while frontier:
current = frontier.popleft()
if current in seen:
continue
seen.add(current)
refs = _refs_of(current)
if refs:
ref_graph[current] = refs
for ref in refs:
if ref not in all_names:
warnings_list.append(f"Variable '{current}' references '@{{{ref}}}@' which does not exist.")
elif ref not in seen:
frontier.append(ref)

# Cycle detection on the assembled graph. Because the graph includes
# nodes reached transitively through server-only variables, cycles
# whose midpoints are server-only are now caught too.
def _detect_cycles(graph: dict[str, set[str]]) -> list[list[str]]:
cycles: list[list[str]] = []
visited: set[str] = set()
Expand All @@ -587,7 +616,6 @@ def _detect_cycles(graph: dict[str, set[str]]) -> list[list[str]]:

def dfs(node: str) -> None:
if node in in_stack:
# Found a cycle - extract it
cycle_start = path.index(node)
cycles.append(path[cycle_start:] + [node])
return
Expand Down
21 changes: 15 additions & 6 deletions logfire/variables/composition.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Variable composition: expand `@{variable_name}@` references in serialized values.

This module provides pure functions for expanding variable references in serialized
JSON strings. References use the `@{variable_name}@` syntax and are expanded using
a Handlebars-compatible subset: simple references, dotted field reads, and block
helpers whose condition/iterable is a top-level referenced variable.
JSON strings. References use the `@{variable_name}@` syntax and are expanded by
running the value through `pydantic_handlebars` with the composition delimiter
pair, so the full Handlebars syntax is available: simple references, dotted
field reads, block helpers (including with dotted or sub-expression headers like
`@{#if user.active}@`), and helper sub-expressions.

Meanwhile, any `{{runtime}}` placeholders are preserved untouched for later
template rendering.
Expand Down Expand Up @@ -36,6 +38,12 @@
# 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'(?<!\\)@\{')

# Dotted-reference matcher used by the unresolved-reference protection
Expand Down Expand Up @@ -98,9 +106,10 @@ def expand_references(
) -> tuple[str, list[ComposedReference]]:
"""Expand `@{var}@` references in a serialized variable value.

Uses the Handlebars engine so that `@{}@` supports simple references,
dotted field reads, and block helpers whose condition/iterable is a
top-level referenced variable while preserving `{{runtime}}` placeholders
Uses the Handlebars engine so `@{}@` supports the full Handlebars
syntax — simple references, dotted field reads, block helpers (including
with dotted or sub-expression headers like `@{#if user.active}@`), and
helper sub-expressions — while preserving `{{runtime}}` placeholders
untouched.

Args:
Expand Down
8 changes: 6 additions & 2 deletions logfire/variables/reference_syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from typing import Any

from logfire.variables._handlebars import get_environment
from logfire.variables._handlebars import compile_composition_template


def render_once(template: str, context: dict[str, Any]) -> str:
Expand All @@ -25,5 +25,9 @@ def render_once(template: str, context: dict[str, Any]) -> str:
`{{...}}` runtime placeholders in *template* are not touched — they
are plain content under the configured delimiters. The escape
sequence `\@{` produces a literal `@{` in the output.

Compiled templates are cached
(`compile_composition_template` is an `lru_cache`) so resolving the
same managed-variable value repeatedly doesn't re-parse the source.
"""
return get_environment().render(template, context)
return compile_composition_template(template).render(context)
Loading
Loading