Skip to content

Composition and templating with native handlebars#1954

Open
alexmojaki wants to merge 13 commits into
mainfrom
feature/variable-composition-native-handlebars
Open

Composition and templating with native handlebars#1954
alexmojaki wants to merge 13 commits into
mainfrom
feature/variable-composition-native-handlebars

Conversation

@alexmojaki
Copy link
Copy Markdown
Collaborator

#1951 combined with #1952, i.e. #1952 directly against main, since I think that makes for cleaner review instead of the stack.

dmontagu added 4 commits May 21, 2026 17:35
Managed variables can now reference other variables and render
Handlebars templates against typed inputs.

Composition: `@{variable_name}@` references in serialized variable
values are expanded during resolution, before deserialization. The
resolver walks a small Handlebars-compatible subset of `@{}@` block
helpers (top-level `#if`/`#each`) and supports dotted-path access
(`@{user.name}@`). Resolution priority is shared between top-level
variables and child references via `_BaseVariable._lookup_serialized`:
context override → provider → registered code default. Reference
cycles, missing references, and depth limits surface through
`ComposedReference.error` and propagate as a single composition
warning at resolution time, with the resolver falling back to the
code default. Each resolved variable also carries a `composed_from`
trail (with reason/label/version per ref) onto the span attributes.

Template rendering: a new `logfire.template_var()` registers a
`TemplateVariable[T, InputsT]` whose `.get(inputs)` resolves the
variable, expands `@{ref}@` references, renders any `{{placeholder}}`
expressions using `pydantic_handlebars`, and deserialises to the
declared type. `inputs_type` generates a `template_inputs_schema`
that is included in the variable config and synced to the server.
`pydantic-handlebars` is an optional dependency installed via the
`logfire[variables]` extra on Python 3.10+; calling `template_var()`
without it raises immediately rather than silently degrading.

`Variable` is now a thin subclass of an internal `_BaseVariable`
that holds the shared resolution pipeline. The base class carries
no template-related surface area; `TemplateVariable` overrides
`to_config` to attach `template_inputs_schema`, and external diff/
sync code gates on `isinstance(variable, TemplateVariable)` via the
`get_template_inputs_schema(variable)` helper.

Write-time validation lives in `logfire.variables.template_validation`
and uses `pydantic_handlebars.check_template_compatibility` to detect
undeclared `{{field}}` references across the composition graph. Cycle
detection on the reference graph is also exposed for push-time use.
- Adds `docs/reference/advanced/managed-variables/templates-and-composition.md`
  covering Handlebars `{{placeholder}}` rendering via `logfire.template_var()`
  and `@{variable_name}@` composition references, including structured
  values, cycle detection, and how templates and composition combine.
- Expands the managed-variables index with a templates intro and links
  to the new page.
- Mentions `template_inputs_schema` and the `[variables]` extra in the
  configuration reference and nav.
- Adds `examples/python/variable_composition_demo.py` exercising
  composition, structured variables, template inputs, and
  composition-time conditionals end-to-end.
- Skips Python doc examples whose source mentions `logfire.template_var`
  when `pydantic-handlebars` is unavailable (matches the runtime
  requirement on Python 3.9).
Replaces the regex-and-translate workaround in reference_syntax.py and
composition.py with calls into pydantic-handlebars >= 0.2.0's native
configurable-delimiter API.

## What changed

- pyproject.toml: bump `pydantic-handlebars` to `>=0.2.0` (in both the
  `[variables]` extra and the dev group) and exempt it from the
  `exclude-newer` filter alongside the other Pydantic packages.
- `_handlebars.get_environment()` returns a cached
  `HandlebarsEnvironment(open_delim='@{', close_delim='}@')` for the
  composition pass. `extract_composition_dependencies()` wraps
  `pydantic_handlebars.extract_dependencies` with the same delimiters.
- `reference_syntax.render_once()` shrinks to a one-line call into that
  environment. The sentinel-protect-then-regex-translate code path is
  gone; `{{...}}` runtime placeholders survive because they're plain
  content under the configured delimiters.
