Skip to content

Tier 1 fixes for #1954 (variable composition review feedback)#1962

Merged
dmontagu merged 13 commits into
feature/variable-composition-native-handlebarsfrom
feature/variable-composition-tier1-fixes
May 25, 2026
Merged

Tier 1 fixes for #1954 (variable composition review feedback)#1962
dmontagu merged 13 commits into
feature/variable-composition-native-handlebarsfrom
feature/variable-composition-tier1-fixes

Conversation

@dmontagu
Copy link
Copy Markdown
Contributor

Stacked on #1954. Four correctness fixes addressing review feedback Alex left on #1951 that's now folded into #1954.

1. Transitive reachability in the reference walker (r3288517914)

_check_reference_errors only iterated locally-registered variables when seeding the ref graph, so a missing reference reachable only via composition into a server-only fragment was never reported, and a cycle whose midpoints were server-only went silent. Refactored to walk the full graph with a FIFO queue from each local variable, pulling values out of VariablesConfig for any name reached. VariablesConfig is treated as the self-contained source of truth for substitution — @{name}@ whose name is in neither local registration nor server_config is the same kind of error as a name in nothing at all.

Two regression tests in test_push_variables.py:

  • test_compute_diff_reference_errors_through_server_only_chain — Alex's exact repro: local foo2 → server-only foo1 → missing foo3.
  • test_compute_diff_detects_cycle_through_server_only_chainfoo → server-only server_a → server-only server_bfoo.

2. Top-level context-override fast path (r3287492856 / r3289439477 / Devin r3286255835)

#1951 unified override / provider / code-default through _lookup_serialized, which added a dump_json / validate_json round trip on the override path. Unserializable overrides (e.g. var.override(some_object) with a non-JSON-friendly value) silently fell through to provider / code default. Inconsistent with the pre-#1951 typed-Python return, and inconsistent within the SDK itself — unserializable code defaults still take effect.

Added Variable._resolve_context_override and route to it from the top of _resolve before _lookup_serialized is consulted. Tries serialization (so render_fn can run for TemplateVariable); on failure, returns the user's value verbatim. The _lookup_serialized override branch stays in place for composition-reference overrides — composition substitutes into strings, so serialization is unavoidable there, and falling through on failure remains the right call per the design discussion.

New test in test_variables.py::TestVariableContext::test_override_unserializable_value_returned_typed.

3. Escape-only composition consistency (r3288986490)

_expand_and_deserialize short-circuited on has_references(value). The regex requires unescaped @{, so a value containing only \@{baz}@ produced no refs, was returned unchanged with its backslash; the same escape combined with a real @{ref}@ ran through composition (which does unescape) and produced literal @{baz}@. Behaviour depended on whether another reference was present.

Dropped the short-circuit. expand_references() already handles the no-refs case efficiently (early-return after _unescape_serialized), so both code paths now route through the same unescape logic.

New test in test_variable_composition.py::TestCompositionIntegration::test_escape_only_value_is_unescaped_consistently.

4. template_var() type inference (r3287772951)

template_var() required an explicit type= even when default was a non-callable. var() infers in that case; the inconsistency was easy to trip on when authoring docs. Added a no-type= overload mirroring var()'s pattern, with the same runtime check that a resolve-function default still requires an explicit type=.

Verification

442 variable / composition / template / push tests pass. Pyright + ruff format + ruff check clean.

Recommend merging this into #1954 (or once #1954 lands, retargeting to main) before continuing with Tier 2 (push-time validation improvements — local-default-vs-latest split, LabelRef reporting) and Tier 3 (#1958 docstring exception-name fixes).

Addresses four critical / important items Alex flagged on #1951 (now
folded into #1954):

## 1. Transitive reachability in the reference walker

`_check_reference_errors` previously only seeded its ref graph from
locally-registered variables. References reached transitively through
server-only fragments (the value is in `VariablesConfig` but the
variable isn't declared in code) were never followed, so cycles and
missing-ref errors that pass through a server-only node went silent.

Refactored to walk the full graph from each local variable with a FIFO
queue: every name visited contributes its outgoing edges to the graph,
and any name not already in `local ∪ server_config.variables` is
reported as `'@{name}@ does not exist'`. Treats `VariablesConfig` as
the self-contained source of truth for substitution, matching the
runtime behaviour. Alex r3288517914.

Two new tests in `test_push_variables.py`:
`test_compute_diff_reference_errors_through_server_only_chain` and
`test_compute_diff_detects_cycle_through_server_only_chain`.

## 2. Top-level context-override fast path

The #1951 unification of override / provider / code-default through
`_lookup_serialized` introduced a `dump_json` / `validate_json`
round-trip on the override path. Unserializable overrides
(e.g. `var.override(some_object)` where `some_object` doesn't survive
JSON) silently fell through to the provider / code default — a
behavioural regression vs. the pre-#1951 typed-Python return.

Added `Variable._resolve_context_override` and route to it from the
top of `_resolve` before `_lookup_serialized` is consulted: serialize
when possible (so `render_fn` can run for TemplateVariable) and return
the value verbatim otherwise. The `_lookup_serialized` override branch
stays in place for *composition-reference* overrides — composition
substitutes into strings, so serialization is unavoidable there;
falling through on failure remains the right call. Alex r3287492856 /
r3289439477, Devin r3286255835.

New test in `test_variables.py::TestVariableContext`:
`test_override_unserializable_value_returned_typed`.

## 3. Escape-only composition consistency

`_expand_and_deserialize` short-circuited on `has_references(value)`
being false. The `has_references` regex requires an *unescaped* `@{`,
so a value containing only `\@{baz}@` produced no refs, was returned
unchanged, and kept its backslash. The same escape combined with a
real `@{ref}@` ran through composition (which *does* unescape) and
produced literal `@{baz}@`. Observable behaviour depended on whether
another reference was present.

Dropped the short-circuit. `expand_references()` already handles the
no-refs case efficiently (early-return after `_unescape_serialized`),
so both code paths now route through the same unescape logic. Alex
r3288986490.

New test in `test_variable_composition.py::TestCompositionIntegration`:
`test_escape_only_value_is_unescaped_consistently`.

## 4. `template_var()` type inference

`template_var()` required an explicit `type=` argument even when
`default` was a non-callable. `var()` infers in that case; the
inconsistency tripped Alex when authoring docs. Added a no-`type=`
overload mirroring `var()`'s pattern, with the same runtime check
that a resolve-function `default` still requires an explicit `type=`.
Alex r3287772951.

## Test count

442 variable / composition / template / push tests pass; pyright +
ruff format + ruff check clean.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 6 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

dmontagu added 2 commits May 24, 2026 13:30
`_check_reference_errors`'s `missing_reported` dedup is unreachable —
each `(current, ref)` pair is unique per walk because `seen` gates
re-visiting `current` and `refs` is a set, so the same edge is never
yielded twice. Coverage flagged the branch and removing it simplifies
the loop body.

Add two test cases that exercise the new `template_var()` no-`type=`
overload: type inference from a non-callable default and the
resolve-function `TypeError` guard. These cover lines 2702–2707 in
`_internal/main.py`.
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).
Comment thread logfire/variables/_handlebars.py Outdated
Comment thread logfire/variables/_handlebars.py
Comment thread logfire/variables/_handlebars.py
Four items from Alex (r3295454297 / r3295457538 / r3295460481 /
r3295461517):

## Collapse the repeated try/except import dance

Four functions (`get_environment`, `get_handlebars_renderer`,
`_extract_dependencies_fn`, `_get_template_compatibility_checker`) each
carried their own 5-line `try: from pydantic_handlebars import …
except ModuleNotFoundError as exc: if exc.name == … raise … from exc`
block. Replaced with a single cached `_pydantic_handlebars()` accessor
that imports the *whole module* once and returns it; every other
function reaches in via attribute access. Python's own import machinery
caches modules — wrapping each attribute import in a separate
`@cache`'d helper was dead weight Alex called out as eye-hurting.

## Drop `pragma: no cover`

With the dep-error path consolidated into one branch, replaced the
subprocess-only `test_import_logfire_without_pydantic_handlebars` (which
runs under a subprocess and so doesn't contribute to coverage) with an
in-process companion `test_missing_handlebars_raises_helpful_error` that
clears the `_pydantic_handlebars` cache, blocks the import via
`__import__` monkeypatch, and asserts the error. Subprocess test stays
as the integration-level guarantee.

Also removed the defensive `exc.name == 'pydantic_handlebars'` discriminator
— pydantic-handlebars is a small package with minimal deps, and a
transitive ModuleNotFoundError there is pathological enough that surfacing
the dep-error message is acceptable. That eliminates the only branch
that genuinely couldn't be tested.

## LRU-cache runtime `{{...}}` template compilation

New `compile_runtime_template(source)` mirrors
`compile_composition_template(source)`, both `@lru_cache(maxsize=1024)`.
`render_serialized_string` in `abstract.py` now uses it instead of the
module-level `pydantic_handlebars.render`, which re-parses every call.
`get_safe_string_cls()` replaces the SafeString half of the old
`get_handlebars_renderer` return tuple — `_wrap_safe_context` calls it
once per render.

## LRU-cache extract_composition_dependencies

Promoted `extract_composition_dependencies` itself to
`@lru_cache(maxsize=1024)`. Cycle and reference validation walk the
same template strings repeatedly during push / sync.

Coverage on `logfire/variables/_handlebars.py` is now 100% line + branch.
445 variable / composition / template / push tests pass.
cubic-dev-ai[bot]

This comment was marked as resolved.

dmontagu added 3 commits May 24, 2026 18:26
Cubic r3295580826: dropping the `exc.name == 'pydantic_handlebars'`
check meant a missing transitive dependency would get reframed as
"install pydantic-handlebars", hiding the real failure. Valid concern.

Restored the check. Also added a new in-process test
`test_unrelated_module_not_found_propagates` that monkeypatches
`__import__` to raise `ModuleNotFoundError(name='some_other_dep')` from
inside `pydantic_handlebars` and asserts the raw error propagates (not
wrapped as `HandlebarsDependencyError`).

Both branches of the discriminator are now exercised in-process and
under coverage, so the file stays at 100% line + branch with no
`# pragma: no cover` markers.
The "Cycle Detection" section only mentioned push-time validation — but
the runtime resolution model is the bit that differs most from plain
Handlebars and is worth calling out explicitly. Replaced with two
sections:

- **Recursive Resolution** — composition isn't a one-shot substitution;
  referenced variables go through full resolution (including their own
  `@{...}@`) before substitution. Shows a `parent → middle → leaf`
  example and notes that `composed_from` mirrors the tree.

- **Cycle and depth guards** — describes the two-layer defence:
  `variables_validate` / `push(strict=True)` at sync time (catches
  cycles whose midpoints are server-only too), and `MAX_COMPOSITION_DEPTH`
  / visited-set at runtime with fall-back to code default + `RuntimeWarning`.
  Calls out that an unresolvable `@{ref}@` is treated as a real failure
  (unlike a missing `{{field}}` which silently substitutes empty), and
  includes a snippet showing how to detect via
  `resolved.exception` / `resolved.reason`.
The previous wording mentioned the difference at the end as a "main
semantic difference" line. Promote it: lead with a warning admonition
that spells out the plain-Handlebars behaviour (one-shot string
substitution; inner {{...}} not re-rendered) before showing what
composition does instead. Add a contrast snippet using
`pydantic_handlebars.render` directly to make the difference
concrete — `{{greeting}}` substituting a `'Hello, {{name}}!'` value
emits `'Hello, {{name}}!'` verbatim, no recursive render.

This is a common Handlebars-newcomer footgun; calling it out
explicitly should save people the "wait why isn't this working" loop.
dmontagu added 5 commits May 24, 2026 19:48
Two issues David called out:

1. The "trips up people coming from normal Handlebars" wording was
   presumptuous — this is a new SDK with new users; there's no
   pre-existing audience to "trip up". Removed the line; the prose
   above it already explains the difference plainly.

2. The composition / template-and-composition section had five
   `skip="true"` examples. Skipping is how docs rot — converted all
   five to runnable snippets with `#>` output assertions so
   pytest-examples actually executes them:

   - Composition Tracking — uses a small `report` / `city` pair to
     show `composed_from` entries with `name`, `value`, `reason`.
   - Combining Templates and Composition — minor reshape to use
     `#>` output instead of trailing comments; runs end-to-end.
   - Recursive Resolution — `parent → middle → leaf` example now
     runs, and prints the `composed_from` chain shape.
   - Plain-Handlebars contrast — direct `pydantic_handlebars.render`
     call demonstrating that `{{name}}` inside a value is not
     re-rendered.
   - Cycle-and-depth runtime guard — registers a small
     `cycle_left` / `cycle_right` pair, calls `get()`, asserts
     `resolved.reason == 'other_error'` and the warning fires.

All 8 snippets in the page now run under `pytest tests/test_docs.py -k
templates-and-composition`. mkdocs build clean.
`from collections import deque` (stdlib), `from pydantic import
TypeAdapter, ValidationError` (hard dep, not circular), and
`from logfire.variables._handlebars import compile_runtime_template,
get_safe_string_cls` (`_handlebars.py` doesn't import abstract) all
moved to module-level. There was no good reason for them to be lazy —
no circular import, no optional dependency at the call site, no
startup-cost concern. Local-imports-in-function-bodies are a cost in
readability; pay it only when there's a real reason.

The remaining local imports are intentional: every
`from logfire.variables.{composition,config,variable} import …` is
inside a function because those modules import `from
logfire.variables.abstract import …` at their top, so hoisting them
would create an import cycle. `import logfire` in
`ResolvedVariable.__enter__` is similarly circular. Left alone.

Net: -7 redundant in-function import lines, no behaviour change. 447
variable / composition / template / push tests pass; pyright + ruff
clean.
Two places were silent on how `Variable.override(...)` interacts with
composition and template rendering — important behaviour to spell out,
not least because the pre-#1962 silent-fallthrough bug was visible to
users without any docs to refer to.

## `override()` docstring (`variable.py`)

Expanded the one-sentence summary into three explicit sections:

- **Composition is skipped** — overriding with `'hi @{user}@'` yields
  the literal string; `@{user}@` is not expanded. Reasoning: overrides
  are the user's literal choice, not a template for the composition
  graph to resolve.
- **Template rendering still applies to `TemplateVariable`** — as long
  as the override value is JSON-serializable, `{{...}}` rendering
  against the runtime `inputs` happens the same way as for a provider
  value.
- **Unserializable overrides come back verbatim** — no
  serialize/deserialize round-trip, no render pass; matches the
  "literal user choice" intent (and is the pre-#1951 behaviour
  restored).

## User docs (`configuration-reference.md`)

Added a `## Overrides and composition` subsection under "Contextual
Overrides" with the same two-bullet summary and a runnable snippet
demonstrating both states — composition expanding `@{user}@` from the
default vs. override returning `'Hi @{user}@'` literally. Runs under
`pytest tests/test_docs.py`.
The hoist of `from pydantic import TypeAdapter, ValidationError` to
module scope in 1d3504a broke `import logfire` on Pyodide. Pydantic
is only pulled in by the `[variables]` extra, not base logfire, so
`abstract.py` must be importable without it. The pyodide_test harness
(`pyodide_test/test.mjs`) does exactly that — `await
micropip.install(['file:logfire-…whl'])` followed by `import logfire`
with no pydantic — and that import failed at the top-level
`from pydantic import TypeAdapter, ValidationError`.

Reverted:

- `TypeAdapter` back to TYPE_CHECKING (annotation-only at module
  scope; `from __future__ import annotations` keeps it string-only
  at runtime) and a local import in `push_variable_types` where it's
  called at runtime.
- `ValidationError` back to function-local imports in
  `_check_label_compatibility` and `_check_type_label_compatibility`.
- Added a short comment on the TYPE_CHECKING block explaining why
  pydantic stays lazy.

`deque` and the `_handlebars` accessors stay at module scope —
`collections` is stdlib, and `_handlebars.py` is import-safe without
pydantic-handlebars (the try/except at its top handles that).

Verified locally: `cd pyodide_test && node --experimental-wasm-stack-switching test.mjs`
passes (`Logfire Pyodide tests passed 🎉`).
@cubic-dev-ai
Copy link
Copy Markdown
Contributor

cubic-dev-ai Bot commented May 25, 2026

You're iterating quickly on this pull request. To help protect your rate limits, cubic has paused automatic reviews on new pushes for now—when you're ready for another review, comment @cubic-dev-ai review.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@dmontagu dmontagu merged commit de12509 into feature/variable-composition-native-handlebars May 25, 2026
16 checks passed
@dmontagu dmontagu deleted the feature/variable-composition-tier1-fixes branch May 25, 2026 03:06
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