- `composition.find_references()` / `_collect_ref_names()` now route
  through `extract_composition_dependencies` for AST-correct detection,
  then a textual position scan supplies first-occurrence ordering.

## What you can now write

`@{...}@` accepts the full Handlebars syntax — block helpers with
dotted-path headers, `{{#each}}` parent-context references (`../`),
helper subexpressions, the lot. Previously the regex translator only
covered top-level identifiers in block headers, so e.g.
`@{#if user.active}@` silently dropped its condition. New regression
tests cover these in `TestExpandReferencesNativeHandlebarsSyntax` and
`TestFindReferencesNativeHandlebarsSyntax`.

The unresolved-dotted-reference protection in `_render_value` is kept
for behaviour compat (literal `@{name.field}@` is retained in the
output when `name` can't be resolved). That's the
"misrender retention" parity question — addressed separately.
- Use exact, trimmed equality against 'null' in expand_references
  rather than a case-insensitive startswith match. JSON null is
  always lowercase, and 'nullify' shouldn't have matched.
- Add a test that exercises the 'dotted ref whose base is resolved'
  branch in _protect_unresolved_dotted_refs, which the regex
  narrowing in this PR left uncovered.
devin-ai-integration[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

Comment thread logfire/variables/composition.py Outdated
dmontagu added a commit that referenced this pull request May 24, 2026
Six items Alex flagged as important-but-not-blocking on the original
1951/1952 stack, folded in before #1954 merges:

## Docs

- `composition.py` module docstring + `expand_references` docstring no
  longer claim block helpers are restricted to top-level identifiers —
  the native handlebars path accepts `@{#if user.active}@` and helper
  sub-expression headers.
- `docs/.../templates-and-composition.md` Control Flow section rewritten
  to match: drops the "must be top-level" caveat and adds rows for
  dotted-path conditions and `../` parent-scope access from inside a
  block.

## Caching

- New `compile_composition_template(source)` in `_handlebars` wraps the
  parse step in an `lru_cache(maxsize=1024)` (#1952 r3289095058).
  `reference_syntax.render_once` now compiles once per distinct source
  and reuses the compiled program across resolutions — managed-variable
  values are typically stable, so the hit rate should be high.
- `extract_composition_dependencies` no longer re-runs the
  `try: from pydantic_handlebars import …` block on every call — the
  import is moved into a `@cache`d helper that returns the function
  object directly. Matches the pattern of the existing
  `_get_template_compatibility_checker` (#1952 r3288194502).

## Warning text

- `_composition_failure` renamed to `_fallback_to_default` and takes a
  `failure_stage` argument (`'composition'` or `'template rendering'`)
  so the `RuntimeWarning` text reflects the actual failed step. A
  pydantic-handlebars parse error during `{{...}}` rendering used to
  surface as `"composition failed"` even though composition succeeded
  (codex finding).
- Updated `test_remote_render_error_records_exception` accordingly.

## Test naming

- `test_override_render_failure_falls_back` renamed its locals:
  `bad_override` → `templated_config` (it's a *valid* template), and
  introduced `invalid_inputs` for the inputs that actually fail the
  pattern constraint. Alex r3289312130.

## Escape-detection coupling

- Added a comment on the `_HAS_REFERENCE` regex flagging that the
  lookbehind encodes pydantic-handlebars' current escape semantics
  ("any preceding `\` escapes") and noting it'll need to count
  preceding backslashes if pydantic-handlebars adopts Handlebars.js's
  odd-vs-even-backslash spec behaviour (#1952 r3289062247).
cubic-dev-ai[bot]

This comment was marked as resolved.

templated_config = Config.model_construct(code='{{code}}')
invalid_inputs = Inputs(code='abc123')
with var.override(templated_config):
resolved = var.get(invalid_inputs)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point about the warning in #1951 (comment) remains

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants