From 9d5a27ef7a13accbfe9dcf4ee0e41d5c8e888cab Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:51:18 -0700 Subject: [PATCH 01/40] Add variable composition: Handlebars template rendering, <> reference expansion, and template validation --- .../configuration-reference.md | 3 +- .../advanced/managed-variables/index.md | 41 +- .../templates-and-composition.md | 263 +++++++ examples/python/variable_composition_demo.py | 613 +++++++++++++++ logfire-api/logfire_api/_internal/main.pyi | 4 +- logfire/__init__.py | 2 + logfire/_internal/integrations/pytest.py | 7 +- logfire/_internal/main.py | 127 +++- logfire/variables/__init__.py | 8 + logfire/variables/abstract.py | 299 +++++++- logfire/variables/angle_bracket.py | 69 ++ logfire/variables/composition.py | 336 +++++++++ logfire/variables/config.py | 16 +- logfire/variables/template_validation.py | 206 +++++ logfire/variables/variable.py | 462 +++++++++--- mkdocs.yml | 1 + pyproject.toml | 3 +- tests/test_template_validation.py | 577 ++++++++++++++ tests/test_variable_composition.py | 705 ++++++++++++++++++ tests/test_variable_templates.py | 554 ++++++++++++++ uv.lock | 16 + 21 files changed, 4172 insertions(+), 140 deletions(-) create mode 100644 docs/reference/advanced/managed-variables/templates-and-composition.md create mode 100644 examples/python/variable_composition_demo.py create mode 100644 logfire/variables/angle_bracket.py create mode 100644 logfire/variables/composition.py create mode 100644 logfire/variables/template_validation.py create mode 100644 tests/test_template_validation.py create mode 100644 tests/test_variable_composition.py create mode 100644 tests/test_variable_templates.py diff --git a/docs/reference/advanced/managed-variables/configuration-reference.md b/docs/reference/advanced/managed-variables/configuration-reference.md index f5b9fc6c7..bb1c44dee 100644 --- a/docs/reference/advanced/managed-variables/configuration-reference.md +++ b/docs/reference/advanced/managed-variables/configuration-reference.md @@ -14,7 +14,8 @@ | `json_schema` | JSON Schema for validation (optional) | | `description` | Human-readable description (optional) | | `aliases` | Alternative names that resolve to this variable (optional, for migrations) | -| `example` | JSON-serialized example value, used as template in UI (optional) | +| `example` | JSON-serialized example value, used as starting point when creating versions in the UI (optional) | +| `template_inputs_schema` | JSON Schema for template `{{placeholder}}` inputs (optional, set automatically by `logfire.template_var()`) | **LabeledValue** — A label with an inline serialized value: diff --git a/docs/reference/advanced/managed-variables/index.md b/docs/reference/advanced/managed-variables/index.md index b2fec38f7..3cb578a1c 100644 --- a/docs/reference/advanced/managed-variables/index.md +++ b/docs/reference/advanced/managed-variables/index.md @@ -14,6 +14,7 @@ Managed variables are a way to externalize runtime configuration from your code. - **Observability-integrated**: Every variable resolution creates a span, and using the context manager automatically sets baggage so downstream operations are tagged with which label and version was used - **Versions and labels**: Create immutable version snapshots of your variable's value, and assign labels (like `production`, `staging`, `canary`) that point to specific versions - **Rollouts and targeting**: Control what percentage of requests receive each labeled version, and route specific users or segments based on attributes +- **Templates and composition**: Use `{{placeholder}}` Handlebars syntax in values that get rendered with runtime inputs, and compose variables from reusable fragments via `<>` references (see [Templates and Composition](templates-and-composition.md)) ### Versions and Labels @@ -112,6 +113,36 @@ With managed variables, you can iterate safely in production: - **Instant rollback**: If a version is causing problems, move the label back to the previous version in seconds, with no deploy required - **Full history**: Every version is immutable and preserved, so you can always see exactly what was served and when +## Template Variables + +For AI applications, variables often contain prompt templates with placeholders that get filled in at runtime. **Template variables** support this natively with Handlebars `{{placeholder}}` syntax: + +```python skip="true" +from pydantic import BaseModel + +import logfire + + +class PromptInputs(BaseModel): + user_name: str + is_premium: bool = False + + +prompt = logfire.template_var( + 'system_prompt', + type=str, + default='Hello {{user_name}}!{{#if is_premium}} Welcome back, valued member.{{/if}}', + inputs_type=PromptInputs, +) + +with prompt.get(PromptInputs(user_name='Alice', is_premium=True)) as resolved: + print(resolved.value) # "Hello Alice! Welcome back, valued member." +``` + +Variables can also reference other variables using `<>` syntax, allowing you to compose values from reusable fragments that can be independently updated in the UI. + +For full details, see [Templates and Composition](templates-and-composition.md). + ## How It Works Here's the typical workflow using the `AgentConfig` example from above: @@ -231,8 +262,10 @@ This bypasses the rollout weights and directly resolves the value from the speci ### Variable Parameters -| Parameter | Description | -|-----------|-------------------------------------------------------------------------| -| `name` | Unique identifier for the variable | +| Parameter | Description | +|-----------|-------------| +| `name` | Unique identifier for the variable | | `type` | Expected type for validation; can be a primitive type or Pydantic model | -| `default` | Default value when no configuration is found (can also be a function) | +| `default` | Default value when no configuration is found (can also be a function) | + +For variables with Handlebars template rendering, use `logfire.template_var()` instead, which adds an `inputs_type` parameter. See [Templates and Composition](templates-and-composition.md). diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md new file mode 100644 index 000000000..b2b16084f --- /dev/null +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -0,0 +1,263 @@ +# Template Variables and Composition + +Managed variables can contain **Handlebars templates** (`{{placeholder}}`) and **composition references** (`<>`), enabling dynamic values that are assembled from multiple sources and rendered with runtime inputs. + +This is especially useful for AI applications where prompts are built from reusable fragments and personalized with request-specific data. + +## Template Variables + +A **template variable** is a variable whose value contains `{{placeholder}}` expressions that are rendered with typed inputs at resolution time. Define one with `logfire.template_var()`: + +```python +from pydantic import BaseModel + +import logfire + +logfire.configure() + + +class PromptInputs(BaseModel): + user_name: str + is_premium: bool = False + + +prompt = logfire.template_var( + 'system_prompt', + type=str, + default='Hello {{user_name}}! Welcome to our service.', + inputs_type=PromptInputs, +) +``` + +When you call `.get()`, you pass an instance of the inputs type. The SDK renders all `{{placeholder}}` expressions in the resolved value before returning: + +```python skip="true" +with prompt.get(PromptInputs(user_name='Alice')) as resolved: + print(resolved.value) # "Hello Alice! Welcome to our service." +``` + +The full resolution pipeline is: + +1. **Resolve** — fetch the serialized value from the provider (or use the code default) +2. **Compose** — expand any `<>` references (see [Composition](#variable-composition) below) +3. **Render** — render `{{placeholder}}` Handlebars templates using the provided inputs +4. **Deserialize** — validate and deserialize to the variable's type + +### Template Variables Parameters + +`logfire.template_var()` accepts the same parameters as `logfire.var()` plus: + +| Parameter | Description | +|-----------|-------------| +| `inputs_type` | A Pydantic `BaseModel` (or any type supported by `TypeAdapter`) describing the expected template inputs. This is used for type-safe `.get(inputs)` calls and generates a `template_inputs_schema` for validation. | + +### Handlebars Syntax + +Template variables use [Handlebars](https://handlebarsjs.com/) syntax, powered by the [`pydantic-handlebars`](https://github.com/pydantic/pydantic-handlebars) library. The most common patterns: + +| Syntax | Description | +|--------|-------------| +| `{{field}}` | Insert a value | +| `{{obj.nested}}` | Dot-notation access | +| `{{#if field}}...{{/if}}` | Conditional block | +| `{{#unless field}}...{{/unless}}` | Inverse conditional | +| `{{#each items}}...{{/each}}` | Iterate over a list | +| `{{#with obj}}...{{/with}}` | Change context | +| `{{! comment }}` | Comment (not rendered) | + +**Example with conditionals:** + +```python skip="true" +prompt = logfire.template_var( + 'greeting', + type=str, + default='Hello {{user_name}}!{{#if is_premium}} Thank you for being a premium member.{{/if}}', + inputs_type=PromptInputs, +) + +with prompt.get(PromptInputs(user_name='Alice', is_premium=True)) as resolved: + print(resolved.value) + # "Hello Alice! Thank you for being a premium member." + +with prompt.get(PromptInputs(user_name='Bob', is_premium=False)) as resolved: + print(resolved.value) + # "Hello Bob!" +``` + +### Structured Template Variables + +Template variables work with structured types too. Only string fields containing `{{placeholders}}` are rendered — other fields pass through unchanged: + +```python +from pydantic import BaseModel + +import logfire + +logfire.configure() + + +class UserContext(BaseModel): + user_name: str + tier: str + + +class AgentConfig(BaseModel): + instructions: str + model: str + temperature: float + + +agent_config = logfire.template_var( + 'agent_config', + type=AgentConfig, + default=AgentConfig( + instructions='You are helping {{user_name}}, a {{tier}} customer.', + model='openai:gpt-4o-mini', + temperature=0.7, + ), + inputs_type=UserContext, +) +``` + +```python skip="true" +with agent_config.get(UserContext(user_name='Alice', tier='premium')) as resolved: + print(resolved.value.instructions) # "You are helping Alice, a premium customer." + print(resolved.value.model) # "openai:gpt-4o-mini" (unchanged) +``` + +### Ad-hoc Rendering with `resolved.render()` + +For regular variables (created with `logfire.var()`) that happen to contain template syntax, you can render them after resolution using `resolved.render()`: + +```python skip="true" +from pydantic import BaseModel + +import logfire + + +class Inputs(BaseModel): + user_name: str + + +prompt = logfire.var('prompt', type=str, default='Hello {{user_name}}') + +with prompt.get() as resolved: + rendered = resolved.render(Inputs(user_name='Alice')) + print(rendered) # "Hello Alice" +``` + +This is useful when you want the flexibility to render templates on some code paths but not others. + +### Template Validation + +When a template variable is pushed to Logfire (via `logfire.variables_push()`), the `template_inputs_schema` is synced alongside the variable's JSON schema. The system validates that all `{{field}}` references in variable values (including values reachable through composition) are compatible with the declared schema. + +For example, if your `inputs_type` declares `user_name: str` and `is_premium: bool`, but a version value references `{{unknown_field}}`, the validation will flag this as an error. + +## Variable Composition {#variable-composition} + +**Composition** lets a variable's value reference other variables using `<>` syntax. When the variable is resolved, `<>` references are expanded by looking up the referenced variable and substituting its value. + +This is useful for building values from reusable fragments: + +```python skip="true" +import logfire + +logfire.configure() + +# A reusable instruction fragment +safety_rules = logfire.var( + 'safety_rules', + type=str, + default='Never share personal data. Always be respectful.', +) + +# A prompt that includes the safety rules via composition +agent_prompt = logfire.var( + 'agent_prompt', + type=str, + default='You are a helpful assistant. <>', +) + +with agent_prompt.get() as resolved: + print(resolved.value) + # "You are a helpful assistant. Never share personal data. Always be respectful." +``` + +When `safety_rules` is updated in the Logfire UI, all variables that reference `<>` automatically pick up the new value — no code changes or redeployment required. + +### Composition with Handlebars Power + +The `<<>>` syntax supports the full Handlebars feature set — not just simple variable substitution. You can use conditionals, loops, and more: + +| Syntax | Description | +|--------|-------------| +| `<>` | Insert a variable's value | +| `<>` | Access a nested field | +| `<<#if variable>>...<<#else>>...<>` | Conditional on whether a variable is set | +| `<<#each items>>...<>` | Iterate over a list variable | + +### Composition Tracking + +Every `<>` expansion is recorded in the resolution result. You can inspect which variables were composed and their values: + +```python skip="true" +with agent_prompt.get() as resolved: + for ref in resolved.composed_from: + print(f" {ref.name}: version={ref.version}, label={ref.label}") +``` + +These composition details are also recorded as span attributes, so you can see the full composition chain in your Logfire traces. + +### Combining Templates and Composition + +Template variables and composition work together. A common pattern is to compose reusable fragments via `<>` and render runtime inputs via `{{}}`: + +```python skip="true" +from pydantic import BaseModel + +import logfire + +logfire.configure() + + +class ChatInputs(BaseModel): + user_name: str + language: str + + +# Reusable fragment (no template inputs) +tone_instructions = logfire.var( + 'tone_instructions', + type=str, + default='Be friendly and concise.', +) + +# Template variable that composes the fragment and renders inputs +chat_prompt = logfire.template_var( + 'chat_prompt', + type=str, + default='You are helping {{user_name}}. Respond in {{language}}. <>', + inputs_type=ChatInputs, +) + +# Resolution: compose <> first, then render {{user_name}} and {{language}} +with chat_prompt.get(ChatInputs(user_name='Alice', language='French')) as resolved: + print(resolved.value) + # "You are helping Alice. Respond in French. Be friendly and concise." +``` + +### Cycle Detection + +The system detects circular references at write time. If variable A references `<>` and variable B references `<>`, pushing this configuration will produce an error. This prevents infinite loops during resolution. + +## Requirements + +Template rendering and composition require the [`pydantic-handlebars`](https://github.com/pydantic/pydantic-handlebars) library, which is included in the `variables` extra: + +```bash +pip install 'logfire[variables]' +``` + +!!! note "Python 3.10+" + `pydantic-handlebars` requires Python 3.10 or later. On Python 3.9, basic variable features (`logfire.var()` without templates or composition) still work, but template rendering is not available. diff --git a/examples/python/variable_composition_demo.py b/examples/python/variable_composition_demo.py new file mode 100644 index 000000000..33967f933 --- /dev/null +++ b/examples/python/variable_composition_demo.py @@ -0,0 +1,613 @@ +"""Demo: Variable Composition & Template Rendering with Logfire Managed Variables. + +This script demonstrates the full power of Logfire's variable composition +(<> references) and Handlebars template rendering ({{field}}) +using a purely local configuration — no remote server needed. + +Key features shown: + 1. Basic variable composition: <> references expand inline + 2. Nested composition: variable A references B, which references C + 3. Subfield variable references: <> accesses a field of a structured variable + 4. Template rendering with {{field}} placeholders and Pydantic input models + 5. Accessing subfields of template inputs (e.g. {{user.name}}, {{user.email}}) + 6. Handlebars conditionals: {{#if}}, {{else}}, {{/if}} + 7. Handlebars iteration: {{#each items}}...{{/each}} + 8. TemplateVariable: single-step get(inputs) with automatic rendering + 9. Variable.get() + .render(inputs): two-step manual rendering + 10. Rollout overrides with attribute-based conditions + 11. Composition-time conditionals: <<#if flag>>...<>...<> +""" + +from __future__ import annotations + +import json + +from pydantic import BaseModel + +import logfire +from logfire._internal.config import LocalVariablesOptions +from logfire.variables.config import ( + LabeledValue, + Rollout, + RolloutOverride, + ValueEquals, + VariableConfig, + VariablesConfig, +) + +# --------------------------------------------------------------------------- +# 1. Define Pydantic models for structured data & template inputs +# --------------------------------------------------------------------------- + + +class UserProfile(BaseModel): + """Nested model used as a variable value (composed into other variables).""" + + name: str + email: str + tier: str = 'free' + + +class PromptInputs(BaseModel): + """Template inputs with nested subfields.""" + + user: UserProfile + topic: str + max_tokens: int = 500 + + +class NotificationInputs(BaseModel): + """Template inputs for notification templates.""" + + user: UserProfile + action: str + details: str = '' + + +class OnboardingInputs(BaseModel): + """Template inputs demonstrating conditionals and iteration.""" + + user: UserProfile + is_new_user: bool = True + features: list[str] = [] + + +# --------------------------------------------------------------------------- +# 2. Build a local VariablesConfig with several interconnected variables +# --------------------------------------------------------------------------- + +variables_config = VariablesConfig( + variables={ + # --- Leaf variables (no references to other variables) --- + 'app_name': VariableConfig( + name='app_name', + labels={ + 'production': LabeledValue(version=1, serialized_value=json.dumps('Acme Platform')), + 'staging': LabeledValue(version=1, serialized_value=json.dumps('Acme Platform [STAGING]')), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[ + RolloutOverride( + conditions=[ValueEquals(attribute='environment', value='staging')], + rollout=Rollout(labels={'staging': 1.0}), + ), + ], + ), + 'safety_disclaimer': VariableConfig( + name='safety_disclaimer', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps( + 'Always verify critical information independently. This AI assistant may make mistakes.' + ), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + 'support_email': VariableConfig( + name='support_email', + labels={ + 'production': LabeledValue(version=1, serialized_value=json.dumps('help@acme.com')), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + # --- Structured variable (JSON object) for subfield composition --- + # Other variables can reference subfields like <> + 'brand': VariableConfig( + name='brand', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps( + { + 'tagline': 'Build faster, ship smarter', + 'color': '#4F46E5', + 'support_url': 'https://acme.dev/support', + } + ), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + # --- Composed variable: references <> --- + 'support_footer': VariableConfig( + name='support_footer', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps('Need help? Contact us at <>.'), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + # --- Composed + templated variable --- + # References <>, <>, <> + # Also contains {{user.name}}, {{user.tier}}, {{topic}} template placeholders + 'system_prompt': VariableConfig( + name='system_prompt', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps( + 'You are a helpful assistant for <>.\n\n' + 'The user you are speaking with is {{user.name}} ({{user.tier}} tier).\n' + 'They want help with: {{topic}}\n\n' + 'Guidelines:\n' + '- Be concise and helpful\n' + '- <>\n\n' + '<>' + ), + ), + 'concise': LabeledValue( + version=1, + serialized_value=json.dumps( + '<> assistant. User: {{user.name}} ({{user.tier}}). ' + 'Topic: {{topic}}. Be brief. <>' + ), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + template_inputs_schema=PromptInputs.model_json_schema(), + ), + # --- Notification template: uses {{user.name}}, {{user.email}}, {{action}} --- + # Also demonstrates {{#if details}} conditional + 'notification_template': VariableConfig( + name='notification_template', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps( + 'Hi {{user.name}},\n\n' + 'Your action "{{action}}" has been completed on <>.\n' + '{{#if details}}Details: {{details}}\n{{/if}}' + '\nA confirmation has been sent to {{user.email}}.\n\n' + '<>' + ), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + template_inputs_schema=NotificationInputs.model_json_schema(), + ), + # --- Onboarding template: demonstrates #if/#else and #each --- + # Also uses <> subfield composition + 'onboarding_message': VariableConfig( + name='onboarding_message', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps( + '{{#if is_new_user}}' + 'Welcome to <>, {{user.name}}! ' + '<>.\n' + '{{else}}' + 'Welcome back to <>, {{user.name}}!\n' + '{{/if}}' + '\n' + '{{#if features}}' + 'Here are your enabled features:\n' + '{{#each features}}' + ' - {{this}}\n' + '{{/each}}' + '{{else}}' + 'No features enabled yet. Visit <> to get started.\n' + '{{/if}}' + '\nQuestions? Reach out to <>.' + ), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + template_inputs_schema=OnboardingInputs.model_json_schema(), + ), + # --- Structured variable (JSON object) with template fields --- + # Shows that templates work inside structured types, not just strings + 'welcome_config': VariableConfig( + name='welcome_config', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps( + { + 'greeting': 'Welcome to <>, {{user.name}}!', + 'subtitle': 'Your {{user.tier}} account is ready. <>.', + 'cta_text': 'Explore {{topic}}', + 'show_banner': True, + 'max_tokens': 500, + } + ), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + template_inputs_schema=PromptInputs.model_json_schema(), + ), + # --- Feature flag (boolean) for composition-time conditionals --- + 'beta_enabled': VariableConfig( + name='beta_enabled', + labels={ + 'enabled': LabeledValue(version=1, serialized_value='true'), + 'disabled': LabeledValue(version=1, serialized_value='false'), + }, + rollout=Rollout(labels={'enabled': 1.0}), + overrides=[], + ), + # --- Composed variable using <<#if>> at composition time --- + # The <<#if beta_enabled>> block is evaluated during composition, NOT at + # template-render time. This means the conditional is resolved when the + # variable value is expanded, controlled by the beta_enabled flag variable. + 'banner_message': VariableConfig( + name='banner_message', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps( + '<<#if beta_enabled>>' + 'Try our new beta features! <>.' + '<>' + 'Welcome to <>.' + '<>' + ), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + } +) + +# --------------------------------------------------------------------------- +# 3. Configure Logfire with local variables (no remote server needed) +# --------------------------------------------------------------------------- + +logfire.configure( + send_to_logfire=False, + variables=LocalVariablesOptions( + config=variables_config, + instrument=False, # Keep output clean for the demo + ), +) + +# --------------------------------------------------------------------------- +# 4. Define variables in code +# --------------------------------------------------------------------------- + +app_name_var = logfire.var('app_name', type=str, default='MyApp') +safety_var = logfire.var('safety_disclaimer', type=str, default='Be careful.') +support_email_var = logfire.var('support_email', type=str, default='support@example.com') +support_footer_var = logfire.var('support_footer', type=str, default='Contact support.') +brand_var = logfire.var( + 'brand', + type=dict, + default={'tagline': 'Default tagline', 'color': '#000', 'support_url': 'https://example.com'}, +) + +# A Variable with template_inputs — uses two-step get() + render() +system_prompt_var = logfire.var( + 'system_prompt', + type=str, + default='Hello {{user.name}}, how can I help with {{topic}}?', + template_inputs=PromptInputs, +) + +# TemplateVariables — single-step get(inputs) with automatic rendering +notification_var = logfire.template_var( + 'notification_template', + type=str, + default='Hi {{user.name}}, your {{action}} is done.', + inputs_type=NotificationInputs, +) + +onboarding_var = logfire.template_var( + 'onboarding_message', + type=str, + default='Welcome, {{user.name}}!', + inputs_type=OnboardingInputs, +) + +# Structured (dict) variable with templates inside +welcome_var = logfire.template_var( + 'welcome_config', + type=dict, + default={'greeting': 'Welcome, {{user.name}}!', 'show_banner': True, 'max_tokens': 500}, + inputs_type=PromptInputs, +) + +# Composition-time conditional variable +banner_var = logfire.var('banner_message', type=str, default='Welcome.') + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def section(title: str) -> None: + """Print a section header.""" + print(f'\n{"=" * 70}') + print(f' {title}') + print(f'{"=" * 70}\n') + + +# --------------------------------------------------------------------------- +# 5. Demo: Basic composition (no templates) +# --------------------------------------------------------------------------- + +section('1. Basic Composition: <> references expand inline') + +result = support_footer_var.get() +print(f'support_footer resolved to:\n "{result.value}"\n') +print(f'Composed from {len(result.composed_from)} reference(s):') +for ref in result.composed_from: + print(f' - <<{ref.name}>> -> "{ref.value}" (label={ref.label}, v{ref.version})') + +# --------------------------------------------------------------------------- +# 6. Demo: Nested composition (A -> B -> C) +# --------------------------------------------------------------------------- + +section('2. Nested Composition: system_prompt -> support_footer -> support_email') + +# Get the raw (unrendered) system prompt to see composition in action +raw_result = system_prompt_var.get() +print('After composition (before template rendering):') +print(f' label={raw_result.label}, version={raw_result.version}') +print() + +# Show the composed value — <> are expanded but {{fields}} remain +composed_value = raw_result.value +# Since templates haven't been rendered yet, {{...}} placeholders are literal +print('Composed value ({{placeholders}} still present):') +for line in composed_value.split('\n'): + print(f' {line}') + +print(f'\nComposed from {len(raw_result.composed_from)} top-level reference(s):') +for ref in raw_result.composed_from: + print(f' - <<{ref.name}>> -> "{ref.value}"') + # Show nested references (e.g. support_footer -> support_email) + for nested in ref.composed_from: + print(f' -> <<{nested.name}>> -> "{nested.value}"') + +# --------------------------------------------------------------------------- +# 7. Demo: Subfield references to structured variables +# --------------------------------------------------------------------------- + +section('3. Subfield Variable References: <>, <>') + +print('The "brand" variable is a JSON object:') +brand_result = brand_var.get() +for key, value in brand_result.value.items(): + print(f' {key}: {value!r}') + +print() +print('Other variables can reference individual fields via <>.') +print('For example, the onboarding_message template contains:') +print(' <> -> expands to the tagline string') +print(' <> -> expands to the support URL string') + +# --------------------------------------------------------------------------- +# 8. Demo: Handlebars conditionals (#if / #else) +# --------------------------------------------------------------------------- + +section('4. Handlebars Conditionals: {{#if}}, {{else}}, {{/if}}') + +print('The onboarding_message template uses conditionals:') +print(' {{#if is_new_user}}...welcome...{{else}}...welcome back...{{/if}}') +print(' {{#if features}}...list them...{{else}}...no features yet...{{/if}}') +print() + +# New user WITH features +new_user_inputs = OnboardingInputs( + user=UserProfile(name='Alice', email='alice@example.com'), + is_new_user=True, + features=['Dashboard Analytics', 'API Access', 'Team Management'], +) + +result_new = onboarding_var.get(new_user_inputs) +print('--- New user with features ---') +for line in result_new.value.split('\n'): + print(f' {line}') + +print() + +# Returning user WITHOUT features +returning_user_inputs = OnboardingInputs( + user=UserProfile(name='Bob', email='bob@example.com', tier='premium'), + is_new_user=False, + features=[], +) + +result_returning = onboarding_var.get(returning_user_inputs) +print('--- Returning user, no features ---') +for line in result_returning.value.split('\n'): + print(f' {line}') + +print() +print('Composed references in the onboarding message:') +for ref in result_new.composed_from: + print(f' - <<{ref.name}>> -> "{ref.value}"') + +# --------------------------------------------------------------------------- +# 9. Demo: Template rendering with subfield access (user.name, user.tier) +# --------------------------------------------------------------------------- + +section('5. Template Rendering: Subfields of inputs ({{user.name}}, {{user.tier}})') + +user = UserProfile(name='Alice Johnson', email='alice@example.com', tier='premium') +inputs = PromptInputs(user=user, topic='billing questions') + +# Two-step: get() then render() +with system_prompt_var.get() as resolved: + rendered = resolved.render(inputs) + +print('Rendered system prompt:') +for line in rendered.split('\n'): + print(f' {line}') + +# --------------------------------------------------------------------------- +# 10. Demo: TemplateVariable single-step get(inputs) +# --------------------------------------------------------------------------- + +section('6. TemplateVariable: Single-step get(inputs) with auto-rendering') + +# {{#if details}} conditional: included because details is non-empty +notif_inputs = NotificationInputs( + user=UserProfile(name='Bob Smith', email='bob@corp.com', tier='enterprise'), + action='project deployment', + details='Deployed v2.3.1 to production', +) + +result = notification_var.get(notif_inputs) +print('--- With details ({{#if details}} is truthy) ---') +for line in result.value.split('\n'): + print(f' {line}') + +print() + +# {{#if details}} conditional: omitted because details is empty +notif_no_details = NotificationInputs( + user=UserProfile(name='Carol', email='carol@startup.io'), + action='password reset', +) + +result_no_details = notification_var.get(notif_no_details) +print('--- Without details ({{#if details}} is falsy) ---') +for line in result_no_details.value.split('\n'): + print(f' {line}') + +# --------------------------------------------------------------------------- +# 11. Demo: Structured variable with templates and subfield composition +# --------------------------------------------------------------------------- + +section('7. Structured Variable: Templates + <> in dict values') + +struct_inputs = PromptInputs( + user=UserProfile(name='Carol', email='carol@startup.io'), + topic='AI integrations', +) + +struct_result = welcome_var.get(struct_inputs) +print('Rendered welcome_config (dict with templates in string fields):') +for key, value in struct_result.value.items(): + print(f' {key}: {value!r}') + +print('\nNote: string values were rendered, non-strings (bool, int) pass through unchanged.') +print('The subtitle used <> to compose in the brand tagline.') + +# --------------------------------------------------------------------------- +# 12. Demo: Rollout overrides with attributes +# --------------------------------------------------------------------------- + +section('8. Rollout Overrides: Attribute-based label selection') + +prod_result = app_name_var.get() +print(f'Default (production): "{prod_result.value}" (label={prod_result.label})') + +staging_result = app_name_var.get(attributes={'environment': 'staging'}) +print(f'With env=staging: "{staging_result.value}" (label={staging_result.label})') + +# --------------------------------------------------------------------------- +# 13. Demo: Explicit label selection +# --------------------------------------------------------------------------- + +section('9. Explicit Label Selection: Choosing a specific label') + +verbose_result = system_prompt_var.get(label='production') +concise_result = system_prompt_var.get(label='concise') + +print('Production prompt (first 80 chars):') +print(f' "{verbose_result.value[:80]}..."') +print('\nConcise prompt:') +print(f' "{concise_result.value}"') + +# Now render the concise one with template inputs +rendered_concise = concise_result.render(inputs) +print('\nConcise prompt rendered:') +print(f' "{rendered_concise}"') + +# --------------------------------------------------------------------------- +# 14. Demo: Composition-time conditionals (<<#if>> at composition time) +# --------------------------------------------------------------------------- + +section('10. Composition-Time Conditionals: <<#if>> with feature flags') + +print('The banner_message variable uses <<#if beta_enabled>> at composition time.') +print('This conditional is resolved when <<>> references are expanded, NOT at') +print('template render time. The beta_enabled variable controls which branch appears.') +print() + +# beta_enabled is true by default (the "enabled" label has weight 1.0) +banner_result = banner_var.get() +print('With beta_enabled=true:') +print(f' "{banner_result.value}"') +print() + +# Show the composed references +print(f'Composed from {len(banner_result.composed_from)} reference(s):') +for ref in banner_result.composed_from: + print(f' - <<{ref.name}>> = {ref.value!r}') + +# --------------------------------------------------------------------------- +# 15. Demo: Using context manager for baggage propagation +# --------------------------------------------------------------------------- + +section('11. Context Manager: Baggage propagation for observability') + +with system_prompt_var.get() as resolved: + print('Inside context manager:') + print(f' Variable: {resolved.name}') + print(f' Label: {resolved.label}') + print(f' Version: {resolved.version}') + print(f' Baggage key: logfire.variables.{resolved.name}') + print(f' Baggage value: {resolved.label}') + print() + print(' Any spans created in this block will carry the variable') + print(' resolution as baggage, enabling downstream correlation.') + +# --------------------------------------------------------------------------- +# 16. Summary +# --------------------------------------------------------------------------- + +section('Summary') +print('This demo showed:') +print(' - <> composition: inline expansion of variable references') +print(' - Nested composition: A -> B -> C chains expand recursively') +print(' - <> subfield refs: access fields of structured variables') +print(' - {{field}} templates with Handlebars syntax') +print(' - Subfield access in templates: {{user.name}}, {{user.email}}, {{user.tier}}') +print(' - {{#if cond}}...{{else}}...{{/if}} conditionals (template-time)') +print(' - {{#each list}}...{{/each}} iteration (template-time)') +print(' - <<#if flag>>...<>...<> conditionals (composition-time)') +print(' - Structured variables: templates render inside dict string values') +print(' - TemplateVariable: single-step get(inputs) with auto-rendering') +print(' - Variable + render(): two-step manual rendering') +print(' - Rollout overrides: attribute-based label selection') +print(' - Explicit label selection: get(label="concise")') +print(' - Context manager: baggage propagation for observability') +print() +print('All using LocalVariablesOptions — no remote server required!') diff --git a/logfire-api/logfire_api/_internal/main.pyi b/logfire-api/logfire_api/_internal/main.pyi index 740a0aac5..c3345f4e5 100644 --- a/logfire-api/logfire_api/_internal/main.pyi +++ b/logfire-api/logfire_api/_internal/main.pyi @@ -1177,9 +1177,9 @@ class Logfire: `False` if the timeout was reached before the shutdown was completed, `True` otherwise. """ @overload - def var(self, name: str, *, default: T, description: str | None = None) -> Variable[T]: ... + def var(self, name: str, *, default: T, description: str | None = None, template_inputs: type[Any] | None = None) -> Variable[T]: ... @overload - def var(self, name: str, *, type: type[T], default: T | ResolveFunction[T], description: str | None = None) -> Variable[T]: ... + def var(self, name: str, *, type: type[T], default: T | ResolveFunction[T], description: str | None = None, template_inputs: type[Any] | None = None) -> Variable[T]: ... def variables_clear(self) -> None: """Clear all registered variables from this Logfire instance. diff --git a/logfire/__init__.py b/logfire/__init__.py index 8c0d01e02..bd6f644d0 100644 --- a/logfire/__init__.py +++ b/logfire/__init__.py @@ -97,6 +97,7 @@ # Variables var = DEFAULT_LOGFIRE_INSTANCE.var +template_var = DEFAULT_LOGFIRE_INSTANCE.template_var variables_clear = DEFAULT_LOGFIRE_INSTANCE.variables_clear variables_get = DEFAULT_LOGFIRE_INSTANCE.variables_get variables_push = DEFAULT_LOGFIRE_INSTANCE.variables_push @@ -196,6 +197,7 @@ def loguru_handler() -> Any: 'LocalVariablesOptions', 'variables', 'var', + 'template_var', 'variables_clear', 'variables_get', 'variables_push', diff --git a/logfire/_internal/integrations/pytest.py b/logfire/_internal/integrations/pytest.py index 05916d398..1afaad1bb 100644 --- a/logfire/_internal/integrations/pytest.py +++ b/logfire/_internal/integrations/pytest.py @@ -455,7 +455,12 @@ def pytest_runtest_makereport( # Record exception details if call.excinfo and call.excinfo.value: # pragma: no branch # Branch coverage: excinfo.value is always present for failed tests in normal pytest execution - span.record_exception(call.excinfo.value) + try: + span.record_exception(call.excinfo.value) + except RuntimeError: + # CPython 3.11+ can raise "generator raised StopIteration" from + # traceback.extract_tb when processing certain bytecode positions. + pass elif report.skipped: # pragma: no cover # TODO: this needs improvement in processing skip reasons skip_reason = '' diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 60d4c0e29..1130ad6b2 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -115,6 +115,7 @@ from ..integrations.wsgi import RequestHook as WSGIRequestHook, ResponseHook as WSGIResponseHook from ..variables import ( ResolveFunction, + TemplateVariable, ValidationReport, Variable, VariablesConfig, @@ -156,7 +157,7 @@ def __init__( self._sample_rate = sample_rate self._console_log = console_log self._otel_scope = otel_scope - self._variables: dict[str, Variable[Any]] = {} + self._variables: dict[str, Variable[Any] | TemplateVariable[Any, Any]] = {} @property def config(self) -> LogfireConfig: @@ -2455,6 +2456,7 @@ def var( *, default: T, description: str | None = None, + template_inputs: type[Any] | None = None, ) -> Variable[T]: ... @overload @@ -2465,6 +2467,7 @@ def var( type: type[T], default: T | ResolveFunction[T], description: str | None = None, + template_inputs: type[Any] | None = None, ) -> Variable[T]: ... def var( @@ -2474,6 +2477,7 @@ def var( type: type[T] | None = None, default: T | ResolveFunction[T], description: str | None = None, + template_inputs: type[Any] | None = None, ) -> Variable[T]: """Define a managed variable. @@ -2498,6 +2502,28 @@ def var( ... ``` + Template rendering example: + + ```py + from pydantic import BaseModel + + + class PromptInputs(BaseModel): + user_name: str + is_premium: bool = False + + + prompt = logfire.var( + 'system_prompt', + type=str, + default='Hello {{user_name}}', + template_inputs=PromptInputs, + ) + + with prompt.get() as resolved: + rendered = resolved.render(PromptInputs(user_name='Alice')) + ``` + Args: name: Unique identifier for the variable. Must match the name configured in the Logfire UI when using remote variables. @@ -2509,6 +2535,9 @@ def var( Can also be a callable with `targeting_key` and `attributes` parameters (requires `type` to be set explicitly). description: Optional human-readable description of what the variable controls. + template_inputs: Optional Pydantic model type describing the expected template inputs + for Handlebars ``{{placeholder}}`` rendering. When set, the JSON Schema of this + model is pushed to the server and used by the UI for autocomplete and preview. """ from logfire.variables.variable import Variable, is_resolve_function @@ -2536,7 +2565,90 @@ def var( f"A variable with name '{name}' has already been registered. Each variable must have a unique name." ) - variable = Variable[T](name, default=default, type=tp, logfire_instance=self, description=description) + variable = Variable[T]( + name, + default=default, + type=tp, + logfire_instance=self, + description=description, + template_inputs=template_inputs, + ) + self._variables[name] = variable + + return variable + + def template_var( + self, + name: str, + *, + type: type[T], + default: T | ResolveFunction[T], + inputs_type: type[Any], + description: str | None = None, + ) -> TemplateVariable[T, Any]: + """Define a managed template variable with integrated rendering. + + Like ``var()``, but ``get(inputs)`` automatically renders Handlebars ``{{placeholder}}`` + templates in the resolved value before returning. The pipeline is: + resolve → compose ``<>`` → render ``{{}}`` → deserialize. + + ```py + from pydantic import BaseModel + + import logfire + + logfire.configure() + + + class PromptInputs(BaseModel): + user_name: str + is_premium: bool = False + + + prompt = logfire.template_var( + 'system_prompt', + type=str, + default='Hello {{user_name}}', + inputs_type=PromptInputs, + ) + + with prompt.get(PromptInputs(user_name='Alice')) as resolved: + print(resolved.value) # "Hello Alice" + ``` + + Args: + name: Unique identifier for the variable. + type: Expected type for validation and JSON schema generation. + default: Default value used when no remote configuration is found. + Can also be a callable with ``targeting_key`` and ``attributes`` parameters. + 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 + + if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name): + raise ValueError( + f"Invalid variable name '{name}'. " + 'Variable names must be valid Python identifiers (letters, digits, and underscores, ' + 'not starting with a digit).' + ) + + if name in self._variables: + raise ValueError( + f"A variable with name '{name}' has already been registered. Each variable must have a unique name." + ) + + variable = TemplateVariable[T, Any]( + name, + type=type, + default=default, + inputs_type=inputs_type, + description=description, + logfire_instance=self, + ) self._variables[name] = variable return variable @@ -2544,19 +2656,20 @@ def var( def variables_clear(self) -> None: """Clear all registered variables from this Logfire instance. - This removes all variables previously registered via [`var()`][logfire.Logfire.var], + This removes all variables previously registered via [`var()`][logfire.Logfire.var] + or [`template_var()`][logfire.Logfire.template_var], allowing them to be re-registered. This is primarily intended for use in tests to ensure a clean state between test cases. """ self._variables.clear() - def variables_get(self) -> list[Variable[Any]]: + def variables_get(self) -> list[Variable[Any] | TemplateVariable[Any, Any]]: """Get all variables registered with this Logfire instance.""" return list(self._variables.values()) def variables_push( self, - variables: list[Variable[Any]] | None = None, + variables: list[Variable[Any] | TemplateVariable[Any, Any]] | None = None, *, dry_run: bool = False, yes: bool = False, @@ -2672,7 +2785,7 @@ class UserSettings(BaseModel): def variables_validate( self, - variables: list[Variable[Any]] | None = None, + variables: list[Variable[Any] | TemplateVariable[Any, Any]] | None = None, ) -> ValidationReport: """Validate that provider-side variable label values match local type definitions. @@ -2774,7 +2887,7 @@ def variables_pull_config(self) -> VariablesConfig: # pragma: no cover def variables_build_config( self, - variables: list[Variable[Any]] | None = None, + variables: list[Variable[Any] | TemplateVariable[Any, Any]] | None = None, ) -> VariablesConfig: """Build a VariablesConfig from registered Variable instances. diff --git a/logfire/variables/__init__.py b/logfire/variables/__init__.py index 74a3534e9..509605d35 100644 --- a/logfire/variables/__init__.py +++ b/logfire/variables/__init__.py @@ -13,6 +13,11 @@ VariableNotFoundError, VariableWriteError, ) +from logfire.variables.composition import ( + ComposedReference, + VariableCompositionCycleError, + VariableCompositionError, +) if TYPE_CHECKING: # We use a TYPE_CHECKING block here because we need to do these imports lazily to prevent issues due to loading the @@ -39,6 +44,7 @@ ) from logfire.variables.variable import ( ResolveFunction, + TemplateVariable, Variable, targeting_context, ) @@ -46,6 +52,7 @@ __all__ = [ # Variable classes 'Variable', + 'TemplateVariable', 'ResolvedVariable', 'ResolveFunction', # Configuration classes @@ -112,6 +119,7 @@ def __getattr__(name: str): ) from logfire.variables.variable import ( ResolveFunction, + TemplateVariable, Variable, targeting_context, ) diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index 12d7d9099..1be4c49b0 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -6,8 +6,8 @@ from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence from contextlib import ExitStack -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeVar SyncMode = Literal['merge', 'replace'] @@ -15,8 +15,9 @@ from pydantic import TypeAdapter import logfire + from logfire.variables.composition import ComposedReference from logfire.variables.config import VariableConfig, VariablesConfig, VariableTypeConfig - from logfire.variables.variable import Variable + from logfire.variables.variable import _BaseVariable # pyright: ignore[reportPrivateUsage] # ANSI color codes for terminal output ANSI_RESET = '\033[0m' @@ -37,6 +38,7 @@ 'VariableWriteError', 'VariableNotFoundError', 'VariableAlreadyExistsError', + 'render_serialized_string', ) T = TypeVar('T') @@ -112,6 +114,23 @@ class ResolvedVariable(Generic[T_co]): """The version number of the resolved value, if any.""" exception: Exception | None = None """Any exception that occurred during resolution.""" + composed_from: list[ComposedReference] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + """Variables that were composed into this value via <> expansion. + + Each entry is a ComposedReference for a referenced variable, including + its label, version, reason, and any nested composed_from entries. + """ + _serialized_value: str | None = None + """Internal: the post-composition, pre-deserialization JSON string. + + Used by render() to apply Handlebars template rendering on the serialized + form before deserializing to the variable's type. + """ + _deserializer: Callable[[str], Any] | None = None + """Internal: function to deserialize a JSON string to the variable's type. + + Returns the deserialized value or an Exception on failure. + """ def __post_init__(self): self._exit_stack = ExitStack() @@ -130,6 +149,147 @@ def __enter__(self): def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: self._exit_stack.__exit__(exc_type, exc_val, exc_tb) + def render(self, inputs: Any = None) -> T_co: + """Render Handlebars templates in this variable's value. + + Operates on the serialized JSON (post-composition), renders all ``{{placeholder}}`` + expressions using the provided inputs, then deserializes to the variable's type. + + For ``str`` variables, this renders the template and returns a string. + For structured variables (e.g., Pydantic models), all string values containing + ``{{placeholders}}`` are rendered while non-string fields pass through unchanged. + + Args: + inputs: Template context values. Can be a Pydantic ``BaseModel`` (uses ``model_dump()``), + a ``dict``, or any ``Mapping``. If ``None``, renders with an empty context. + + Returns: + The rendered value, typed as the variable's type ``T_co``. + + Raises: + ValueError: If no serialized value is available for rendering. + ImportError: If ``pydantic-handlebars`` is not installed. + + Example: + ```python skip="true" + from pydantic import BaseModel + + + class Inputs(BaseModel): + user_name: str + + + prompt = logfire.var('prompt', type=str, default='Hello {{user_name}}') + with prompt.get() as resolved: + rendered = resolved.render(Inputs(user_name='Alice')) + # rendered == "Hello Alice" + ``` + """ + if self._serialized_value is None: + raise ValueError( + 'Cannot render template: no serialized value available. ' + 'This can happen if the variable resolved to a context override ' + 'or if serialization of the default value failed.' + ) + if self._deserializer is None: + raise ValueError('Cannot render template: no deserializer available.') + + rendered_json = render_serialized_string(self._serialized_value, inputs) + + # Deserialize the rendered JSON + result = self._deserializer(rendered_json) + if isinstance(result, Exception): + raise result + return result + + +def _inputs_to_context(inputs: Any) -> dict[str, Any]: + """Convert inputs (Pydantic model, dict, or Mapping) to a template context dict. + + Args: + inputs: Template context values. Can be a Pydantic ``BaseModel`` (uses ``model_dump()``), + a ``dict``, or any ``Mapping``. If ``None``, returns an empty dict. + + Returns: + A dict suitable for use as a Handlebars template context. + + Raises: + TypeError: If inputs is not a supported type. + """ + if inputs is None: + return {} + elif hasattr(inputs, 'model_dump'): + return inputs.model_dump() + elif isinstance(inputs, Mapping): + return dict(inputs) # pyright: ignore[reportUnknownArgumentType] + else: + raise TypeError(f'Expected a dict, Mapping, or Pydantic model for render inputs, got {type(inputs).__name__}') + + +def render_serialized_string(serialized_json: str, inputs: Any) -> str: + """Render Handlebars templates in a serialized JSON string. + + Decodes the JSON, renders all string values containing ``{{placeholders}}`` + using the provided inputs, then re-encodes to JSON. + + Args: + serialized_json: A JSON-encoded string potentially containing Handlebars templates. + inputs: Template context values. Can be a Pydantic ``BaseModel``, ``dict``, + ``Mapping``, or ``None``. + + Returns: + The rendered JSON string. + """ + from pydantic_handlebars import SafeString, render as hbs_render + + safe_string_cls: type[str] = SafeString + render_fn: Callable[..., str] = hbs_render + + context = _inputs_to_context(inputs) + + # Wrap all string values in SafeString to disable HTML escaping. + # For prompt/config templates (not HTML), escaping is undesirable. + context = _wrap_safe_context(context, safe_string_cls) + + # Decode the serialized JSON, render string values, then re-encode. + # We can't render the raw JSON directly because substituted values + # might contain JSON-special characters (e.g., double quotes) that + # would make the resulting JSON invalid. + decoded = json.loads(serialized_json) + rendered_value = _render_json_value(decoded, render_fn, context) + return json.dumps(rendered_value) + + +def _wrap_safe_context(context: dict[str, Any], safe_string_cls: type[str]) -> dict[str, Any]: + """Recursively wrap all string values in SafeString to disable HTML escaping.""" + return {k: _wrap_safe_value(v, safe_string_cls) for k, v in context.items()} + + +def _wrap_safe_value(value: Any, safe_string_cls: type[str]) -> Any: + """Wrap a single value: strings become SafeString, dicts/lists are recursed.""" + if isinstance(value, str): + return safe_string_cls(value) + if isinstance(value, dict): + return _wrap_safe_context(value, safe_string_cls) # pyright: ignore[reportUnknownArgumentType] + if isinstance(value, list): + return [_wrap_safe_value(item, safe_string_cls) for item in value] # pyright: ignore[reportUnknownVariableType] + return value + + +def _render_json_value(value: Any, hbs_render: Callable[..., str], context: dict[str, Any]) -> Any: + """Recursively render Handlebars templates in a decoded JSON value. + + Only string values are rendered; dicts and lists are walked recursively. + """ + if isinstance(value, str): + return hbs_render(value, context) + if isinstance(value, dict): + return {k: _render_json_value(v, hbs_render, context) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] + if isinstance(value, list): + return [_render_json_value(item, hbs_render, context) for item in value] # pyright: ignore[reportUnknownVariableType] + # Numbers, booleans, None pass through unchanged + return value + # --- Dataclasses for push/validate operations --- @@ -158,6 +318,7 @@ class VariableChange: local_description: str | None = None server_description: str | None = None description_differs: bool = False # True if descriptions differ (for warning) + template_inputs_schema: dict[str, Any] | None = None # JSON Schema for template inputs @dataclass @@ -166,6 +327,8 @@ class VariableDiff: changes: list[VariableChange] orphaned_server_variables: list[str] # Variables on server not in local code + reference_warnings: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + """Warnings about variable references (non-existent refs, cycles, etc.).""" @property def has_changes(self) -> bool: @@ -216,6 +379,8 @@ class ValidationReport: """Names of variables that exist locally but not on the server.""" description_differences: list[DescriptionDifference] """List of variables where local and server descriptions differ.""" + reference_warnings: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + """Warnings about variable references (non-existent refs, cycles, etc.).""" @property def has_errors(self) -> bool: @@ -279,6 +444,12 @@ def format(self, *, colors: bool = True) -> str: lines.append(f' Local: {local_desc}') lines.append(f' Server: {server_desc}') + # Show reference warnings + if self.reference_warnings: + lines.append(f'\n{yellow}=== Reference warnings ==={reset}') + for warning in self.reference_warnings: + lines.append(f' {yellow}⚠ {warning}{reset}') + # Summary line if not self.is_valid: error_count = variables_with_errors + len(self.variables_not_on_server) @@ -292,12 +463,12 @@ def format(self, *, colors: bool = True) -> str: # --- Helper functions for push/validate operations --- -def _get_json_schema(variable: Variable[object]) -> dict[str, Any]: +def _get_json_schema(variable: _BaseVariable[object]) -> dict[str, Any]: """Get the JSON schema for a variable's type.""" return variable.type_adapter.json_schema() -def _get_default_serialized(variable: Variable[object]) -> str | None: +def _get_default_serialized(variable: _BaseVariable[object]) -> str | None: """Get the serialized default value for a variable. Returns None if the default is a ResolveFunction (can't serialize a function). @@ -311,7 +482,7 @@ def _get_default_serialized(variable: Variable[object]) -> str | None: def _check_label_compatibility( - variable: Variable[object], + variable: _BaseVariable[object], label: str, serialized_value: str, ) -> LabelCompatibility: @@ -335,7 +506,7 @@ def _check_label_compatibility( def _check_all_label_compatibility( - variable: Variable[object], + variable: _BaseVariable[object], server_var: VariableConfig, ) -> list[LabelCompatibility]: """Check all labeled values and latest_version against the variable's Python type. @@ -411,8 +582,92 @@ def _check_type_label_compatibility( return incompatible +def _check_reference_warnings( + variables: Sequence[_BaseVariable[object]], + server_config: VariablesConfig, +) -> list[str]: + """Check for reference warnings: non-existent refs and cycles. + + Scans local variable defaults and server label values for <> + and validates that referenced variables exist and there are no cycles. + """ + 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()) + + # Build a reference graph: variable_name -> set of referenced names + ref_graph: dict[str, set[str]] = {} + + # Scan local variable defaults for references + for variable in variables: + refs: set[str] = set() + if not is_resolve_function(variable.default): + try: + serialized_default = variable.type_adapter.dump_json(variable.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) + 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)) + if server_var.latest_version is not None: + refs.update(find_references(server_var.latest_version.serialized_value)) + + 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.") + + # Check for cycles using DFS + def _detect_cycles(graph: dict[str, set[str]]) -> list[list[str]]: + cycles: list[list[str]] = [] + visited: set[str] = set() + in_stack: set[str] = set() + path: 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 + if node in visited: + return + visited.add(node) + in_stack.add(node) + path.append(node) + for neighbor in graph.get(node, set()): + dfs(neighbor) + path.pop() + in_stack.remove(node) + + for node in graph: + if node not in visited: + dfs(node) + return cycles + + cycles = _detect_cycles(ref_graph) + for cycle in cycles: + cycle_str = ' -> '.join(cycle) + warnings_list.append(f'Reference cycle detected: {cycle_str}') + + return warnings_list + + def _compute_diff( - variables: Sequence[Variable[object]], + variables: Sequence[_BaseVariable[object]], server_config: VariablesConfig, ) -> VariableDiff: """Compute the diff between local variables and server config. @@ -432,6 +687,9 @@ def _compute_diff( local_description = variable.description server_var = server_config.variables.get(variable.name) + # Get template_inputs_schema if available + template_inputs_schema = variable.get_template_inputs_schema() + if server_var is None: # New variable - needs to be created default_serialized = _get_default_serialized(variable) @@ -442,6 +700,7 @@ def _compute_diff( local_schema=local_schema, initial_value=default_serialized, local_description=local_description, + template_inputs_schema=template_inputs_schema, ) ) else: @@ -495,7 +754,10 @@ def _compute_diff( # Find orphaned server variables (on server but not in local code) orphaned = [name for name in server_config.variables.keys() if name not in local_names] - return VariableDiff(changes=changes, orphaned_server_variables=orphaned) + # Check for reference warnings (non-existent refs, cycles) + reference_warnings = _check_reference_warnings(variables, server_config) + + return VariableDiff(changes=changes, orphaned_server_variables=orphaned, reference_warnings=reference_warnings) def _format_diff(diff: VariableDiff) -> str: @@ -558,6 +820,12 @@ def _format_diff(diff: VariableDiff) -> str: lines.append(f' Local: {local_desc}') lines.append(f' Server: {server_desc}') + # Show reference warnings + if diff.reference_warnings: + lines.append(f'\n{ANSI_YELLOW}=== Reference warnings ==={ANSI_RESET}') + for warning in diff.reference_warnings: + lines.append(f' {ANSI_YELLOW}⚠ {warning}{ANSI_RESET}') + return '\n'.join(lines) @@ -591,6 +859,7 @@ def _create_variable( overrides=[], json_schema=change.local_schema, example=change.initial_value, # Store the code default as an example for the UI + template_inputs_schema=change.template_inputs_schema, ) provider.create_variable(config) @@ -981,7 +1250,7 @@ def pull_config(self) -> VariablesConfig: # pragma: no cover def push_variables( self, - variables: Sequence[Variable[object]], + variables: Sequence[_BaseVariable[object]], *, dry_run: bool = False, yes: bool = False, @@ -1077,7 +1346,7 @@ def push_variables( def validate_variables( self, - variables: Sequence[Variable[object]], + variables: Sequence[_BaseVariable[object]], ) -> ValidationReport: """Validate that provider-side variable label values match local type definitions. @@ -1152,11 +1421,15 @@ def validate_variables( ) ) + # Check for reference warnings + reference_warnings = _check_reference_warnings(variables, server_config) + return ValidationReport( errors=errors, variables_checked=len(variables), variables_not_on_server=variables_not_on_server, description_differences=description_differences, + reference_warnings=reference_warnings, ) # --- Variable Types API --- @@ -1428,7 +1701,7 @@ def get_variable_config(self, name: str) -> VariableConfig | None: def push_variables( self, - variables: Sequence[Variable[Any]], + variables: Sequence[_BaseVariable[Any]], *, dry_run: bool = False, yes: bool = False, @@ -1444,7 +1717,7 @@ def push_variables( def validate_variables( self, - variables: Sequence[Variable[Any]], + variables: Sequence[_BaseVariable[Any]], ) -> ValidationReport: """No-op implementation that returns an empty validation report. diff --git a/logfire/variables/angle_bracket.py b/logfire/variables/angle_bracket.py new file mode 100644 index 000000000..82c25298b --- /dev/null +++ b/logfire/variables/angle_bracket.py @@ -0,0 +1,69 @@ +"""Angle-bracket Handlebars: low-level swap primitives for <<>> rendering. + +This module provides ``render_once`` which performs a single-pass render using +``<<>>`` as the delimiter instead of ``{{}}``. It is the engine behind variable +composition — it gives ``<<>>`` syntax the full power of Handlebars +(conditionals, loops, helpers, etc.) while preserving any ``{{}}`` runtime +placeholders untouched. + +Algorithm (swap + protect): + a. Protect ``{}<>`` characters in context values with HTML entities + b. Swap ``{↔<`` and ``}↔>`` in the template (so ``<<>>`` becomes ``{{}}``) + c. Run standard Handlebars + d. Reverse swap + e. Unescape the entities we introduced +""" + +from __future__ import annotations + +from typing import Any + +from pydantic_handlebars import SafeString, render as hbs_render + +# --------------------------------------------------------------------------- +# Character swap table: { ↔ < and } ↔ > +# --------------------------------------------------------------------------- +_SWAP = str.maketrans('{}<>', '<>{}') + +# --------------------------------------------------------------------------- +# Protection: escape {}<> in values to numeric entities that contain +# NO {}<> characters, so the reverse swap can't corrupt them. +# --------------------------------------------------------------------------- +_PROTECT = str.maketrans( + { + '{': '{', + '}': '}', + '<': '<', + '>': '>', + } +) + + +def _unescape_protected(s: str) -> str: + """Undo only the four entities we introduced.""" + return s.replace('{', '{').replace('}', '}').replace('<', '<').replace('>', '>') + + +def _protect_value(value: Any) -> Any: + """Recursively protect string values, preserving structure for dicts/lists.""" + if isinstance(value, str): + return SafeString(value.translate(_PROTECT)) + if isinstance(value, dict): + return {k: _protect_value(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] + if isinstance(value, list): + return [_protect_value(v) for v in value] # pyright: ignore[reportUnknownVariableType] + return value # bools, ints, None, etc. — pass through + + +# --------------------------------------------------------------------------- +# Core single-pass render: swap → Handlebars → unswap → unescape +# --------------------------------------------------------------------------- + + +def render_once(template: str, context: dict[str, Any]) -> str: + """Single-pass render: swap <<>>↔{{}}, run Handlebars, reverse swap, unescape.""" + swapped_template = template.translate(_SWAP) + safe_context = {k: _protect_value(v) for k, v in context.items()} + result: str = hbs_render(swapped_template, safe_context) + result = result.translate(_SWAP) + return _unescape_protected(result) diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py new file mode 100644 index 000000000..1dedaa8f7 --- /dev/null +++ b/logfire/variables/composition.py @@ -0,0 +1,336 @@ +"""Variable composition: expand <> references in serialized values. + +This module provides pure functions for expanding variable references in serialized +JSON strings. References use the ``<>`` syntax and are expanded using +the Handlebars engine via character-swap, giving ``<<>>`` the full power of +Handlebars: ``<<#if>>``, ``<<#each>>``, ``<<#unless>>``, ``<<#with>>``, etc. + +Meanwhile, any ``{{runtime}}`` placeholders are preserved untouched for later +template rendering. + +The composition logic is shared between the SDK (client-side expansion) and the +backend OFREP endpoint (server-side expansion). +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Optional, Tuple # noqa: UP035 + +from logfire.variables.angle_bracket import render_once + +__all__ = ( + 'MAX_COMPOSITION_DEPTH', + 'VariableCompositionError', + 'VariableCompositionCycleError', + 'ComposedReference', + 'expand_references', + 'find_references', + 'has_references', +) + +# Matches unescaped << (not preceded by \). +# In JSON-serialized strings, a real backslash is \\, so \\<< is an escaped ref. +_HAS_ANGLE = re.compile(r'(?> or <> +_SIMPLE_REF = re.compile(r'(?>') + +# Block helper references: <<#helper identifier ...>> — extracts the first identifier after the helper name. +_BLOCK_REF = re.compile(r'(?>)') + +# Handlebars keywords that should never be treated as variable references. +# These are valid in <> syntax but are Handlebars built-ins. +_HBS_KEYWORDS = frozenset({'else', 'this'}) + +MAX_COMPOSITION_DEPTH = 20 + + +class VariableCompositionError(Exception): + """Error during variable composition (reference expansion).""" + + +class VariableCompositionCycleError(VariableCompositionError): + """Circular reference detected during variable composition.""" + + +@dataclass +class ComposedReference: + """Metadata about a single <> that was encountered during expansion. + + This is a lightweight dataclass used to track composition results without + depending on ResolvedVariable, making it reusable from both the SDK and backend. + """ + + name: str + """Name of the referenced variable.""" + value: str | None + """Expanded raw string value, or None if unresolved.""" + label: str | None + """Label of the referenced variable's resolution.""" + version: int | None + """Version of the referenced variable's resolution.""" + reason: str + """Resolution reason (e.g., 'resolved', 'unrecognized_variable').""" + error: str | None = None + """Error message if the reference could not be expanded.""" + composed_from: list[ComposedReference] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + """Nested references that were expanded within this reference.""" + + +# resolve_fn signature: (ref_name) -> (serialized_value, label, version, reason) +ResolveFn = Callable[[str], Tuple[Optional[str], Optional[str], Optional[int], str]] # noqa: UP006 + + +def has_references(serialized_value: str) -> bool: + """Quick check for any unescaped ``<<`` in a serialized value.""" + return _HAS_ANGLE.search(serialized_value) is not None + + +def expand_references( + serialized_value: str, + variable_name: str, + resolve_fn: ResolveFn, + *, + _visited: frozenset[str] = frozenset(), + _depth: int = 0, +) -> tuple[str, list[ComposedReference]]: + """Expand <> references in a serialized variable value. + + Uses the Handlebars engine via character-swap so that ``<<>>`` supports the + full Handlebars feature set (``<<#if>>``, ``<<#each>>``, etc.) while + preserving ``{{runtime}}`` placeholders untouched. + + Args: + serialized_value: The raw JSON-serialized variable value. + variable_name: Name of the variable being expanded (for cycle detection). + resolve_fn: Function that resolves a variable name to + (serialized_value, label, version, reason). + _visited: Internal - set of variable names in the current expansion chain. + _depth: Internal - current recursion depth. + + Returns: + Tuple of (expanded_serialized_value, list_of_composed_references). + + Raises: + VariableCompositionError: If max depth is exceeded. + VariableCompositionCycleError: If a circular reference is detected. + """ + if _depth > MAX_COMPOSITION_DEPTH: + raise VariableCompositionError( + f'Maximum composition depth ({MAX_COMPOSITION_DEPTH}) exceeded ' + f"while expanding '{variable_name}'. This likely indicates a circular reference." + ) + + if variable_name in _visited: + raise VariableCompositionCycleError(f'Circular reference detected: {" -> ".join(_visited)} -> {variable_name}') + + visited = _visited | {variable_name} + composed: list[ComposedReference] = [] + + # JSON-decode the serialized value so we can work with actual strings. + try: + decoded = json.loads(serialized_value) + except (json.JSONDecodeError, TypeError): + return serialized_value, composed + + # Collect all unique base variable names referenced anywhere in the decoded value. + 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] = {} + unresolved_names: set[str] = set() + + for ref_name in all_ref_names: + ref_serialized, ref_label, ref_version, ref_reason = resolve_fn(ref_name) + + if ref_serialized is None: + composed.append( + ComposedReference( + name=ref_name, + value=None, + label=ref_label, + version=ref_version, + reason=ref_reason, + ) + ) + unresolved_names.add(ref_name) + continue + + # JSON-decode the referenced value. + try: + raw_value = json.loads(ref_serialized) + except (json.JSONDecodeError, TypeError): + composed.append( + ComposedReference( + name=ref_name, + value=None, + label=ref_label, + version=ref_version, + reason=ref_reason, + error=f"Referenced variable '{ref_name}' has a non-JSON serialized value.", + ) + ) + unresolved_names.add(ref_name) + continue + + # Recursively expand references within the resolved value (if it's a string). + nested_composed: list[ComposedReference] = [] + if isinstance(raw_value, str) and has_references(json.dumps(raw_value)): + try: + expanded_serialized, nested_composed = expand_references( + json.dumps(raw_value), + ref_name, + resolve_fn, + _visited=visited, + _depth=_depth + 1, + ) + raw_value = json.loads(expanded_serialized) + except VariableCompositionError as e: + composed.append( + ComposedReference( + name=ref_name, + value=None, + label=ref_label, + version=ref_version, + reason=ref_reason, + error=str(e), + ) + ) + unresolved_names.add(ref_name) + continue + + # Build the ComposedReference for this variable. + value_str: str | None + if isinstance(raw_value, str): + value_str = raw_value + else: + value_str = json.dumps(raw_value) + + composed.append( + ComposedReference( + name=ref_name, + value=value_str, + label=ref_label, + version=ref_version, + reason=ref_reason, + composed_from=nested_composed, + ) + ) + + context[ref_name] = raw_value + + # For unresolved variable names, add a self-referential context entry so that + # Handlebars renders <> back as literal "<>". The _protect_value + # function in render_once will entity-encode the <> characters in the value, + # preventing the swap from consuming them. + for name in unresolved_names: + context[name] = f'<<{name}>>' + + # Walk the decoded value and render each string through the Handlebars swap engine. + rendered = _render_value(decoded, context) + + result_serialized = json.dumps(rendered) + return result_serialized, composed + + +def find_references(serialized_value: str) -> list[str]: + """Find all <> references in a serialized value. + + Detects both simple ``<>`` and block ``<<#helper var>>`` patterns. + For dotted references like ``<>``, only the base variable name + (first segment) is returned. This ensures correct cycle detection and + reference graph building. + + Args: + serialized_value: The raw JSON-serialized variable value to scan. + + Returns: + List of unique variable names referenced, in order of first occurrence. + """ + seen: set[str] = set() + result: list[str] = [] + + # Simple references: <> or <> + for match in _SIMPLE_REF.finditer(serialized_value): + full_ref = match.group(1) + var_name = full_ref.split('.')[0] + if var_name not in seen and var_name not in _HBS_KEYWORDS: + seen.add(var_name) + result.append(var_name) + + # Block helper references: <<#if var>>, <<#each var>>, etc. + for match in _BLOCK_REF.finditer(serialized_value): + var_name = match.group(1) + if var_name not in seen and var_name not in _HBS_KEYWORDS: + seen.add(var_name) + result.append(var_name) + + return result + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _collect_ref_names(value: Any) -> list[str]: + """Recursively walk a decoded JSON value and collect all unique base variable names.""" + seen: set[str] = set() + result: list[str] = [] + + def _walk(v: Any) -> None: + if isinstance(v, str): + for match in _SIMPLE_REF.finditer(v): + full_ref = match.group(1) + name = full_ref.split('.')[0] + if name not in seen and name not in _HBS_KEYWORDS: + seen.add(name) + result.append(name) + for match in _BLOCK_REF.finditer(v): + name = match.group(1) + if name not in seen and name not in _HBS_KEYWORDS: + seen.add(name) + result.append(name) + elif isinstance(v, dict): + for val in v.values(): # pyright: ignore[reportUnknownVariableType] + _walk(val) + elif isinstance(v, list): + for item in v: # pyright: ignore[reportUnknownVariableType] + _walk(item) + + _walk(value) + return result + + +def _render_value(value: Any, context: dict[str, Any]) -> Any: + """Recursively walk a decoded JSON value, rendering strings through Handlebars. + + Unresolved variable names should already be present in the context as their + literal ``<>`` text so that Handlebars preserves them. + """ + if isinstance(value, str): + if not has_references(value): + # Unescape \<< to << for non-reference strings. + return value.replace('\\<<', '<<') + return render_once(value, context) + if isinstance(value, dict): + return {k: _render_value(v, context) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] + if isinstance(value, list): + return [_render_value(v, context) for v in value] # pyright: ignore[reportUnknownVariableType] + 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/logfire/variables/config.py b/logfire/variables/config.py index 50cdee7c1..f6650ed25 100644 --- a/logfire/variables/config.py +++ b/logfire/variables/config.py @@ -14,7 +14,7 @@ VariablesOptions as VariablesOptions, ) from logfire.variables.abstract import ResolvedVariable -from logfire.variables.variable import Variable +from logfire.variables.variable import _BaseVariable # pyright: ignore[reportPrivateUsage] try: from pydantic import Discriminator @@ -312,6 +312,12 @@ class VariableConfig(BaseModel): """Alternative names that resolve to this variable; useful for name migrations.""" example: str | None = None """JSON-serialized example value from code; used as a template when creating new values in the UI.""" + template_inputs_schema: dict[str, Any] | None = None + """JSON Schema describing the expected template inputs for Handlebars rendering. + + When set, the variable's values can contain {{placeholder}} Handlebars syntax. + The schema is derived from a Pydantic model passed as `template_inputs` to `logfire.var()`. + """ # NOTE: Context-based targeting_key can be set via targeting_context() from logfire.variables. # TODO(DavidM): Consider adding remotely-managed targeting_key_attribute for automatic attribute-based targeting. @@ -532,7 +538,7 @@ def _get_variable_config(self, name: VariableName) -> VariableConfig | None: return None - def get_validation_errors(self, variables: list[Variable[Any]]) -> dict[str, dict[str | None, Exception]]: + def get_validation_errors(self, variables: Sequence[_BaseVariable[Any]]) -> dict[str, dict[str | None, Exception]]: """Validate that all variable label values can be deserialized to their expected types. Args: @@ -565,7 +571,7 @@ def get_validation_errors(self, variables: list[Variable[Any]]) -> dict[str, dic return errors @staticmethod - def from_variables(variables: list[Variable[Any]]) -> VariablesConfig: + def from_variables(variables: Sequence[_BaseVariable[Any]]) -> VariablesConfig: """Create a VariablesConfig from a list of Variable instances. This creates a minimal config with just the name, schema, and example for each variable. @@ -589,6 +595,9 @@ def from_variables(variables: list[Variable[Any]]) -> VariablesConfig: if not is_resolve_function(variable.default): example = variable.type_adapter.dump_json(variable.default).decode('utf-8') + # Get template inputs schema if available + template_inputs_schema = variable.get_template_inputs_schema() + config = VariableConfig( name=variable.name, description=variable.description, @@ -597,6 +606,7 @@ def from_variables(variables: list[Variable[Any]]) -> VariablesConfig: overrides=[], json_schema=json_schema, example=example, + template_inputs_schema=template_inputs_schema, ) variable_configs[variable.name] = config diff --git a/logfire/variables/template_validation.py b/logfire/variables/template_validation.py new file mode 100644 index 000000000..39ecf8133 --- /dev/null +++ b/logfire/variables/template_validation.py @@ -0,0 +1,206 @@ +"""Template validation: check ``{{field}}`` references against ``template_inputs_schema``. + +This module validates that Handlebars ``{{field}}`` references in template variable +values (including composed ``<>`` dependencies) match the declared +``template_inputs_schema``. It uses ``pydantic_handlebars.check_template_compatibility`` +for full AST-based schema checking (nested paths, block scopes, helpers). + +It also provides cycle detection for composition graphs. + +Used by both the SDK and the backend for pre-write validation. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from pydantic_handlebars import check_template_compatibility + +from logfire.variables.composition import find_references + +__all__ = ( + 'TemplateFieldIssue', + 'TemplateValidationResult', + 'validate_template_composition', + 'detect_composition_cycles', + 'find_template_fields', +) + +# Matches {{identifier}} — simple Handlebars variable references. +# Excludes block helpers ({{#if}}), closing tags ({{/if}}), partials ({{> name}}), +# comments ({{! text}}), and triple-stache ({{{raw}}}). +TEMPLATE_FIELD_PATTERN = re.compile(r'\{\{\s*([a-zA-Z_]\w*)\s*\}\}') + + +@dataclass +class TemplateFieldIssue: + """A ``{{field}}`` reference that doesn't match the variable's ``template_inputs_schema``.""" + + field_name: str + """The template field name (e.g., ``user_name`` from ``{{user_name}}``).""" + found_in_variable: str + """Name of the variable whose value contains this field reference.""" + found_in_label: str | None + """Label of the value where the field was found, or ``None`` for the latest version.""" + reference_path: list[str] + """Composition path from the root variable to ``found_in_variable``.""" + + +@dataclass +class TemplateValidationResult: + """Result of template composition validation.""" + + issues: list[TemplateFieldIssue] = field(default_factory=list[TemplateFieldIssue]) + + +def find_template_fields(text: str) -> set[str]: + """Find all ``{{field}}`` references in a string. + + Returns: + Set of field names found in the text. + """ + return set(TEMPLATE_FIELD_PATTERN.findall(text)) + + +def _extract_template_strings(serialized_json: str) -> list[str]: + """Extract all string values from serialized JSON that contain ``{{...}}`` templates.""" + try: + decoded = json.loads(serialized_json) + except (json.JSONDecodeError, TypeError): + # If it's not valid JSON, treat the raw string as a potential template + if '{{' in serialized_json: + return [serialized_json] + return [] + return _collect_template_strings(decoded) + + +def _collect_template_strings(value: Any) -> list[str]: + """Recursively collect strings containing ``{{...}}`` from a decoded JSON value.""" + if isinstance(value, str): + return [value] if '{{' in value else [] + if isinstance(value, dict): + result: list[str] = [] + for v in value.values(): # pyright: ignore[reportUnknownVariableType] + result.extend(_collect_template_strings(v)) + return result + if isinstance(value, list): + result = [] + for item in value: # pyright: ignore[reportUnknownVariableType] + result.extend(_collect_template_strings(item)) + return result + return [] + + +def validate_template_composition( + variable_name: str, + template_inputs_schema: dict[str, Any], + get_all_serialized_values: Callable[[str], dict[str | None, str]], +) -> TemplateValidationResult: + """Validate that ``{{field}}`` references in a template variable match its schema. + + Walks the composition graph starting from *variable_name*, collecting all + template strings from the variable's values and its ``<>`` dependencies, + then uses AST-based schema checking via ``check_template_compatibility`` to + find incompatible field references. + + Args: + variable_name: Name of the template variable to validate. + template_inputs_schema: JSON Schema describing the expected template inputs. + get_all_serialized_values: Function that returns ``{label_or_none: serialized_json}`` + for any variable name. ``None`` key represents the latest version. + + Returns: + A :class:`TemplateValidationResult` with any issues found. + """ + issues: list[TemplateFieldIssue] = [] + seen_issues: set[tuple[str, str, str | None]] = set() + + def _collect(name: str, path: list[str], visited: frozenset[str]) -> None: + if name in visited: + return + visited = visited | {name} + + for label, serialized_value in get_all_serialized_values(name).items(): + templates = _extract_template_strings(serialized_value) + if not templates: + for ref in find_references(serialized_value): + _collect(ref, path + [ref], visited) + continue + + result = check_template_compatibility(templates, template_inputs_schema) + for issue in result.issues: + if issue.severity != 'error': + continue + key = (issue.field_path, name, label) + if key not in seen_issues: + seen_issues.add(key) + issues.append( + TemplateFieldIssue( + field_name=issue.field_path, + found_in_variable=name, + found_in_label=label, + reference_path=list(path), + ) + ) + + for ref in find_references(serialized_value): + _collect(ref, path + [ref], visited) + + _collect(variable_name, [], frozenset()) + + return TemplateValidationResult(issues=issues) + + +def detect_composition_cycles( + variable_name: str, + new_references: set[str], + get_all_references: Callable[[str], set[str]], +) -> list[str] | None: + """Check if adding *new_references* to *variable_name* would create a cycle. + + Args: + variable_name: The variable being updated. + new_references: Set of variable names directly referenced by the new value. + get_all_references: Function that returns all variable names referenced by + any value of the given variable name. + + Returns: + The cycle path (e.g., ``['A', 'B', 'C', 'A']``) if a cycle is detected, + or ``None`` if no cycle exists. + """ + for ref in sorted(new_references): # sort for deterministic results + path = _find_cycle(variable_name, ref, get_all_references, frozenset()) + if path is not None: + return path + return None + + +def _find_cycle( + target: str, + current: str, + get_all_references: Callable[[str], set[str]], + visited: frozenset[str], + path: list[str] | None = None, +) -> list[str] | None: + """DFS to find a path from *current* back to *target*.""" + if path is None: + path = [target, current] + + if current == target: + return path + + if current in visited: + return None + + visited = visited | {current} + + for ref in sorted(get_all_references(current)): # sort for deterministic results + result = _find_cycle(target, ref, get_all_references, visited, path + [ref]) + if result is not None: + return result + + return None diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 5b9a5a382..318804db9 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -1,7 +1,8 @@ from __future__ import annotations as _annotations import inspect -from collections.abc import Iterator, Mapping, Sequence +import json +from collections.abc import Callable, Iterator, Mapping, Sequence from contextlib import ExitStack, contextmanager from contextvars import ContextVar from dataclasses import dataclass, field, replace @@ -10,9 +11,18 @@ from opentelemetry.trace import get_current_span from pydantic import TypeAdapter, ValidationError +from pydantic_handlebars import HandlebarsError from typing_extensions import TypeIs +from logfire.variables.composition import ( + ComposedReference, + VariableCompositionError, + expand_references, + has_references, +) + if TYPE_CHECKING: + from logfire.variables.abstract import VariableProvider from logfire.variables.config import VariableConfig if find_spec('anyio') is not None: # pragma: no branch @@ -27,11 +37,14 @@ __all__ = ( 'ResolveFunction', 'is_resolve_function', + '_BaseVariable', 'Variable', + 'TemplateVariable', 'targeting_context', ) T_co = TypeVar('T_co', covariant=True) +InputsT = TypeVar('InputsT') _VARIABLE_OVERRIDES: ContextVar[dict[str, Any] | None] = ContextVar('_VARIABLE_OVERRIDES', default=None) @@ -115,8 +128,12 @@ def is_resolve_function(f: Any) -> TypeIs[ResolveFunction[Any]]: return required_positional <= 2 and total_positional >= 2 -class Variable(Generic[T_co]): - """A managed variable that can be resolved dynamically based on configuration.""" +class _BaseVariable(Generic[T_co]): + """Base class for managed variables with shared resolution infrastructure. + + Contains all shared logic: init, deserialization, override, refresh, config, + resolution pipeline. Subclasses (Variable, TemplateVariable) add their own get() method. + """ name: str """Unique name identifying this variable.""" @@ -126,6 +143,8 @@ class Variable(Generic[T_co]): """Default value or function to compute the default.""" description: str | None """Description of the variable.""" + template_inputs_type: type[Any] | None + """The Pydantic model type for template inputs, if template rendering is enabled.""" logfire_instance: logfire.Logfire """The Logfire instance this variable is associated with.""" @@ -137,6 +156,7 @@ def __init__( type: type[T_co], default: T_co | ResolveFunction[T_co], description: str | None = None, + template_inputs: type[Any] | None = None, logfire_instance: logfire.Logfire, ): """Create a new managed variable. @@ -147,21 +167,35 @@ def __init__( default: Default value to use when no configuration is found, or a function that computes the default based on targeting_key and attributes. description: Optional human-readable description of what this variable controls. + template_inputs: Optional Pydantic model type describing the expected template inputs + for Handlebars rendering. When set, values can contain ``{{placeholder}}`` syntax. logfire_instance: The Logfire instance this variable is associated with. Used to determine config, etc. """ self.name = name self.value_type = type self.default = default self.description = description + self.template_inputs_type = template_inputs self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='variables') self.type_adapter = TypeAdapter[T_co](type) - def _deserialize(self, serialized_value: str) -> T_co | Exception: + if template_inputs is not None: + self._template_inputs_adapter: TypeAdapter[Any] | None = TypeAdapter(template_inputs) + else: + self._template_inputs_adapter = None + + def get_template_inputs_schema(self) -> dict[str, Any] | None: + """Return the JSON schema for template inputs, or None if not configured.""" + if self._template_inputs_adapter is not None: + return self._template_inputs_adapter.json_schema() + return None + + def _deserialize(self, serialized_value: str) -> T_co | ValidationError | ValueError: """Deserialize a JSON string to the variable's type, returning an Exception on failure.""" try: return self.type_adapter.validate_json(serialized_value) - except Exception as e: + except (ValidationError, ValueError) as e: return e @contextmanager @@ -187,82 +221,13 @@ def refresh_sync(self, force: bool = False): """Synchronously refresh the variable.""" self.logfire_instance.config.get_variable_provider().refresh(force=force) - def get( - self, - targeting_key: str | None = None, - attributes: Mapping[str, Any] | None = None, - *, - label: str | None = None, - ) -> ResolvedVariable[T_co]: - """Resolve the variable and return full details including label, version, and any errors. - - Args: - targeting_key: Optional key for deterministic label selection (e.g., user ID). - If not provided, falls back to contextvar targeting key (set via targeting_context), - then to the current trace ID if there is an active trace. - attributes: Optional attributes for condition-based targeting rules. - label: Optional explicit label name to select. If provided, bypasses rollout - weights and targeting, directly selecting the specified label. If the label - doesn't exist in the configuration, falls back to default resolution. - - Returns: - A ResolvedVariable object containing the resolved value, selected label, - version, and any errors that occurred. - """ - merged_attributes = self._get_merged_attributes(attributes) - - # Targeting key resolution: call-site > contextvar > trace_id - if targeting_key is None: - targeting_key = _get_contextvar_targeting_key(self.name) - - if targeting_key is None and (current_trace_id := get_current_span().get_span_context().trace_id): - # If there is no active trace, the current_trace_id will be zero - targeting_key = f'trace_id:{current_trace_id:032x}' - - # Include the variable name directly here to make the span name more useful, - # it'll still be low cardinality. This also prevents it from being scrubbed from the message. - # Don't inline the f-string to avoid f-string magic. - span_name = f'Resolve variable {self.name}' - with ExitStack() as stack: - span: logfire.LogfireSpan | None = None - if _get_variables_instrument(self.logfire_instance.config.variables): - span = stack.enter_context( - self.logfire_instance.span( - span_name, - name=self.name, - targeting_key=targeting_key, - attributes=merged_attributes, - ) - ) - result = self._resolve(targeting_key, merged_attributes, span, label) - if span is not None: - # Serialize value safely for OTel span attributes, which only support primitives. - # Try to JSON serialize the value; if that fails, fall back to string representation. - try: - serialized_value = self.type_adapter.dump_json(result.value).decode('utf-8') - except Exception: - serialized_value = repr(result.value) - span.set_attributes( - { - 'name': result.name, - 'value': serialized_value, - 'label': result.label, - 'version': result.version, - 'reason': result._reason, # pyright: ignore[reportPrivateUsage] - } - ) - if result.exception: - span.record_exception( - result.exception, - ) - return result - def _resolve( self, targeting_key: str | None, attributes: Mapping[str, Any] | None, span: logfire.LogfireSpan | None, label: str | None = None, + render_fn: Callable[[str], str] | None = None, ) -> ResolvedVariable[T_co]: serialized_result: ResolvedVariable[str | None] | None = None try: @@ -270,6 +235,10 @@ def _resolve( context_value = context_overrides[self.name] if is_resolve_function(context_value): context_value = context_value(targeting_key, attributes) + # For TemplateVariable (render_fn set), the override is a template + # that still gets rendered with inputs. + if render_fn is not None: + context_value = self._render_default(context_value, render_fn) return ResolvedVariable(name=self.name, value=context_value, _reason='context_override') provider = self.logfire_instance.config.get_variable_provider() @@ -278,21 +247,8 @@ def _resolve( if label is not None: serialized_result = provider.get_serialized_value_for_label(self.name, label) if serialized_result.value is not None: - # Successfully got the explicit label - value_or_exc = self._deserialize(serialized_result.value) - if isinstance(value_or_exc, Exception): - if span: # pragma: no branch - span.set_attribute('invalid_serialized_label', serialized_result.label) - span.set_attribute('invalid_serialized_value', serialized_result.value) - default = self._get_default(targeting_key, attributes) - reason: str = 'validation_error' if isinstance(value_or_exc, ValidationError) else 'other_error' - return ResolvedVariable(name=self.name, value=default, exception=value_or_exc, _reason=reason) - return ResolvedVariable( - name=self.name, - value=value_or_exc, - label=serialized_result.label, - version=serialized_result.version, - _reason='resolved', + return self._expand_and_deserialize( + serialized_result, provider, targeting_key, attributes, span, render_fn=render_fn ) # Label not found - fall through to default resolution @@ -300,33 +256,136 @@ def _resolve( if serialized_result.value is None: default = self._get_default(targeting_key, attributes) + if render_fn is not None: + default = self._render_default(default, render_fn) return _with_value(serialized_result, default) - # Deserialize - returns T | Exception - value_or_exc = self._deserialize(serialized_result.value) - if isinstance(value_or_exc, Exception): - if span: # pragma: no branch - span.set_attribute('invalid_serialized_label', serialized_result.label) - span.set_attribute('invalid_serialized_value', serialized_result.value) - default = self._get_default(targeting_key, attributes) - reason: str = 'validation_error' if isinstance(value_or_exc, ValidationError) else 'other_error' - return ResolvedVariable(name=self.name, value=default, exception=value_or_exc, _reason=reason) - - return ResolvedVariable( - name=self.name, - value=value_or_exc, - label=serialized_result.label, - version=serialized_result.version, - _reason='resolved', + return self._expand_and_deserialize( + serialized_result, provider, targeting_key, attributes, span, render_fn=render_fn ) - except Exception as e: + except ( # Safety net: providers and resolve functions are user-defined and may raise any of these + ValidationError, + ValueError, + TypeError, + KeyError, + AttributeError, + RuntimeError, + OSError, + HandlebarsError, + VariableCompositionError, + ) as e: if span and serialized_result is not None: # pragma: no cover span.set_attribute('invalid_serialized_label', serialized_result.label) span.set_attribute('invalid_serialized_value', serialized_result.value) default = self._get_default(targeting_key, attributes) return ResolvedVariable(name=self.name, value=default, exception=e, _reason='other_error') + def _render_default(self, default: Any, render_fn: Callable[[str], str]) -> T_co: + """Serialize the default value, apply render_fn, then deserialize back.""" + try: + serialized = self.type_adapter.dump_json(default).decode('utf-8') + rendered = render_fn(serialized) + result = self._deserialize(rendered) + if isinstance(result, (ValidationError, ValueError)): + raise result + return result + except (ValidationError, ValueError, TypeError, HandlebarsError): + # If rendering the default fails, return the original default + return default + + def _expand_and_deserialize( + self, + serialized_result: ResolvedVariable[str | None], + provider: VariableProvider, + targeting_key: str | None, + attributes: Mapping[str, Any] | None, + span: logfire.LogfireSpan | None, + render_fn: Callable[[str], str] | None = None, + ) -> ResolvedVariable[T_co]: + """Expand <> in a serialized value, optionally render templates, then deserialize. + + Handles composition between the provider fetch and Pydantic deserialization. + When render_fn is provided, it is applied after composition and before deserialization. + """ + assert serialized_result.value is not None + + serialized_value = serialized_result.value + composed: list[ComposedReference] = [] + + # Expand <> if any are present + if has_references(serialized_value): + + def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str]: + ref_resolved = provider.get_serialized_value(ref_name, targeting_key, attributes) + return ( + ref_resolved.value, + ref_resolved.label, + ref_resolved.version, + ref_resolved._reason, # pyright: ignore[reportPrivateUsage] + ) + + try: + serialized_value, composed = expand_references( + serialized_value, + self.name, + resolve_ref, + ) + except VariableCompositionError as e: + default = self._get_default(targeting_key, attributes) + return ResolvedVariable( + name=self.name, + value=default, + exception=e, + _reason='other_error', + label=serialized_result.label, + version=serialized_result.version, + composed_from=composed, + ) + + # Apply render_fn (template rendering) if provided + if render_fn is not None: + try: + serialized_value = render_fn(serialized_value) + except (HandlebarsError, ValueError, TypeError) as e: + default = self._get_default(targeting_key, attributes) + return ResolvedVariable( + name=self.name, + value=default, + exception=e, + _reason='other_error', + label=serialized_result.label, + version=serialized_result.version, + composed_from=composed, + ) + + # Deserialize the (possibly expanded/rendered) value + value_or_exc = self._deserialize(serialized_value) + if isinstance(value_or_exc, Exception): + if span: # pragma: no branch + span.set_attribute('invalid_serialized_label', serialized_result.label) + span.set_attribute('invalid_serialized_value', serialized_value) + default = self._get_default(targeting_key, attributes) + reason: str = 'validation_error' if isinstance(value_or_exc, ValidationError) else 'other_error' + return ResolvedVariable( + name=self.name, + value=default, + exception=value_or_exc, + _reason=reason, + composed_from=composed, + ) + + return ResolvedVariable( + name=self.name, + value=value_or_exc, + label=serialized_result.label, + version=serialized_result.version, + _reason='resolved', + composed_from=composed, + _serialized_value=serialized_value, + _deserializer=self._deserialize, + ) + def _get_default( self, targeting_key: str | None = None, merged_attributes: Mapping[str, Any] | None = None ) -> T_co: @@ -374,6 +433,10 @@ def to_config(self) -> VariableConfig: if not is_resolve_function(self.default): example = self.type_adapter.dump_json(self.default).decode('utf-8') + template_inputs_schema: dict[str, Any] | None = None + if self._template_inputs_adapter is not None: + template_inputs_schema = self._template_inputs_adapter.json_schema() + return VariableConfig( name=self.name, description=self.description, @@ -382,8 +445,191 @@ def to_config(self) -> VariableConfig: overrides=[], json_schema=json_schema, example=example, + template_inputs_schema=template_inputs_schema, ) + def _get_result_and_record_span( + self, + targeting_key: str | None, + attributes: Mapping[str, Any] | None, + label: str | None, + render_fn: Callable[[str], str] | None = None, + ) -> ResolvedVariable[T_co]: + """Common get() logic: resolve targeting key, open span, call _resolve, record attributes.""" + merged_attributes = self._get_merged_attributes(attributes) + + # Targeting key resolution: call-site > contextvar > trace_id + if targeting_key is None: + targeting_key = _get_contextvar_targeting_key(self.name) + + if targeting_key is None and (current_trace_id := get_current_span().get_span_context().trace_id): + # If there is no active trace, the current_trace_id will be zero + targeting_key = f'trace_id:{current_trace_id:032x}' + + # Include the variable name directly here to make the span name more useful, + # it'll still be low cardinality. This also prevents it from being scrubbed from the message. + # Don't inline the f-string to avoid f-string magic. + span_name = f'Resolve variable {self.name}' + with ExitStack() as stack: + span: logfire.LogfireSpan | None = None + if _get_variables_instrument(self.logfire_instance.config.variables): + span = stack.enter_context( + self.logfire_instance.span( + span_name, + name=self.name, + targeting_key=targeting_key, + attributes=merged_attributes, + ) + ) + result = self._resolve(targeting_key, merged_attributes, span, label, render_fn=render_fn) + # Ensure rendering support is always available + if result._deserializer is None: # pyright: ignore[reportPrivateUsage] + result._deserializer = self._deserialize # pyright: ignore[reportPrivateUsage] + if result._serialized_value is None and result.value is not None: # pyright: ignore[reportPrivateUsage] + try: + result._serialized_value = self.type_adapter.dump_json(result.value).decode('utf-8') # pyright: ignore[reportPrivateUsage] + except (ValueError, TypeError, RuntimeError): + pass + if span is not None: + # Serialize value safely for OTel span attributes, which only support primitives. + # Try to JSON serialize the value; if that fails, fall back to string representation. + try: + serialized_value = self.type_adapter.dump_json(result.value).decode('utf-8') + except (ValueError, TypeError, RuntimeError): + serialized_value = repr(result.value) + attrs: dict[str, Any] = { + 'name': result.name, + 'value': serialized_value, + 'label': result.label, + 'version': result.version, + 'reason': result._reason, # pyright: ignore[reportPrivateUsage] + } + if result.composed_from: + attrs['composed_from'] = json.dumps( + [ + { + 'name': c.name, + 'version': c.version, + 'label': c.label, + 'reason': c.reason, + 'error': c.error, + } + for c in result.composed_from + ] + ) + span.set_attributes(attrs) + if result.exception: + span.record_exception( + result.exception, + ) + return result + + +class Variable(_BaseVariable[T_co]): + """A managed variable that can be resolved dynamically based on configuration.""" + + def get( + self, + targeting_key: str | None = None, + attributes: Mapping[str, Any] | None = None, + *, + label: str | None = None, + ) -> ResolvedVariable[T_co]: + """Resolve the variable and return full details including label, version, and any errors. + + Args: + targeting_key: Optional key for deterministic label selection (e.g., user ID). + If not provided, falls back to contextvar targeting key (set via targeting_context), + then to the current trace ID if there is an active trace. + attributes: Optional attributes for condition-based targeting rules. + label: Optional explicit label name to select. If provided, bypasses rollout + weights and targeting, directly selecting the specified label. If the label + doesn't exist in the configuration, falls back to default resolution. + + Returns: + A ResolvedVariable object containing the resolved value, selected label, + version, and any errors that occurred. + """ + return self._get_result_and_record_span(targeting_key, attributes, label) + + +class TemplateVariable(_BaseVariable[T_co], Generic[T_co, InputsT]): + """A managed variable with integrated template rendering. + + Like ``Variable``, but ``get()`` requires ``inputs`` and automatically renders + Handlebars ``{{placeholder}}`` templates in the resolved value before returning. + The pipeline is: resolve → compose ``<>`` → render ``{{}}`` → deserialize. + """ + + inputs_type: type[InputsT] + """The type used for template inputs.""" + + def __init__( + self, + name: str, + *, + type: type[T_co], + default: T_co | ResolveFunction[T_co], + inputs_type: type[InputsT], + description: str | None = None, + logfire_instance: logfire.Logfire, + ): + """Create a new template variable. + + Args: + name: Unique name identifying this variable. + type: The expected type of this variable's values, used for validation. + default: Default value to use when no configuration is found, or a function + that computes the default based on targeting_key and attributes. + 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 this variable controls. + logfire_instance: The Logfire instance this variable is associated with. + """ + super().__init__( + name, + type=type, + default=default, + description=description, + template_inputs=inputs_type, + logfire_instance=logfire_instance, + ) + self.inputs_type = inputs_type + + def get( + self, + inputs: InputsT, + targeting_key: str | None = None, + attributes: Mapping[str, Any] | None = None, + *, + label: str | None = None, + ) -> ResolvedVariable[T_co]: + """Resolve the variable, render templates with the given inputs, and return the result. + + The resolution pipeline is: + 1. Fetch serialized value from provider (or use default) + 2. Expand ``<>`` composition references + 3. Render ``{{placeholder}}`` Handlebars templates using ``inputs`` + 4. Deserialize to the variable's type + + Args: + inputs: Template context values. Typically a Pydantic ``BaseModel`` instance + matching ``inputs_type``. All ``{{placeholder}}`` expressions in the value + are rendered using this context. + targeting_key: Optional key for deterministic label selection (e.g., user ID). + attributes: Optional attributes for condition-based targeting rules. + label: Optional explicit label name to select. + + Returns: + A ResolvedVariable with the fully rendered and deserialized value. + """ + from logfire.variables.abstract import render_serialized_string + + def _render_fn(serialized_json: str) -> str: + return render_serialized_string(serialized_json, inputs) + + return self._get_result_and_record_span(targeting_key, attributes, label, render_fn=_render_fn) + def _with_value(details: ResolvedVariable[Any], new_value: T_co) -> ResolvedVariable[T_co]: """Return a copy of the provided resolution details, just with a different value. @@ -401,7 +647,7 @@ def _with_value(details: ResolvedVariable[Any], new_value: T_co) -> ResolvedVari @contextmanager def targeting_context( targeting_key: str, - variables: Sequence[Variable[Any]] | None = None, + variables: Sequence[Variable[Any] | TemplateVariable[Any, Any]] | None = None, ) -> Iterator[None]: """Set the targeting key for variable resolution within this context. diff --git a/mkdocs.yml b/mkdocs.yml index da23654bb..690e196ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -208,6 +208,7 @@ nav: - Managed Variables: - Overview: reference/advanced/managed-variables/index.md - UI Guide: reference/advanced/managed-variables/ui.md + - Templates & Composition: reference/advanced/managed-variables/templates-and-composition.md - A/B Testing: reference/advanced/managed-variables/ab-testing.md - Targeting: reference/advanced/managed-variables/targeting.md - Remote Variables: reference/advanced/managed-variables/remote.md diff --git a/pyproject.toml b/pyproject.toml index 8adec8d49..4e0b8abd9 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; python_version >= '3.10'"] -variables = ["pydantic>=2"] +variables = ["pydantic>=2", "pydantic-handlebars>=0.1.0; python_version >= '3.10'"] [project.urls] Homepage = "https://logfire.pydantic.dev/" @@ -197,6 +197,7 @@ dev = [ "pytest-examples>=0.0.18", "pytest-timeout>=2.4.0", "pytest-asyncio>=0.24.0", + "pydantic-handlebars>=0.1.0; python_version >= '3.10'", ] docs = [ "black>=23.12.0", diff --git a/tests/test_template_validation.py b/tests/test_template_validation.py new file mode 100644 index 000000000..1486ddfb3 --- /dev/null +++ b/tests/test_template_validation.py @@ -0,0 +1,577 @@ +"""Tests for template_validation: {{field}} validation and cycle detection.""" + +# pyright: reportPrivateUsage=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from __future__ import annotations + +from logfire.variables.template_validation import ( + TemplateFieldIssue, + TemplateValidationResult, + _extract_template_strings, + detect_composition_cycles, + find_template_fields, + validate_template_composition, +) + +# ============================================================================= +# find_template_fields +# ============================================================================= + + +class TestFindTemplateFields: + def test_simple_field(self): + assert find_template_fields('Hello {{name}}!') == {'name'} + + def test_multiple_fields(self): + result = find_template_fields('{{greeting}} {{name}}, age {{age}}') + assert result == {'greeting', 'name', 'age'} + + def test_duplicate_fields(self): + """Duplicate fields produce a single entry in the set.""" + result = find_template_fields('{{name}} and {{name}} again') + assert result == {'name'} + + def test_empty_string(self): + assert find_template_fields('') == set() + + def test_no_templates(self): + assert find_template_fields('Hello world, no templates here') == set() + + def test_ignores_block_helpers(self): + """{{#if condition}} is a single block; not matched as {{identifier}}.""" + result = find_template_fields('{{#if condition}}yes{{/if}}') + # The entire {{#if condition}} has # after {{ so it doesn't match + assert result == set() + + def test_block_helper_hash_excluded(self): + """{{#if}} has a # prefix so the identifier doesn't start with [a-zA-Z_].""" + result = find_template_fields('{{#if}}content{{/if}}') + assert 'if' not in result + assert result == set() + + def test_closing_tag_excluded(self): + """{{/if}} has a / prefix so it won't match.""" + result = find_template_fields('{{/if}}') + assert result == set() + + def test_partial_excluded(self): + """{{> partial}} has a > prefix so it won't match.""" + result = find_template_fields('{{> myPartial}}') + assert result == set() + + def test_comment_excluded(self): + """{{! comment}} has a ! prefix so it won't match.""" + result = find_template_fields('{{! this is a comment}}') + assert result == set() + + def test_triple_stache_not_matched(self): + """{{{raw}}} — the outer braces don't form a valid {{identifier}} match.""" + # {{{raw}}} is 3 opening braces + raw + 3 closing braces + # The regex looks for {{ identifier }}, so {{{ would have an extra { before the identifier + result = find_template_fields('{{{raw}}}') + # The regex matches {{raw}} inside {{{raw}}}, leaving extra braces. + # Actually {{ raw }} is a valid match embedded in {{{ raw }}} + # Let's just verify empirically. + assert 'raw' in result # {{raw}} is still matched within {{{raw}}} + + def test_field_with_spaces(self): + """Spaces inside {{ field }} are allowed by the regex.""" + result = find_template_fields('{{ name }}') + assert result == {'name'} + + def test_field_with_underscore(self): + result = find_template_fields('{{user_name}}') + assert result == {'user_name'} + + def test_field_with_digits(self): + result = find_template_fields('{{item1}}') + assert result == {'item1'} + + def test_field_starting_with_underscore(self): + result = find_template_fields('{{_private}}') + assert result == {'_private'} + + def test_mixed_valid_and_invalid(self): + """Valid {{field}} mixed with helpers and partials.""" + text = '{{name}} {{#if active}}{{role}}{{/if}} {{> footer}} {{! ignored}}' + result = find_template_fields(text) + assert 'name' in result + assert 'role' in result + # Helpers, closing tags, partials, and comments should not appear + assert '#if' not in result + assert '/if' not in result + assert '> footer' not in result + assert '! ignored' not in result + + +# ============================================================================= +# _extract_template_strings +# ============================================================================= + + +class TestExtractTemplateStrings: + def test_json_string(self): + """JSON string value like '"Hello {{name}}"'.""" + result = _extract_template_strings('"Hello {{name}}"') + assert result == ['Hello {{name}}'] + + def test_json_object(self): + """JSON object with multiple string values containing fields.""" + result = _extract_template_strings('{"key": "Hello {{name}}", "other": "{{age}}"}') + assert result == ['Hello {{name}}', '{{age}}'] + + def test_json_array(self): + """JSON array with string values containing fields.""" + result = _extract_template_strings('["{{a}}", "{{b}}"]') + assert result == ['{{a}}', '{{b}}'] + + def test_invalid_json_falls_back_to_plain_text(self): + """Invalid JSON with templates falls back to the raw string.""" + result = _extract_template_strings('not valid json {{field}}') + assert result == ['not valid json {{field}}'] + + def test_invalid_json_no_templates(self): + """Invalid JSON without templates returns empty list.""" + result = _extract_template_strings('not valid json no templates') + assert result == [] + + def test_json_number_no_fields(self): + """JSON number value has no template strings.""" + result = _extract_template_strings('42') + assert result == [] + + def test_json_boolean_no_fields(self): + """JSON boolean value has no template strings.""" + result = _extract_template_strings('true') + assert result == [] + + def test_json_null_no_fields(self): + """JSON null value has no template strings.""" + result = _extract_template_strings('null') + assert result == [] + + def test_nested_json_object(self): + """Nested JSON objects have their string values collected.""" + result = _extract_template_strings('{"outer": {"inner": "{{deep}}"}}') + assert result == ['{{deep}}'] + + def test_mixed_types_in_object(self): + """Object with mixed types: only strings with templates are collected.""" + result = _extract_template_strings('{"text": "{{name}}", "count": 42, "active": true, "nothing": null}') + assert result == ['{{name}}'] + + def test_array_with_mixed_types(self): + """Array with mixed types: only template strings collected.""" + result = _extract_template_strings('["{{a}}", 42, true, null, "{{b}}"]') + assert result == ['{{a}}', '{{b}}'] + + def test_empty_json_string(self): + result = _extract_template_strings('""') + assert result == [] + + def test_empty_json_object(self): + result = _extract_template_strings('{}') + assert result == [] + + def test_empty_json_array(self): + result = _extract_template_strings('[]') + assert result == [] + + def test_deeply_nested_structure(self): + """Deeply nested JSON structure with template strings at various levels.""" + result = _extract_template_strings('{"a": [{"b": "{{x}}"}, {"c": ["{{y}}", {"d": "{{z}}"}]}]}') + assert result == ['{{x}}', '{{y}}', '{{z}}'] + + def test_string_without_templates_excluded(self): + """Strings without {{}} are not collected.""" + result = _extract_template_strings('{"a": "no templates", "b": "has {{one}}"}') + assert result == ['has {{one}}'] + + +# ============================================================================= +# validate_template_composition +# ============================================================================= + + +def _make_get_all_serialized( + data: dict[str, dict[str | None, str]], +) -> ...: + """Helper: build get_all_serialized_values from a simple mapping.""" + + def get_all_serialized_values(name: str) -> dict[str | None, str]: + return data.get(name, {}) + + return get_all_serialized_values + + +class TestValidateTemplateComposition: + def test_all_fields_valid(self): + """All {{field}} references match schema properties — no issues.""" + schema = {'properties': {'name': {'type': 'string'}, 'age': {'type': 'integer'}}} + get_values = _make_get_all_serialized( + { + 'my_var': {None: '"Hello {{name}}, you are {{age}}"'}, + } + ) + result = validate_template_composition('my_var', schema, get_values) + assert result.issues == [] + + def test_field_not_in_schema(self): + """A {{field}} not in schema properties produces an issue.""" + schema = {'properties': {'name': {'type': 'string'}}} + get_values = _make_get_all_serialized( + { + 'my_var': {None: '"Hello {{name}} {{unknown}}"'}, + } + ) + result = validate_template_composition('my_var', schema, get_values) + assert len(result.issues) == 1 + issue = result.issues[0] + assert issue.field_name == 'unknown' + assert issue.found_in_variable == 'my_var' + assert issue.found_in_label is None + assert issue.reference_path == [] + + def test_transitive_reference_issue(self): + """var_a references <>, var_b has {{field}} not in var_a's schema.""" + schema = {'properties': {'name': {'type': 'string'}}} + get_values = _make_get_all_serialized( + { + 'var_a': {None: '"Hello {{name}} <>"'}, + 'var_b': {None: '"extra {{bad_field}}"'}, + } + ) + result = validate_template_composition('var_a', schema, get_values) + assert len(result.issues) == 1 + issue = result.issues[0] + assert issue.field_name == 'bad_field' + assert issue.found_in_variable == 'var_b' + assert issue.reference_path == ['var_b'] + + def test_multiple_labels(self): + """Issues across multiple labels (None for latest, 'prod' for labeled).""" + schema = {'properties': {'x': {'type': 'string'}}} + get_values = _make_get_all_serialized( + { + 'my_var': { + None: '"{{x}} {{bad1}}"', + 'prod': '"{{x}} {{bad2}}"', + }, + } + ) + result = validate_template_composition('my_var', schema, get_values) + assert len(result.issues) == 2 + field_names = {i.field_name for i in result.issues} + assert field_names == {'bad1', 'bad2'} + labels = {i.found_in_label for i in result.issues} + assert labels == {None, 'prod'} + + def test_cycle_does_not_infinite_loop(self): + """A cycle in composition references terminates without infinite recursion.""" + schema = {'properties': {}} + get_values = _make_get_all_serialized( + { + 'a': {None: '"<>"'}, + 'b': {None: '"<>"'}, + } + ) + # Should complete without hanging + result = validate_template_composition('a', schema, get_values) + # No fields to report as issues — the cycle just stops traversal + assert isinstance(result, TemplateValidationResult) + + def test_no_template_fields(self): + """Variable with no {{}} fields produces no issues.""" + schema = {'properties': {'name': {'type': 'string'}}} + get_values = _make_get_all_serialized( + { + 'my_var': {None: '"Hello world"'}, + } + ) + result = validate_template_composition('my_var', schema, get_values) + assert result.issues == [] + + def test_empty_schema_no_restrictions(self): + """With empty properties, any field is allowed (no declared properties to conflict with).""" + schema = {'properties': {}} + get_values = _make_get_all_serialized( + { + 'my_var': {None: '"{{a}} {{b}}"'}, + } + ) + result = validate_template_composition('my_var', schema, get_values) + assert result.issues == [] + + def test_unknown_fields_with_declared_properties(self): + """When schema declares properties, unlisted fields are issues.""" + schema = {'properties': {'x': {'type': 'string'}}} + get_values = _make_get_all_serialized( + { + 'my_var': {None: '"{{a}} {{b}}"'}, + } + ) + result = validate_template_composition('my_var', schema, get_values) + assert len(result.issues) == 2 + field_names = {i.field_name for i in result.issues} + assert field_names == {'a', 'b'} + + def test_schema_without_properties_key(self): + """Schema missing 'properties' key treats all fields as invalid.""" + schema = {'type': 'object'} + get_values = _make_get_all_serialized( + { + 'my_var': {None: '"{{field}}"'}, + } + ) + result = validate_template_composition('my_var', schema, get_values) + assert len(result.issues) == 1 + assert result.issues[0].field_name == 'field' + + def test_variable_with_no_values(self): + """Variable with no serialized values produces no issues.""" + schema = {'properties': {'name': {'type': 'string'}}} + get_values = _make_get_all_serialized( + { + 'my_var': {}, + } + ) + result = validate_template_composition('my_var', schema, get_values) + assert result.issues == [] + + def test_unknown_variable_no_values(self): + """Unknown variable (not in data) produces no issues.""" + schema = {'properties': {'name': {'type': 'string'}}} + get_values = _make_get_all_serialized({}) + result = validate_template_composition('unknown', schema, get_values) + assert result.issues == [] + + def test_transitive_chain(self): + """A -> B -> C, field in C not in A's schema.""" + schema = {'properties': {'ok': {'type': 'string'}}} + get_values = _make_get_all_serialized( + { + 'a': {None: '"{{ok}} <>"'}, + 'b': {None: '"<>"'}, + 'c': {None: '"{{deep_field}}"'}, + } + ) + result = validate_template_composition('a', schema, get_values) + assert len(result.issues) == 1 + issue = result.issues[0] + assert issue.field_name == 'deep_field' + assert issue.found_in_variable == 'c' + assert issue.reference_path == ['b', 'c'] + + def test_duplicate_issue_dedup(self): + """Same field/variable/label combination is only reported once.""" + schema = {'properties': {'allowed': {'type': 'string'}}} + # Two labels in a, both reference b which has the same field + get_values = _make_get_all_serialized( + { + 'a': {None: '"<>"', 'prod': '"<>"'}, + 'b': {None: '"{{field}}"'}, + } + ) + result = validate_template_composition('a', schema, get_values) + # field in b/None should only appear once even though a has two labels pointing to b + b_issues = [i for i in result.issues if i.found_in_variable == 'b'] + assert len(b_issues) == 1 + + def test_issue_reference_path_is_copy(self): + """reference_path in issues is an independent list, not a shared reference.""" + schema = {'properties': {'allowed': {'type': 'string'}}} + get_values = _make_get_all_serialized( + { + 'a': {None: '"<> <>"'}, + 'b': {None: '"{{field_b}}"'}, + 'c': {None: '"{{field_c}}"'}, + } + ) + result = validate_template_composition('a', schema, get_values) + assert len(result.issues) >= 2 + paths = [i.reference_path for i in result.issues] + # Each path should be independent + for p in paths: + assert isinstance(p, list) + + def test_json_object_value_fields(self): + """Fields inside JSON object string values are found.""" + schema = {'properties': {'name': {'type': 'string'}}} + get_values = _make_get_all_serialized( + { + 'my_var': {None: '{"greeting": "Hello {{name}} {{extra}}"}'}, + } + ) + result = validate_template_composition('my_var', schema, get_values) + assert len(result.issues) == 1 + assert result.issues[0].field_name == 'extra' + + +# ============================================================================= +# detect_composition_cycles +# ============================================================================= + + +def _make_get_all_references( + graph: dict[str, set[str]], +) -> ...: + """Helper: build get_all_references from a simple adjacency dict.""" + + def get_all_references(name: str) -> set[str]: + return graph.get(name, set()) + + return get_all_references + + +class TestDetectCompositionCycles: + def test_no_cycle(self): + """No cycle returns None.""" + get_refs = _make_get_all_references( + { + 'a': {'b'}, + 'b': {'c'}, + 'c': set(), + } + ) + result = detect_composition_cycles('a', {'b'}, get_refs) + assert result is None + + def test_direct_self_reference(self): + """A references itself.""" + get_refs = _make_get_all_references( + { + 'a': set(), + } + ) + result = detect_composition_cycles('a', {'a'}, get_refs) + assert result is not None + assert result[0] == 'a' + assert result[-1] == 'a' + + def test_a_b_a_cycle(self): + """A -> B -> A cycle.""" + get_refs = _make_get_all_references( + { + 'a': set(), # a's current refs don't matter; new_references is what we're adding + 'b': {'a'}, # b currently references a + } + ) + result = detect_composition_cycles('a', {'b'}, get_refs) + assert result is not None + assert result == ['a', 'b', 'a'] + + def test_a_b_c_a_cycle(self): + """A -> B -> C -> A cycle.""" + get_refs = _make_get_all_references( + { + 'b': {'c'}, + 'c': {'a'}, + } + ) + result = detect_composition_cycles('a', {'b'}, get_refs) + assert result is not None + assert result == ['a', 'b', 'c', 'a'] + + def test_diamond_no_cycle(self): + """Diamond shape (A->B, A->C, B->D, C->D) has no cycle.""" + get_refs = _make_get_all_references( + { + 'b': {'d'}, + 'c': {'d'}, + 'd': set(), + } + ) + result = detect_composition_cycles('a', {'b', 'c'}, get_refs) + assert result is None + + def test_empty_new_references(self): + """No new references means no cycle.""" + get_refs = _make_get_all_references({}) + result = detect_composition_cycles('a', set(), get_refs) + assert result is None + + def test_long_chain_no_cycle(self): + """Long chain without cycle returns None.""" + get_refs = _make_get_all_references( + { + 'b': {'c'}, + 'c': {'d'}, + 'd': {'e'}, + 'e': set(), + } + ) + result = detect_composition_cycles('a', {'b'}, get_refs) + assert result is None + + def test_cycle_path_deterministic(self): + """Cycle detection is deterministic (sorted references).""" + get_refs = _make_get_all_references( + { + 'b': {'a'}, + } + ) + result1 = detect_composition_cycles('a', {'b'}, get_refs) + result2 = detect_composition_cycles('a', {'b'}, get_refs) + assert result1 == result2 + + def test_multiple_new_refs_one_cycles(self): + """Multiple new_references, only one causes a cycle — cycle is detected.""" + get_refs = _make_get_all_references( + { + 'b': set(), + 'c': {'a'}, + } + ) + result = detect_composition_cycles('a', {'b', 'c'}, get_refs) + assert result is not None + assert result[-1] == 'a' + + def test_reference_to_unknown_variable(self): + """Referencing an unknown variable (no entries in graph) — no cycle.""" + get_refs = _make_get_all_references({}) + result = detect_composition_cycles('a', {'unknown'}, get_refs) + assert result is None + + +# ============================================================================= +# Dataclass tests +# ============================================================================= + + +class TestTemplateFieldIssue: + def test_attributes(self): + issue = TemplateFieldIssue( + field_name='user_name', + found_in_variable='prompt', + found_in_label='production', + reference_path=['snippet', 'prompt'], + ) + assert issue.field_name == 'user_name' + assert issue.found_in_variable == 'prompt' + assert issue.found_in_label == 'production' + assert issue.reference_path == ['snippet', 'prompt'] + + def test_none_label(self): + issue = TemplateFieldIssue( + field_name='x', + found_in_variable='v', + found_in_label=None, + reference_path=[], + ) + assert issue.found_in_label is None + + +class TestTemplateValidationResult: + def test_default_empty_issues(self): + result = TemplateValidationResult() + assert result.issues == [] + + def test_with_issues(self): + issue = TemplateFieldIssue( + field_name='x', + found_in_variable='v', + found_in_label=None, + reference_path=[], + ) + result = TemplateValidationResult(issues=[issue]) + assert len(result.issues) == 1 diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py new file mode 100644 index 000000000..cccdd4d3a --- /dev/null +++ b/tests/test_variable_composition.py @@ -0,0 +1,705 @@ +"""Tests for variable composition (<> reference expansion).""" + +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from pydantic import BaseModel + +import logfire +from logfire._internal.config import LocalVariablesOptions +from logfire.testing import TestExporter +from logfire.variables.composition import ( + VariableCompositionCycleError, + VariableCompositionError, + expand_references, + find_references, +) +from logfire.variables.config import ( + LabeledValue, + LabelRef, + LatestVersion, + Rollout, + VariableConfig, + VariablesConfig, +) + +# ============================================================================= +# Tests for the pure composition functions (expand_references, find_references) +# ============================================================================= + + +def _make_resolve_fn( + variables: dict[str, str | None], +) -> Any: + """Create a resolve_fn from a simple name->serialized_value dict.""" + + def resolve_fn(ref_name: str) -> tuple[str | None, str | None, int | None, str]: + if ref_name in variables: + value = variables[ref_name] + if value is None: + return (None, None, None, 'unrecognized_variable') + return (value, 'production', 1, 'resolved') + return (None, None, None, 'unrecognized_variable') + + return resolve_fn + + +class TestExpandReferences: + def test_no_references(self): + """Values without <<>> are returned unchanged.""" + resolve_fn = _make_resolve_fn({}) + expanded, composed = expand_references('"hello world"', 'my_var', resolve_fn) + assert expanded == '"hello world"' + assert composed == [] + + def test_simple_string_reference(self): + """Simple <> expands to the referenced string value.""" + resolve_fn = _make_resolve_fn({'greeting': '"Hello"'}) + expanded, composed = expand_references('"<> World"', 'my_var', resolve_fn) + assert expanded == '"Hello World"' + assert len(composed) == 1 + assert composed[0].name == 'greeting' + assert composed[0].value == 'Hello' + assert composed[0].label == 'production' + assert composed[0].version == 1 + assert composed[0].reason == 'resolved' + assert composed[0].error is None + + def test_multiple_references(self): + """Multiple <> in one value are all expanded.""" + resolve_fn = _make_resolve_fn( + { + 'greeting': '"Hello"', + 'name': '"World"', + } + ) + expanded, composed = expand_references('"<> <>!"', 'my_var', resolve_fn) + assert expanded == '"Hello World!"' + assert len(composed) == 2 + assert composed[0].name == 'greeting' + assert composed[1].name == 'name' + + def test_same_reference_multiple_times(self): + """The same <> used multiple times expands each occurrence.""" + resolve_fn = _make_resolve_fn({'word': '"echo"'}) + expanded, composed = expand_references('"<> <>"', 'my_var', resolve_fn) + assert expanded == '"echo echo"' + # Handlebars resolves all occurrences in one pass, so only one ComposedReference + assert len(composed) == 1 + assert composed[0].name == 'word' + + def test_nested_references(self): + """References within referenced values are expanded recursively.""" + resolve_fn = _make_resolve_fn( + { + 'a': '"Hello <>"', + 'b': '"World"', + } + ) + expanded, composed = expand_references('"<>!"', 'my_var', resolve_fn) + assert expanded == '"Hello World!"' + assert len(composed) == 1 + assert composed[0].name == 'a' + assert composed[0].value == 'Hello World' + assert len(composed[0].composed_from) == 1 + assert composed[0].composed_from[0].name == 'b' + + def test_cycle_detection(self): + """Circular references are caught and the reference is left unexpanded.""" + resolve_fn = _make_resolve_fn( + { + 'a': '"<>"', + 'b': '"<>"', + } + ) + # The cycle is caught inside expand_references; b tries to expand a + # which is already in the visited set. + _, composed = expand_references('"<>"', 'my_var', resolve_fn) + # a expands, but when b tries to expand <>, it hits the cycle. + # b is successfully resolved but its nested ref to a fails (cycle). + assert len(composed) == 1 + assert composed[0].name == 'a' + # b resolved but a inside b failed with cycle error + assert len(composed[0].composed_from) == 1 + b_ref = composed[0].composed_from[0] + assert b_ref.name == 'b' + # b itself resolved, but its expansion of <> failed + assert len(b_ref.composed_from) == 1 + assert b_ref.composed_from[0].name == 'a' + assert b_ref.composed_from[0].error is not None + assert 'Circular reference' in b_ref.composed_from[0].error + + def test_self_reference_cycle(self): + """A variable referencing itself is caught.""" + resolve_fn = _make_resolve_fn({'a': '"<>"'}) + # my_var references a, a references itself + _, composed = expand_references('"<>"', 'my_var', resolve_fn) + assert len(composed) == 1 + assert composed[0].name == 'a' + # a resolved, but its self-reference <> failed with cycle + assert len(composed[0].composed_from) == 1 + assert composed[0].composed_from[0].name == 'a' + assert composed[0].composed_from[0].error is not None + assert 'Circular reference' in composed[0].composed_from[0].error + + def test_depth_limit(self): + """Chains exceeding MAX_COMPOSITION_DEPTH are caught.""" + # Build a chain: var_0 -> var_1 -> var_2 -> ... -> var_21 + variables: dict[str, str | None] = {} + for i in range(22): + if i < 21: + variables[f'var_{i}'] = f'"<>"' + else: + variables[f'var_{i}'] = '"end"' + resolve_fn = _make_resolve_fn(variables) + _, composed = expand_references('"<>"', 'my_var', resolve_fn) + # Should have error about depth limit somewhere in the chain + assert len(composed) == 1 + + # Walk down the chain to find the depth error + ref = composed[0] + depth_error_found = False + while ref.composed_from: + if ref.error and 'Maximum composition depth' in ref.error: + depth_error_found = True + break + ref = ref.composed_from[0] + if not depth_error_found and ref.error: + depth_error_found = 'Maximum composition depth' in ref.error + assert depth_error_found, 'Expected depth limit error somewhere in the chain' + + def test_unresolvable_reference(self): + """References to non-existent variables are left unexpanded.""" + resolve_fn = _make_resolve_fn({}) + expanded, composed = expand_references('"Hello <>"', 'my_var', resolve_fn) + assert expanded == '"Hello <>"' + assert len(composed) == 1 + assert composed[0].name == 'nonexistent' + assert composed[0].value is None + assert composed[0].reason == 'unrecognized_variable' + + def test_none_value_reference(self): + """References to variables with None value are left unexpanded.""" + resolve_fn = _make_resolve_fn({'missing': None}) + expanded, composed = expand_references('"Hello <>"', 'my_var', resolve_fn) + assert expanded == '"Hello <>"' + assert len(composed) == 1 + assert composed[0].value is None + + def test_non_string_reference(self): + """Non-string variables (numbers) are rendered via Handlebars toString.""" + resolve_fn = _make_resolve_fn({'number': '42'}) + expanded, composed = expand_references('"Value: <>"', 'my_var', resolve_fn) + assert expanded == '"Value: 42"' + assert len(composed) == 1 + assert composed[0].error is None + + def test_boolean_reference(self): + """Boolean variables are rendered via Handlebars toString.""" + resolve_fn = _make_resolve_fn({'flag': 'true'}) + expanded, composed = expand_references('"Flag: <>"', 'my_var', resolve_fn) + assert expanded == '"Flag: true"' + assert len(composed) == 1 + assert composed[0].error is None + + def test_object_reference(self): + """Object variables are available in the Handlebars context.""" + resolve_fn = _make_resolve_fn({'obj': '{"key": "value"}'}) + expanded, composed = expand_references('"Data: <>"', 'my_var', resolve_fn) + # Handlebars renders objects via toString — typically [object Object] or similar + result = json.loads(expanded) + assert 'Data:' in result + assert len(composed) == 1 + assert composed[0].error is None + + def test_structured_type_with_references(self): + """References inside JSON string values of structured types expand correctly.""" + resolve_fn = _make_resolve_fn({'safety': '"Be safe."'}) + serialized = json.dumps({'prompt': '<> Always.', 'model': 'gpt-4'}) + expanded, composed = expand_references(serialized, 'my_var', resolve_fn) + parsed = json.loads(expanded) + assert parsed['prompt'] == 'Be safe. Always.' + assert parsed['model'] == 'gpt-4' + assert len(composed) == 1 + assert composed[0].name == 'safety' + + def test_json_encoding_newlines(self): + """Newlines in referenced values are properly JSON-escaped.""" + resolve_fn = _make_resolve_fn({'multi': '"Line1\\nLine2"'}) + expanded, _ = expand_references('"Before <> After"', 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == 'Before Line1\nLine2 After' + + def test_json_encoding_quotes(self): + """Quotes in referenced values are properly JSON-escaped.""" + resolve_fn = _make_resolve_fn({'quoted': '"She said \\"hello\\""'}) + expanded, _ = expand_references('"<>!"', 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == 'She said "hello"!' + + def test_json_encoding_unicode(self): + """Unicode in referenced values works correctly.""" + resolve_fn = _make_resolve_fn({'emoji': json.dumps('Hello 🌍')}) + expanded, _ = expand_references('"<>!"', 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == 'Hello 🌍!' + + def test_json_encoding_backslashes(self): + """Backslashes in referenced values are properly JSON-escaped.""" + resolve_fn = _make_resolve_fn({'path': json.dumps('C:\\Users\\test')}) + expanded, _ = expand_references('"Path: <>"', 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == 'Path: C:\\Users\\test' + + def test_escape_sequence(self): + r"""Escaped \<< is converted to literal <<. + + In serialized JSON, a literal backslash before << is encoded as \\<<. + The regex lookbehind prevents matching, and post-processing converts \<< to <<. + """ + resolve_fn = _make_resolve_fn({'ref': '"expanded"'}) + # Build a JSON string that contains: not \<> but <> + # In JSON encoding, backslash must be \\, so the raw JSON is: + # "not \\<> but <>" + raw_python_str = 'not \\<> but <>' + serialized = json.dumps(raw_python_str) + # serialized is: "not \\<> but <>" + expanded, composed = expand_references(serialized, 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == 'not <> but expanded' + # Only the real ref (second one) is in composed + assert len(composed) == 1 + assert composed[0].name == 'ref' + + def test_escape_only(self): + r"""Only escaped references, no real references.""" + resolve_fn = _make_resolve_fn({}) + raw_python_str = 'literal \\<>' + serialized = json.dumps(raw_python_str) + expanded, composed = expand_references(serialized, 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == 'literal <>' + assert composed == [] + + def test_invalid_json_reference(self): + """References to values with invalid JSON are left unexpanded.""" + resolve_fn = _make_resolve_fn({'bad': 'not json at all'}) + expanded, composed = expand_references('"<>"', 'my_var', resolve_fn) + assert expanded == '"<>"' + assert len(composed) == 1 + assert composed[0].error is not None + assert 'non-JSON' in composed[0].error + + +class TestFindReferences: + def test_no_references(self): + assert find_references('"hello world"') == [] + + def test_single_reference(self): + assert find_references('"<>"') == ['greeting'] + + def test_multiple_unique_references(self): + assert find_references('"<> <> <>"') == ['a', 'b', 'c'] + + def test_duplicate_references(self): + """Duplicates are deduplicated, order preserved.""" + assert find_references('"<> <> <>"') == ['a', 'b'] + + def test_escaped_not_matched(self): + assert find_references(r'"\\<>"') == [] + + def test_mixed_escaped_and_real(self): + result = find_references(r'"\\<> <>"') + assert result == ['real'] + + def test_in_structured_json(self): + serialized = json.dumps({'prompt': '<>', 'other': '<>'}) + assert find_references(serialized) == ['safety', 'format'] + + def test_find_references_block_helpers(self): + """find_references detects variable names from block helper syntax.""" + serialized = json.dumps('<<#if brand>>show<>hide<>') + result = find_references(serialized) + assert 'brand' in result + + def test_find_references_block_and_simple(self): + """find_references finds both simple and block-helper references.""" + serialized = json.dumps('<> <<#if flag>>yes<>') + result = find_references(serialized) + assert 'greeting' in result + assert 'flag' in result + + +# ============================================================================= +# Tests for Handlebars-powered <<>> block helpers +# ============================================================================= + + +class TestBlockHelpers: + def test_block_if_true(self): + """<<#if flag>>yes<>no<> with truthy flag.""" + resolve_fn = _make_resolve_fn({'flag': 'true'}) + expanded, composed = expand_references('"<<#if flag>>yes<>no<>"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'yes' + assert len(composed) == 1 + assert composed[0].name == 'flag' + + def test_block_if_false(self): + """<<#if flag>>yes<>no<> with falsy flag.""" + resolve_fn = _make_resolve_fn({'flag': 'false'}) + expanded, composed = expand_references('"<<#if flag>>yes<>no<>"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'no' + assert len(composed) == 1 + assert composed[0].name == 'flag' + + def test_block_each(self): + """<<#each items>>- <><> iterates over a list.""" + resolve_fn = _make_resolve_fn({'items': '["a", "b", "c"]'}) + expanded, composed = expand_references('"<<#each items>><> <>"', 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == 'a b c ' + assert len(composed) == 1 + assert composed[0].name == 'items' + + def test_block_unless(self): + """<<#unless flag>>shown<> with falsy flag.""" + resolve_fn = _make_resolve_fn({'flag': 'false'}) + expanded, _ = expand_references('"<<#unless flag>>shown<>"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'shown' + + def test_block_unless_truthy(self): + """<<#unless flag>>shown<> with truthy flag shows nothing.""" + resolve_fn = _make_resolve_fn({'flag': 'true'}) + expanded, _ = expand_references('"<<#unless flag>>shown<>"', 'my_var', resolve_fn) + assert json.loads(expanded) == '' + + def test_block_with(self): + """<<#with config>><><> accesses nested fields.""" + resolve_fn = _make_resolve_fn({'config': '{"name": "acme"}'}) + expanded, _ = expand_references('"<<#with config>><><>"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'acme' + + def test_block_if_with_composition(self): + """<<#if brand>><><> — conditional with dotted access.""" + resolve_fn = _make_resolve_fn({'brand': '{"tagline": "Build faster"}'}) + expanded, _ = expand_references('"<<#if brand>><><>"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'Build faster' + + def test_mixed_angle_and_curly_preserved(self): + """<> expands, {{user.name}} is preserved for later rendering.""" + resolve_fn = _make_resolve_fn({'greeting': '"Hello"'}) + expanded, _ = expand_references('"<> {{user.name}}"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'Hello {{user.name}}' + + def test_escape_angle_bracket(self): + r"""Escaped \<> becomes literal <> in output.""" + resolve_fn = _make_resolve_fn({'ref': '"expanded"'}) + raw_python_str = '\\<>' + serialized = json.dumps(raw_python_str) + expanded, _ = expand_references(serialized, 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == '<>' + + def test_escape_mixed(self): + r"""Escaped \<> stays literal, real <> expands.""" + resolve_fn = _make_resolve_fn({'escaped': '"X"', 'real': '"expanded"'}) + raw_python_str = '\\<> <>' + serialized = json.dumps(raw_python_str) + expanded, _ = expand_references(serialized, 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == '<> expanded' + + +# ============================================================================= +# Integration tests using LocalVariableProvider +# ============================================================================= + + +def _make_variables_config(**variables: str | None) -> VariablesConfig: + """Helper to create a VariablesConfig with simple string variables. + + Each kwarg is name=serialized_value (JSON string). + """ + configs: dict[str, VariableConfig] = {} + for name, value in variables.items(): + labels: dict[str, LabeledValue | LabelRef] = {} + latest: LatestVersion | None = None + if value is not None: + labels['production'] = LabeledValue(version=1, serialized_value=value) + latest = LatestVersion(version=1, serialized_value=value) + configs[name] = VariableConfig( + name=name, + json_schema={'type': 'string'} if value is not None and value.startswith('"') else None, + labels=labels, + rollout=Rollout(labels={'production': 1.0}) if value is not None else Rollout(labels={}), + overrides=[], + latest_version=latest, + ) + return VariablesConfig(variables=configs) + + +class TestCompositionIntegration: + def test_simple_reference(self, config_kwargs: dict[str, Any]): + """End-to-end: variable with <> is resolved with composition.""" + variables_config = _make_variables_config( + greeting='"Hello"', + main='"<> World"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='main', default='fallback', type=str) + result = var.get() + assert result.value == 'Hello World' + assert len(result.composed_from) == 1 + assert result.composed_from[0].name == 'greeting' + assert result.composed_from[0].value == 'Hello' + + def test_nested_reference(self, config_kwargs: dict[str, Any]): + """A→B→C chain resolves fully.""" + variables_config = _make_variables_config( + c='"end"', + b='"<>_b"', + a='"<>_a"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='a', default='fallback', type=str) + result = var.get() + assert result.value == 'end_b_a' + assert len(result.composed_from) == 1 + assert result.composed_from[0].name == 'b' + assert result.composed_from[0].composed_from[0].name == 'c' + + def test_cycle_falls_back_gracefully(self, config_kwargs: dict[str, Any]): + """Cycles in references cause graceful fallback to default.""" + variables_config = _make_variables_config( + a='"<>"', + b='"<>"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='a', default='fallback', type=str) + result = var.get() + # The cycle in b trying to reference a (which is in the visited set) means + # b's expansion fails, b is left as <> inside a's value. + # So a's value becomes "<>" (the literal unexpanded ref from b's failed expansion). + # Actually the value should still deserialize as a string, just with unexpanded refs. + assert isinstance(result.value, str) + + def test_nonexistent_reference_left_unexpanded(self, config_kwargs: dict[str, Any]): + """References to non-existent variables are left as-is.""" + variables_config = _make_variables_config( + main='"Hello <>"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='main', default='fallback', type=str) + result = var.get() + assert result.value == 'Hello <>' + + def test_non_string_reference_expanded(self, config_kwargs: dict[str, Any]): + """Non-string variables are now expanded via Handlebars.""" + # Create a variable config with a non-string variable + configs: dict[str, VariableConfig] = { + 'number': VariableConfig( + name='number', + json_schema={'type': 'integer'}, + labels={'production': LabeledValue(version=1, serialized_value='42')}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + latest_version=LatestVersion(version=1, serialized_value='42'), + ), + 'main': VariableConfig( + name='main', + json_schema={'type': 'string'}, + labels={'production': LabeledValue(version=1, serialized_value='"Value: <>"')}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + latest_version=LatestVersion(version=1, serialized_value='"Value: <>"'), + ), + } + variables_config = VariablesConfig(variables=configs) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='main', default='fallback', type=str) + result = var.get() + assert result.value == 'Value: 42' + + def test_structured_type_composition(self, config_kwargs: dict[str, Any]): + """Composition works in string fields of Pydantic models.""" + + class AgentConfig(BaseModel): + prompt: str + model: str + + safety_value = json.dumps('Be safe.') + agent_value = json.dumps({'prompt': '<> Always.', 'model': 'gpt-4'}) + + configs: dict[str, VariableConfig] = { + 'safety': VariableConfig( + name='safety', + json_schema={'type': 'string'}, + labels={'production': LabeledValue(version=1, serialized_value=safety_value)}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + latest_version=LatestVersion(version=1, serialized_value=safety_value), + ), + 'agent_config': VariableConfig( + name='agent_config', + json_schema=None, + labels={'production': LabeledValue(version=1, serialized_value=agent_value)}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + latest_version=LatestVersion(version=1, serialized_value=agent_value), + ), + } + variables_config = VariablesConfig(variables=configs) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='agent_config', default=AgentConfig(prompt='default', model='default'), type=AgentConfig) + result = var.get() + assert result.value.prompt == 'Be safe. Always.' + assert result.value.model == 'gpt-4' + assert len(result.composed_from) == 1 + assert result.composed_from[0].name == 'safety' + + def test_no_composition_for_context_override(self, config_kwargs: dict[str, Any]): + """Context overrides return typed values directly, no composition.""" + variables_config = _make_variables_config( + greeting='"Hello"', + main='"<> World"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='main', default='fallback', type=str) + with var.override('override_value'): + result = var.get() + assert result.value == 'override_value' + assert result.composed_from == [] + assert result._reason == 'context_override' + + def test_composition_with_explicit_label(self, config_kwargs: dict[str, Any]): + """Composition works when using explicit label parameter.""" + variables_config = _make_variables_config( + greeting='"Hello"', + main='"<> World"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='main', default='fallback', type=str) + result = var.get(label='production') + assert result.value == 'Hello World' + assert len(result.composed_from) == 1 + + def test_span_attributes_with_composition(self, config_kwargs: dict[str, Any], exporter: TestExporter): + """Span attributes include composed_from when composition occurs.""" + variables_config = _make_variables_config( + greeting='"Hello"', + main='"<> World"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config, instrument=True) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='main', default='fallback', type=str) + exporter.clear() + + result = var.get() + assert result.value == 'Hello World' + + # Find the completed span for 'main' variable resolution (last one with this name) + spans = exporter.exported_spans + resolve_spans = [s for s in spans if s.name == 'Resolve variable main'] + main_span = resolve_spans[-1] # last = completed span + attrs = dict(main_span.attributes or {}) + + # Check composed_from attribute + composed_from_json = attrs.get('composed_from') + assert isinstance(composed_from_json, str) + composed_data = json.loads(composed_from_json) + assert len(composed_data) == 1 + assert composed_data[0]['name'] == 'greeting' + assert composed_data[0]['version'] == 1 + assert composed_data[0]['label'] == 'production' + + def test_span_attributes_without_composition(self, config_kwargs: dict[str, Any], exporter: TestExporter): + """Span attributes do NOT include composed_from when no composition occurs.""" + variables_config = _make_variables_config( + main='"no refs here"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config, instrument=True) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='main', default='fallback', type=str) + exporter.clear() + + var.get() + + # Find the completed span for 'main' variable resolution (last one with this name) + spans = exporter.exported_spans + resolve_spans = [s for s in spans if s.name == 'Resolve variable main'] + main_span = resolve_spans[-1] # last = completed span + attrs = dict(main_span.attributes or {}) + assert 'composed_from' not in attrs + + def test_no_value_no_composition(self, config_kwargs: dict[str, Any]): + """When variable resolves to None (code default), no composition happens.""" + variables_config = VariablesConfig( + variables={ + 'main': VariableConfig( + name='main', + json_schema={'type': 'string'}, + labels={}, + rollout=Rollout(labels={}), + overrides=[], + ), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='main', default='<> fallback', type=str) + result = var.get() + # Default is returned as-is (no composition on defaults) + assert result.value == '<> fallback' + assert result.composed_from == [] + + +class TestCompositionExceptions: + """Test the exception hierarchy.""" + + def test_composition_error_is_exception(self): + assert issubclass(VariableCompositionError, Exception) + + def test_cycle_error_is_composition_error(self): + assert issubclass(VariableCompositionCycleError, VariableCompositionError) + + def test_direct_cycle_error(self): + with pytest.raises(VariableCompositionCycleError, match='Circular reference'): + expand_references( + '"test"', + 'a', + _make_resolve_fn({}), + _visited=frozenset({'a'}), + ) + + def test_direct_depth_error(self): + with pytest.raises(VariableCompositionError, match='Maximum composition depth'): + expand_references( + '"test"', + 'a', + _make_resolve_fn({}), + _depth=21, + ) diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py new file mode 100644 index 000000000..807da7c65 --- /dev/null +++ b/tests/test_variable_templates.py @@ -0,0 +1,554 @@ +"""Tests for variable template rendering (Handlebars {{placeholder}} support).""" + +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from pydantic import BaseModel + +import logfire +from logfire._internal.config import LocalVariablesOptions +from logfire.variables.config import ( + LabeledValue, + Rollout, + VariableConfig, + VariablesConfig, +) + + +def _make_lf(variables_config: VariablesConfig, config_kwargs: dict[str, Any]) -> logfire.Logfire: + """Create a Logfire instance with LocalVariablesOptions for testing.""" + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + return logfire.configure(**config_kwargs) + + +def _simple_config(name: str, serialized_value: str) -> VariablesConfig: + """Create a minimal VariablesConfig with one variable and one label.""" + return VariablesConfig( + variables={ + name: VariableConfig( + name=name, + labels={'production': LabeledValue(version=1, serialized_value=serialized_value)}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + }, + ) + + +# ============================================================================= +# ResolvedVariable.render() tests +# ============================================================================= + + +class TestRenderSimpleString: + """Test rendering string variables with Handlebars templates.""" + + def test_simple_placeholder(self, config_kwargs: dict[str, Any]): + """Simple {{placeholder}} replacement in a string variable.""" + lf = _make_lf(_simple_config('greeting', json.dumps('Hello {{name}}!')), config_kwargs) + var = lf.var('greeting', type=str, default='default') + resolved = var.get() + assert resolved.value == 'Hello {{name}}!' + rendered = resolved.render({'name': 'Alice'}) + assert rendered == 'Hello Alice!' + + def test_multiple_placeholders(self, config_kwargs: dict[str, Any]): + """Multiple {{placeholders}} in a single string.""" + lf = _make_lf( + _simple_config('prompt', json.dumps('Hello {{user_name}}, welcome to {{company}}!')), + config_kwargs, + ) + var = lf.var('prompt', type=str, default='default') + resolved = var.get() + rendered = resolved.render({'user_name': 'Bob', 'company': 'Acme'}) + assert rendered == 'Hello Bob, welcome to Acme!' + + def test_conditional_template(self, config_kwargs: dict[str, Any]): + """Handlebars #if conditional in a string variable.""" + lf = _make_lf( + _simple_config('prompt', json.dumps('Hello {{name}}.{{#if is_premium}} Premium member!{{/if}}')), + config_kwargs, + ) + var = lf.var('prompt', type=str, default='default') + resolved = var.get() + + rendered_premium = resolved.render({'name': 'Alice', 'is_premium': True}) + assert rendered_premium == 'Hello Alice. Premium member!' + + rendered_basic = resolved.render({'name': 'Bob', 'is_premium': False}) + assert rendered_basic == 'Hello Bob.' + + def test_each_helper(self, config_kwargs: dict[str, Any]): + """Handlebars #each iteration in a string variable.""" + lf = _make_lf( + _simple_config( + 'prompt', + json.dumps('Items: {{#each items}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}'), + ), + config_kwargs, + ) + var = lf.var('prompt', type=str, default='default') + resolved = var.get() + rendered = resolved.render({'items': ['apple', 'banana', 'cherry']}) + assert rendered == 'Items: apple, banana, cherry' + + def test_no_html_escaping(self, config_kwargs: dict[str, Any]): + """String values should NOT be HTML-escaped (not an HTML context).""" + lf = _make_lf(_simple_config('prompt', json.dumps('Value: {{value}}')), config_kwargs) + var = lf.var('prompt', type=str, default='default') + resolved = var.get() + # These characters would normally be HTML-escaped by Handlebars + rendered = resolved.render({'value': ''}) + assert rendered == 'Value: ' + + def test_empty_context(self, config_kwargs: dict[str, Any]): + """Rendering with no inputs leaves placeholders as empty strings.""" + lf = _make_lf(_simple_config('prompt', json.dumps('Hello {{name}}!')), config_kwargs) + var = lf.var('prompt', type=str, default='default') + resolved = var.get() + rendered = resolved.render() + assert rendered == 'Hello !' + + def test_no_templates(self, config_kwargs: dict[str, Any]): + """Rendering a value with no {{placeholders}} returns the value unchanged.""" + lf = _make_lf(_simple_config('prompt', json.dumps('Hello world!')), config_kwargs) + var = lf.var('prompt', type=str, default='default') + resolved = var.get() + rendered = resolved.render({'name': 'unused'}) + assert rendered == 'Hello world!' + + +class TestRenderWithPydanticInputs: + """Test rendering with Pydantic model inputs.""" + + def test_pydantic_model_inputs(self, config_kwargs: dict[str, Any]): + """Rendering with a Pydantic model as inputs.""" + + class PromptInputs(BaseModel): + user_name: str + is_premium: bool = False + + lf = _make_lf( + _simple_config('prompt', json.dumps('Welcome {{user_name}}!{{#if is_premium}} VIP{{/if}}')), + config_kwargs, + ) + var = lf.var('prompt', type=str, default='default', template_inputs=PromptInputs) + resolved = var.get() + rendered = resolved.render(PromptInputs(user_name='Alice', is_premium=True)) + assert rendered == 'Welcome Alice! VIP' + + def test_nested_model_inputs(self, config_kwargs: dict[str, Any]): + """Rendering with nested Pydantic model fields using dot notation.""" + + class Address(BaseModel): + city: str + country: str + + class UserInfo(BaseModel): + name: str + address: Address + + lf = _make_lf( + _simple_config('prompt', json.dumps('User {{name}} from {{address.city}}, {{address.country}}')), + config_kwargs, + ) + var = lf.var('prompt', type=str, default='default', template_inputs=UserInfo) + resolved = var.get() + rendered = resolved.render(UserInfo(name='Alice', address=Address(city='London', country='UK'))) + assert rendered == 'User Alice from London, UK' + + +class TestRenderStructuredType: + """Test rendering structured types (Pydantic models) where string fields contain templates.""" + + def test_model_with_template_fields(self, config_kwargs: dict[str, Any]): + """Rendering a Pydantic model where string fields contain {{placeholders}}.""" + + class PromptConfig(BaseModel): + system_prompt: str + temperature: float + max_tokens: int + + serialized = json.dumps( + { + 'system_prompt': 'Hello {{user_name}}, how can I help?', + 'temperature': 0.7, + 'max_tokens': 100, + } + ) + + lf = _make_lf(_simple_config('config', serialized), config_kwargs) + var = lf.var( + 'config', + type=PromptConfig, + default=PromptConfig(system_prompt='default', temperature=0.5, max_tokens=50), + ) + resolved = var.get() + rendered = resolved.render({'user_name': 'Alice'}) + assert isinstance(rendered, PromptConfig) + assert rendered.system_prompt == 'Hello Alice, how can I help?' + assert rendered.temperature == 0.7 + assert rendered.max_tokens == 100 + + +class TestRenderCodeDefault: + """Test rendering when using code default values (no remote configuration).""" + + def test_render_code_default_string(self, config_kwargs: dict[str, Any]): + """Rendering a code default string that contains templates.""" + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + var = lf.var('prompt', type=str, default='Hello {{name}}!') + resolved = var.get() + # Value is the code default + assert resolved.value == 'Hello {{name}}!' + # Rendering should still work + rendered = resolved.render({'name': 'Alice'}) + assert rendered == 'Hello Alice!' + + +class TestRenderErrors: + """Test error handling in render().""" + + def test_render_invalid_inputs_type(self, config_kwargs: dict[str, Any]): + """Passing a non-dict/non-model to render() raises TypeError.""" + lf = _make_lf(_simple_config('prompt', json.dumps('Hello {{name}}')), config_kwargs) + var = lf.var('prompt', type=str, default='default') + resolved = var.get() + with pytest.raises(TypeError, match='Expected a dict, Mapping, or Pydantic model'): + resolved.render(42) + + +# ============================================================================= +# template_inputs parameter tests +# ============================================================================= + + +class TestTemplateInputsParam: + """Test the template_inputs parameter on logfire.var().""" + + def test_template_inputs_schema_in_config(self, config_kwargs: dict[str, Any]): + """template_inputs generates JSON Schema in the variable config.""" + + class MyInputs(BaseModel): + user_name: str + count: int = 5 + + lf = logfire.configure(**config_kwargs) + var = lf.var('prompt', type=str, default='Hello {{user_name}}', template_inputs=MyInputs) + config = var.to_config() + assert config.template_inputs_schema is not None + assert config.template_inputs_schema['type'] == 'object' + assert 'user_name' in config.template_inputs_schema['properties'] + assert 'count' in config.template_inputs_schema['properties'] + + def test_no_template_inputs(self, config_kwargs: dict[str, Any]): + """Without template_inputs, schema is None.""" + lf = logfire.configure(**config_kwargs) + var = lf.var('prompt', type=str, default='Hello') + config = var.to_config() + assert config.template_inputs_schema is None + + def test_template_inputs_stored_on_variable(self, config_kwargs: dict[str, Any]): + """template_inputs_type is stored on the Variable instance.""" + + class MyInputs(BaseModel): + name: str + + lf = logfire.configure(**config_kwargs) + var = lf.var('prompt', type=str, default='Hello', template_inputs=MyInputs) + assert var.template_inputs_type is MyInputs + + +# ============================================================================= +# VariableConfig.template_inputs_schema tests +# ============================================================================= + + +class TestVariableConfigTemplateInputs: + """Test template_inputs_schema on VariableConfig.""" + + def test_round_trip_serialization(self): + """template_inputs_schema survives serialization/deserialization.""" + schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} + config = VariableConfig( + name='test_var', + labels={}, + rollout=Rollout(labels={}), + overrides=[], + template_inputs_schema=schema, + ) + data = config.model_dump() + restored = VariableConfig.model_validate(data) + assert restored.template_inputs_schema == schema + + def test_none_by_default(self): + """template_inputs_schema defaults to None.""" + config = VariableConfig( + name='test_var', + labels={}, + rollout=Rollout(labels={}), + overrides=[], + ) + assert config.template_inputs_schema is None + + +# ============================================================================= +# Composition + rendering pipeline tests +# ============================================================================= + + +class TestCompositionThenRendering: + """Test the full pipeline: resolve → compose → render.""" + + def test_composition_then_render(self, config_kwargs: dict[str, Any]): + """<> are expanded first, then {{placeholders}} are rendered.""" + variables_config = VariablesConfig( + variables={ + 'snippet': VariableConfig( + name='snippet', + labels={ + 'production': LabeledValue(version=1, serialized_value=json.dumps('Welcome to {{company}}!')), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + 'full_prompt': VariableConfig( + name='full_prompt', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps('Hello {{user_name}}. <>'), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + }, + ) + lf = _make_lf(variables_config, config_kwargs) + var = lf.var('full_prompt', type=str, default='default') + resolved = var.get() + # After composition, <> is expanded but {{placeholders}} remain + assert resolved.value == 'Hello {{user_name}}. Welcome to {{company}}!' + # After rendering, all {{placeholders}} are filled + rendered = resolved.render({'user_name': 'Alice', 'company': 'Acme Corp'}) + assert rendered == 'Hello Alice. Welcome to Acme Corp!' + + +# ============================================================================= +# TemplateVariable tests +# ============================================================================= + + +class TestTemplateVariable: + """Test TemplateVariable[T, InputsT] — single-step get(inputs) rendering.""" + + def test_basic_rendering(self, config_kwargs: dict[str, Any]): + """get(inputs) returns rendered value directly.""" + + class Inputs(BaseModel): + name: str + + lf = _make_lf(_simple_config('greeting', json.dumps('Hello {{name}}!')), config_kwargs) + var = lf.template_var('greeting', type=str, default='default', inputs_type=Inputs) + resolved = var.get(Inputs(name='Alice')) + assert resolved.value == 'Hello Alice!' + + def test_composition_then_render(self, config_kwargs: dict[str, Any]): + """<> expanded first, then {{}} rendered with inputs.""" + + class Inputs(BaseModel): + user_name: str + company: str + + variables_config = VariablesConfig( + variables={ + 'snippet': VariableConfig( + name='snippet', + labels={ + 'production': LabeledValue(version=1, serialized_value=json.dumps('Welcome to {{company}}!')), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + 'full_prompt': VariableConfig( + name='full_prompt', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps('Hello {{user_name}}. <>'), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + }, + ) + lf = _make_lf(variables_config, config_kwargs) + var = lf.template_var('full_prompt', type=str, default='default', inputs_type=Inputs) + resolved = var.get(Inputs(user_name='Alice', company='Acme Corp')) + # Both composition AND rendering done in one step + assert resolved.value == 'Hello Alice. Welcome to Acme Corp!' + + def test_structured_type(self, config_kwargs: dict[str, Any]): + """Pydantic model with template fields renders correctly.""" + + class PromptConfig(BaseModel): + system_prompt: str + temperature: float + max_tokens: int + + class Inputs(BaseModel): + user_name: str + + serialized = json.dumps( + { + 'system_prompt': 'Hello {{user_name}}, how can I help?', + 'temperature': 0.7, + 'max_tokens': 100, + } + ) + + lf = _make_lf(_simple_config('config', serialized), config_kwargs) + var = lf.template_var( + 'config', + type=PromptConfig, + default=PromptConfig(system_prompt='default', temperature=0.5, max_tokens=50), + inputs_type=Inputs, + ) + resolved = var.get(Inputs(user_name='Alice')) + assert isinstance(resolved.value, PromptConfig) + assert resolved.value.system_prompt == 'Hello Alice, how can I help?' + assert resolved.value.temperature == 0.7 + assert resolved.value.max_tokens == 100 + + def test_default_rendering(self, config_kwargs: dict[str, Any]): + """Code default with {{}} templates is rendered.""" + + class Inputs(BaseModel): + name: str + + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + var = lf.template_var('prompt', type=str, default='Hello {{name}}!', inputs_type=Inputs) + resolved = var.get(Inputs(name='Alice')) + # The default value should be rendered with the inputs + assert resolved.value == 'Hello Alice!' + + def test_override_renders_template(self, config_kwargs: dict[str, Any]): + """override() overrides the template, which still gets rendered with inputs.""" + + class Inputs(BaseModel): + name: str + + lf = _make_lf(_simple_config('greeting', json.dumps('Hello {{name}}!')), config_kwargs) + var = lf.template_var('greeting', type=str, default='default', inputs_type=Inputs) + with var.override('Overridden {{name}}!'): + resolved = var.get(Inputs(name='Alice')) + # Override value is treated as a template and rendered + assert resolved.value == 'Overridden Alice!' + + def test_override_literal_string(self, config_kwargs: dict[str, Any]): + """override() with a literal string (no placeholders) works as a plain override.""" + + class Inputs(BaseModel): + name: str + + lf = _make_lf(_simple_config('greeting', json.dumps('Hello {{name}}!')), config_kwargs) + var = lf.template_var('greeting', type=str, default='default', inputs_type=Inputs) + with var.override('exact override value'): + resolved = var.get(Inputs(name='Alice')) + # No placeholders, so rendering is a no-op — value returned as-is + assert resolved.value == 'exact override value' + + def test_pydantic_model_inputs(self, config_kwargs: dict[str, Any]): + """InputsT as Pydantic BaseModel works correctly.""" + + class MyInputs(BaseModel): + user_name: str + is_premium: bool = False + + lf = _make_lf( + _simple_config('prompt', json.dumps('Welcome {{user_name}}!{{#if is_premium}} VIP{{/if}}')), + config_kwargs, + ) + var = lf.template_var('prompt', type=str, default='default', inputs_type=MyInputs) + + resolved = var.get(MyInputs(user_name='Alice', is_premium=True)) + assert resolved.value == 'Welcome Alice! VIP' + + resolved2 = var.get(MyInputs(user_name='Bob')) + assert resolved2.value == 'Welcome Bob!' + + def test_registration(self, config_kwargs: dict[str, Any]): + """template_var() registers in _variables.""" + + class Inputs(BaseModel): + x: str + + lf = logfire.configure(**config_kwargs) + lf.template_var('tv1', type=str, default='x', inputs_type=Inputs) + assert 'tv1' in {v.name for v in lf.variables_get()} + + def test_duplicate_name_error(self, config_kwargs: dict[str, Any]): + """Same name as existing var raises ValueError.""" + + class Inputs(BaseModel): + x: str + + lf = logfire.configure(**config_kwargs) + lf.var('myvar', type=str, default='x') + with pytest.raises(ValueError, match="A variable with name 'myvar' has already been registered"): + lf.template_var('myvar', type=str, default='x', inputs_type=Inputs) + + def test_context_manager(self, config_kwargs: dict[str, Any]): + """with template_var.get(inputs) as resolved: sets baggage.""" + + class Inputs(BaseModel): + name: str + + lf = _make_lf(_simple_config('prompt', json.dumps('Hello {{name}}!')), config_kwargs) + var = lf.template_var('prompt', type=str, default='default', inputs_type=Inputs) + with var.get(Inputs(name='Alice')) as resolved: + assert resolved.value == 'Hello Alice!' + baggage = logfire.get_baggage() + assert baggage.get('logfire.variables.prompt') == 'production' + + def test_no_templates_passthrough(self, config_kwargs: dict[str, Any]): + """Value with no {{}} returns as-is after rendering.""" + + class Inputs(BaseModel): + name: str + + lf = _make_lf(_simple_config('greeting', json.dumps('Hello world!')), config_kwargs) + var = lf.template_var('greeting', type=str, default='default', inputs_type=Inputs) + resolved = var.get(Inputs(name='unused')) + assert resolved.value == 'Hello world!' + + def test_template_inputs_schema_in_config(self, config_kwargs: dict[str, Any]): + """template_var() generates JSON Schema in the variable config.""" + + class MyInputs(BaseModel): + user_name: str + count: int = 5 + + lf = logfire.configure(**config_kwargs) + var = lf.template_var('prompt', type=str, default='Hello {{user_name}}', inputs_type=MyInputs) + config = var.to_config() + assert config.template_inputs_schema is not None + assert config.template_inputs_schema['type'] == 'object' + assert 'user_name' in config.template_inputs_schema['properties'] + assert 'count' in config.template_inputs_schema['properties'] + + def test_dict_inputs(self, config_kwargs: dict[str, Any]): + """Passing a dict as inputs works (via Mapping path).""" + lf = _make_lf(_simple_config('greeting', json.dumps('Hello {{name}}!')), config_kwargs) + var = lf.template_var('greeting', type=str, default='default', inputs_type=dict) + resolved = var.get({'name': 'Alice'}) + assert resolved.value == 'Hello Alice!' diff --git a/uv.lock b/uv.lock index 72d75cf4c..393e06b43 100644 --- a/uv.lock +++ b/uv.lock @@ -3320,6 +3320,7 @@ system-metrics = [ ] variables = [ { name = "pydantic" }, + { name = "pydantic-handlebars", marker = "python_full_version >= '3.10'" }, ] wsgi = [ { name = "opentelemetry-instrumentation-wsgi" }, @@ -3409,6 +3410,7 @@ dev = [ { name = "pydantic-ai-slim", version = "0.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pydantic-ai-slim", version = "1.65.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pydantic-evals", marker = "python_full_version >= '3.10'" }, + { name = "pydantic-handlebars", marker = "python_full_version >= '3.10'" }, { name = "pymongo" }, { name = "pymysql" }, { name = "pyright" }, @@ -3495,6 +3497,7 @@ requires-dist = [ { name = "pydantic", marker = "extra == 'datasets'", specifier = ">=2" }, { name = "pydantic", marker = "extra == 'variables'", specifier = ">=2" }, { name = "pydantic-evals", marker = "python_full_version >= '3.10' and extra == 'datasets'", specifier = ">=1.0.0" }, + { name = "pydantic-handlebars", marker = "python_full_version >= '3.10' and extra == 'variables'", specifier = ">=0.1.0" }, { name = "rich", specifier = ">=13.4.2" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1" }, { name = "typing-extensions", specifier = ">=4.1.0" }, @@ -3570,6 +3573,7 @@ dev = [ { name = "pydantic", specifier = ">=2.11.0" }, { name = "pydantic-ai-slim", specifier = ">=0.0.39" }, { name = "pydantic-evals", marker = "python_full_version >= '3.10'", specifier = ">=1.0.0" }, + { name = "pydantic-handlebars", marker = "python_full_version >= '3.10'", specifier = ">=0.1.0" }, { name = "pymongo", specifier = ">=4.10.1" }, { name = "pymysql", specifier = ">=1.1.1" }, { name = "pyright", specifier = "!=1.1.407" }, @@ -6837,6 +6841,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/c2/275557630cdfa84cde67ee6e55d5e7f8e4e1450a61e78eb0f5c012f44937/pydantic_graph-1.65.0-py3-none-any.whl", hash = "sha256:e1645a18fefee7ae62698b637c8a239ebcd3a5fa125cadcf8424d54907dd7122", size = 72350, upload-time = "2026-03-03T23:46:05.445Z" }, ] +[[package]] +name = "pydantic-handlebars" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/16/d41768bd3fd77e6250c20be11a3e68fee5fff07c3356455e6708f6a60f2a/pydantic_handlebars-0.1.0.tar.gz", hash = "sha256:1931c54946add1b5e3796c9bf6a005ed7662cef0109bb05c352f0b3d031a1260", size = 159826, upload-time = "2026-03-01T20:00:17.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5f/86b1630be61bdebf253c2f953a6c3f073ec21bb0725565ea3896802e1ca3/pydantic_handlebars-0.1.0-py3-none-any.whl", hash = "sha256:8a436fe8bc607295eb04bec58bd6e2c9498c9e069c557ff0b505e3d568c783bc", size = 40890, upload-time = "2026-03-01T20:00:16.106Z" }, +] + [[package]] name = "pydantic-settings" version = "2.13.1" From a335ced13144913dfd943795f6ecba0432c9dcb3 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 10:13:04 +0300 Subject: [PATCH 02/40] Add agents.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file From 91066f9d86d14e160f418c172bdd4060d0cc2a9c Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 10:27:19 +0300 Subject: [PATCH 03/40] Use prompt reference syntax for variable composition --- .../advanced/managed-variables/index.md | 4 +- .../templates-and-composition.md | 30 +-- examples/python/variable_composition_demo.py | 100 ++++----- logfire/_internal/main.py | 2 +- logfire/variables/abstract.py | 6 +- logfire/variables/composition.py | 72 ++++--- .../{angle_bracket.py => reference_syntax.py} | 47 +++-- logfire/variables/template_validation.py | 4 +- logfire/variables/variable.py | 8 +- tests/test_template_validation.py | 16 +- tests/test_variable_composition.py | 190 +++++++++--------- tests/test_variable_templates.py | 10 +- 12 files changed, 243 insertions(+), 246 deletions(-) rename logfire/variables/{angle_bracket.py => reference_syntax.py} (50%) diff --git a/docs/reference/advanced/managed-variables/index.md b/docs/reference/advanced/managed-variables/index.md index f71f4a139..73756b623 100644 --- a/docs/reference/advanced/managed-variables/index.md +++ b/docs/reference/advanced/managed-variables/index.md @@ -14,7 +14,7 @@ Managed variables are a way to externalize runtime configuration from your code. - **Observability-integrated**: Every variable resolution creates a span, and using the context manager automatically sets baggage so downstream operations are tagged with which label and version was used - **Versions and labels**: Create immutable version snapshots of your variable's value, and assign labels (like `production`, `staging`, `canary`) that point to specific versions - **Rollouts and targeting**: Control what percentage of requests receive each labeled version, and route specific users or segments based on attributes -- **Templates and composition**: Use `{{placeholder}}` Handlebars syntax in values that get rendered with runtime inputs, and compose variables from reusable fragments via `<>` references (see [Templates and Composition](templates-and-composition.md)) +- **Templates and composition**: Use `{{placeholder}}` Handlebars syntax in values that get rendered with runtime inputs, and compose variables from reusable fragments via `@{other_variable}@` references (see [Templates and Composition](templates-and-composition.md)) ### Versions and Labels @@ -139,7 +139,7 @@ with prompt.get(PromptInputs(user_name='Alice', is_premium=True)) as resolved: print(resolved.value) # "Hello Alice! Welcome back, valued member." ``` -Variables can also reference other variables using `<>` syntax, allowing you to compose values from reusable fragments that can be independently updated in the UI. +Variables can also reference other variables using `@{variable_name}@` syntax, allowing you to compose values from reusable fragments that can be independently updated in the UI. For full details, see [Templates and Composition](templates-and-composition.md). diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md index b2b16084f..973cdb1e8 100644 --- a/docs/reference/advanced/managed-variables/templates-and-composition.md +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -1,6 +1,6 @@ # Template Variables and Composition -Managed variables can contain **Handlebars templates** (`{{placeholder}}`) and **composition references** (`<>`), enabling dynamic values that are assembled from multiple sources and rendered with runtime inputs. +Managed variables can contain **Handlebars templates** (`{{placeholder}}`) and **composition references** (`@{other_variable}@`), enabling dynamic values that are assembled from multiple sources and rendered with runtime inputs. This is especially useful for AI applications where prompts are built from reusable fragments and personalized with request-specific data. @@ -39,7 +39,7 @@ with prompt.get(PromptInputs(user_name='Alice')) as resolved: The full resolution pipeline is: 1. **Resolve** — fetch the serialized value from the provider (or use the code default) -2. **Compose** — expand any `<>` references (see [Composition](#variable-composition) below) +2. **Compose** — expand any `@{variable_name}@` references (see [Composition](#variable-composition) below) 3. **Render** — render `{{placeholder}}` Handlebars templates using the provided inputs 4. **Deserialize** — validate and deserialize to the variable's type @@ -156,7 +156,7 @@ For example, if your `inputs_type` declares `user_name: str` and `is_premium: bo ## Variable Composition {#variable-composition} -**Composition** lets a variable's value reference other variables using `<>` syntax. When the variable is resolved, `<>` references are expanded by looking up the referenced variable and substituting its value. +**Composition** lets a variable's value reference other variables using `@{variable_name}@` syntax. When the variable is resolved, `@{ref}@` references are expanded by looking up the referenced variable and substituting its value. This is useful for building values from reusable fragments: @@ -176,7 +176,7 @@ safety_rules = logfire.var( agent_prompt = logfire.var( 'agent_prompt', type=str, - default='You are a helpful assistant. <>', + default='You are a helpful assistant. @{safety_rules}@', ) with agent_prompt.get() as resolved: @@ -184,22 +184,22 @@ with agent_prompt.get() as resolved: # "You are a helpful assistant. Never share personal data. Always be respectful." ``` -When `safety_rules` is updated in the Logfire UI, all variables that reference `<>` automatically pick up the new value — no code changes or redeployment required. +When `safety_rules` is updated in the Logfire UI, all variables that reference `@{safety_rules}@` automatically pick up the new value — no code changes or redeployment required. ### Composition with Handlebars Power -The `<<>>` syntax supports the full Handlebars feature set — not just simple variable substitution. You can use conditionals, loops, and more: +The `@{}@` syntax supports the full Handlebars feature set — not just simple variable substitution. You can use conditionals, loops, and more: | Syntax | Description | |--------|-------------| -| `<>` | Insert a variable's value | -| `<>` | Access a nested field | -| `<<#if variable>>...<<#else>>...<>` | Conditional on whether a variable is set | -| `<<#each items>>...<>` | Iterate over a list variable | +| `@{variable_name}@` | Insert a variable's value | +| `@{variable.field}@` | Access a nested field | +| `@{#if variable}@...@{else}@...@{/if}@` | Conditional on whether a variable is set | +| `@{#each items}@...@{/each}@` | Iterate over a list variable | ### Composition Tracking -Every `<>` expansion is recorded in the resolution result. You can inspect which variables were composed and their values: +Every `@{ref}@` expansion is recorded in the resolution result. You can inspect which variables were composed and their values: ```python skip="true" with agent_prompt.get() as resolved: @@ -211,7 +211,7 @@ These composition details are also recorded as span attributes, so you can see t ### Combining Templates and Composition -Template variables and composition work together. A common pattern is to compose reusable fragments via `<>` and render runtime inputs via `{{}}`: +Template variables and composition work together. A common pattern is to compose reusable fragments via `@{ref}@` and render runtime inputs via `{{}}`: ```python skip="true" from pydantic import BaseModel @@ -237,11 +237,11 @@ tone_instructions = logfire.var( chat_prompt = logfire.template_var( 'chat_prompt', type=str, - default='You are helping {{user_name}}. Respond in {{language}}. <>', + default='You are helping {{user_name}}. Respond in {{language}}. @{tone_instructions}@', inputs_type=ChatInputs, ) -# Resolution: compose <> first, then render {{user_name}} and {{language}} +# Resolution: compose @{tone_instructions}@ first, then render {{user_name}} and {{language}} with chat_prompt.get(ChatInputs(user_name='Alice', language='French')) as resolved: print(resolved.value) # "You are helping Alice. Respond in French. Be friendly and concise." @@ -249,7 +249,7 @@ with chat_prompt.get(ChatInputs(user_name='Alice', language='French')) as resolv ### Cycle Detection -The system detects circular references at write time. If variable A references `<>` and variable B references `<>`, pushing this configuration will produce an error. This prevents infinite loops during resolution. +The system detects circular references at write time. If variable A references `@{B}@` and variable B references `@{A}@`, pushing this configuration will produce an error. This prevents infinite loops during resolution. ## Requirements diff --git a/examples/python/variable_composition_demo.py b/examples/python/variable_composition_demo.py index 33967f933..7ff7f0278 100644 --- a/examples/python/variable_composition_demo.py +++ b/examples/python/variable_composition_demo.py @@ -1,13 +1,13 @@ """Demo: Variable Composition & Template Rendering with Logfire Managed Variables. This script demonstrates the full power of Logfire's variable composition -(<> references) and Handlebars template rendering ({{field}}) +(@{variable_name}@ references) and Handlebars template rendering ({{field}}) using a purely local configuration — no remote server needed. Key features shown: - 1. Basic variable composition: <> references expand inline + 1. Basic variable composition: @{var}@ references expand inline 2. Nested composition: variable A references B, which references C - 3. Subfield variable references: <> accesses a field of a structured variable + 3. Subfield variable references: @{var.field}@ accesses a field of a structured variable 4. Template rendering with {{field}} placeholders and Pydantic input models 5. Accessing subfields of template inputs (e.g. {{user.name}}, {{user.email}}) 6. Handlebars conditionals: {{#if}}, {{else}}, {{/if}} @@ -15,7 +15,7 @@ 8. TemplateVariable: single-step get(inputs) with automatic rendering 9. Variable.get() + .render(inputs): two-step manual rendering 10. Rollout overrides with attribute-based conditions - 11. Composition-time conditionals: <<#if flag>>...<>...<> + 11. Composition-time conditionals: @{#if flag}@...@{else}@...@{/if}@ """ from __future__ import annotations @@ -115,7 +115,7 @@ class OnboardingInputs(BaseModel): overrides=[], ), # --- Structured variable (JSON object) for subfield composition --- - # Other variables can reference subfields like <> + # Other variables can reference subfields like @{brand.tagline}@ 'brand': VariableConfig( name='brand', labels={ @@ -133,20 +133,20 @@ class OnboardingInputs(BaseModel): rollout=Rollout(labels={'production': 1.0}), overrides=[], ), - # --- Composed variable: references <> --- + # --- Composed variable: references @{support_email}@ --- 'support_footer': VariableConfig( name='support_footer', labels={ 'production': LabeledValue( version=1, - serialized_value=json.dumps('Need help? Contact us at <>.'), + serialized_value=json.dumps('Need help? Contact us at @{support_email}@.'), ), }, rollout=Rollout(labels={'production': 1.0}), overrides=[], ), # --- Composed + templated variable --- - # References <>, <>, <> + # References @{app_name}@, @{safety_disclaimer}@, @{support_footer}@ # Also contains {{user.name}}, {{user.tier}}, {{topic}} template placeholders 'system_prompt': VariableConfig( name='system_prompt', @@ -154,20 +154,20 @@ class OnboardingInputs(BaseModel): 'production': LabeledValue( version=1, serialized_value=json.dumps( - 'You are a helpful assistant for <>.\n\n' + 'You are a helpful assistant for @{app_name}@.\n\n' 'The user you are speaking with is {{user.name}} ({{user.tier}} tier).\n' 'They want help with: {{topic}}\n\n' 'Guidelines:\n' '- Be concise and helpful\n' - '- <>\n\n' - '<>' + '- @{safety_disclaimer}@\n\n' + '@{support_footer}@' ), ), 'concise': LabeledValue( version=1, serialized_value=json.dumps( - '<> assistant. User: {{user.name}} ({{user.tier}}). ' - 'Topic: {{topic}}. Be brief. <>' + '@{app_name}@ assistant. User: {{user.name}} ({{user.tier}}). ' + 'Topic: {{topic}}. Be brief. @{safety_disclaimer}@' ), ), }, @@ -184,10 +184,10 @@ class OnboardingInputs(BaseModel): version=1, serialized_value=json.dumps( 'Hi {{user.name}},\n\n' - 'Your action "{{action}}" has been completed on <>.\n' + 'Your action "{{action}}" has been completed on @{app_name}@.\n' '{{#if details}}Details: {{details}}\n{{/if}}' '\nA confirmation has been sent to {{user.email}}.\n\n' - '<>' + '@{support_footer}@' ), ), }, @@ -196,7 +196,7 @@ class OnboardingInputs(BaseModel): template_inputs_schema=NotificationInputs.model_json_schema(), ), # --- Onboarding template: demonstrates #if/#else and #each --- - # Also uses <> subfield composition + # Also uses @{brand.tagline}@ subfield composition 'onboarding_message': VariableConfig( name='onboarding_message', labels={ @@ -204,10 +204,10 @@ class OnboardingInputs(BaseModel): version=1, serialized_value=json.dumps( '{{#if is_new_user}}' - 'Welcome to <>, {{user.name}}! ' - '<>.\n' + 'Welcome to @{app_name}@, {{user.name}}! ' + '@{brand.tagline}@.\n' '{{else}}' - 'Welcome back to <>, {{user.name}}!\n' + 'Welcome back to @{app_name}@, {{user.name}}!\n' '{{/if}}' '\n' '{{#if features}}' @@ -216,9 +216,9 @@ class OnboardingInputs(BaseModel): ' - {{this}}\n' '{{/each}}' '{{else}}' - 'No features enabled yet. Visit <> to get started.\n' + 'No features enabled yet. Visit @{brand.support_url}@ to get started.\n' '{{/if}}' - '\nQuestions? Reach out to <>.' + '\nQuestions? Reach out to @{support_email}@.' ), ), }, @@ -235,8 +235,8 @@ class OnboardingInputs(BaseModel): version=1, serialized_value=json.dumps( { - 'greeting': 'Welcome to <>, {{user.name}}!', - 'subtitle': 'Your {{user.tier}} account is ready. <>.', + 'greeting': 'Welcome to @{app_name}@, {{user.name}}!', + 'subtitle': 'Your {{user.tier}} account is ready. @{brand.tagline}@.', 'cta_text': 'Explore {{topic}}', 'show_banner': True, 'max_tokens': 500, @@ -258,8 +258,8 @@ class OnboardingInputs(BaseModel): rollout=Rollout(labels={'enabled': 1.0}), overrides=[], ), - # --- Composed variable using <<#if>> at composition time --- - # The <<#if beta_enabled>> block is evaluated during composition, NOT at + # --- Composed variable using @{#if}@ at composition time --- + # The @{#if beta_enabled}@ block is evaluated during composition, NOT at # template-render time. This means the conditional is resolved when the # variable value is expanded, controlled by the beta_enabled flag variable. 'banner_message': VariableConfig( @@ -268,11 +268,11 @@ class OnboardingInputs(BaseModel): 'production': LabeledValue( version=1, serialized_value=json.dumps( - '<<#if beta_enabled>>' - 'Try our new beta features! <>.' - '<>' - 'Welcome to <>.' - '<>' + '@{#if beta_enabled}@' + 'Try our new beta features! @{brand.tagline}@.' + '@{else}@' + 'Welcome to @{app_name}@.' + '@{/if}@' ), ), }, @@ -359,13 +359,13 @@ def section(title: str) -> None: # 5. Demo: Basic composition (no templates) # --------------------------------------------------------------------------- -section('1. Basic Composition: <> references expand inline') +section('1. Basic Composition: @{variable}@ references expand inline') result = support_footer_var.get() print(f'support_footer resolved to:\n "{result.value}"\n') print(f'Composed from {len(result.composed_from)} reference(s):') for ref in result.composed_from: - print(f' - <<{ref.name}>> -> "{ref.value}" (label={ref.label}, v{ref.version})') + print(f' - @{{{ref.name}}}@ -> "{ref.value}" (label={ref.label}, v{ref.version})') # --------------------------------------------------------------------------- # 6. Demo: Nested composition (A -> B -> C) @@ -379,7 +379,7 @@ def section(title: str) -> None: print(f' label={raw_result.label}, version={raw_result.version}') print() -# Show the composed value — <> are expanded but {{fields}} remain +# Show the composed value — @{refs}@ are expanded but {{fields}} remain composed_value = raw_result.value # Since templates haven't been rendered yet, {{...}} placeholders are literal print('Composed value ({{placeholders}} still present):') @@ -388,16 +388,16 @@ def section(title: str) -> None: print(f'\nComposed from {len(raw_result.composed_from)} top-level reference(s):') for ref in raw_result.composed_from: - print(f' - <<{ref.name}>> -> "{ref.value}"') + print(f' - @{{{ref.name}}}@ -> "{ref.value}"') # Show nested references (e.g. support_footer -> support_email) for nested in ref.composed_from: - print(f' -> <<{nested.name}>> -> "{nested.value}"') + print(f' -> @{{{nested.name}}}@ -> "{nested.value}"') # --------------------------------------------------------------------------- # 7. Demo: Subfield references to structured variables # --------------------------------------------------------------------------- -section('3. Subfield Variable References: <>, <>') +section('3. Subfield Variable References: @{brand.tagline}@, @{brand.support_url}@') print('The "brand" variable is a JSON object:') brand_result = brand_var.get() @@ -405,10 +405,10 @@ def section(title: str) -> None: print(f' {key}: {value!r}') print() -print('Other variables can reference individual fields via <>.') +print('Other variables can reference individual fields via @{brand.field}@.') print('For example, the onboarding_message template contains:') -print(' <> -> expands to the tagline string') -print(' <> -> expands to the support URL string') +print(' @{brand.tagline}@ -> expands to the tagline string') +print(' @{brand.support_url}@ -> expands to the support URL string') # --------------------------------------------------------------------------- # 8. Demo: Handlebars conditionals (#if / #else) @@ -450,7 +450,7 @@ def section(title: str) -> None: print() print('Composed references in the onboarding message:') for ref in result_new.composed_from: - print(f' - <<{ref.name}>> -> "{ref.value}"') + print(f' - @{{{ref.name}}}@ -> "{ref.value}"') # --------------------------------------------------------------------------- # 9. Demo: Template rendering with subfield access (user.name, user.tier) @@ -504,7 +504,7 @@ def section(title: str) -> None: # 11. Demo: Structured variable with templates and subfield composition # --------------------------------------------------------------------------- -section('7. Structured Variable: Templates + <> in dict values') +section('7. Structured Variable: Templates + @{brand.tagline}@ in dict values') struct_inputs = PromptInputs( user=UserProfile(name='Carol', email='carol@startup.io'), @@ -517,7 +517,7 @@ def section(title: str) -> None: print(f' {key}: {value!r}') print('\nNote: string values were rendered, non-strings (bool, int) pass through unchanged.') -print('The subtitle used <> to compose in the brand tagline.') +print('The subtitle used @{brand.tagline}@ to compose in the brand tagline.') # --------------------------------------------------------------------------- # 12. Demo: Rollout overrides with attributes @@ -551,13 +551,13 @@ def section(title: str) -> None: print(f' "{rendered_concise}"') # --------------------------------------------------------------------------- -# 14. Demo: Composition-time conditionals (<<#if>> at composition time) +# 14. Demo: Composition-time conditionals (@{#if}@ at composition time) # --------------------------------------------------------------------------- -section('10. Composition-Time Conditionals: <<#if>> with feature flags') +section('10. Composition-Time Conditionals: @{#if}@ with feature flags') -print('The banner_message variable uses <<#if beta_enabled>> at composition time.') -print('This conditional is resolved when <<>> references are expanded, NOT at') +print('The banner_message variable uses @{#if beta_enabled}@ at composition time.') +print('This conditional is resolved when @{}@ references are expanded, NOT at') print('template render time. The beta_enabled variable controls which branch appears.') print() @@ -570,7 +570,7 @@ def section(title: str) -> None: # Show the composed references print(f'Composed from {len(banner_result.composed_from)} reference(s):') for ref in banner_result.composed_from: - print(f' - <<{ref.name}>> = {ref.value!r}') + print(f' - @{{{ref.name}}}@ = {ref.value!r}') # --------------------------------------------------------------------------- # 15. Demo: Using context manager for baggage propagation @@ -595,14 +595,14 @@ def section(title: str) -> None: section('Summary') print('This demo showed:') -print(' - <> composition: inline expansion of variable references') +print(' - @{variable}@ composition: inline expansion of variable references') print(' - Nested composition: A -> B -> C chains expand recursively') -print(' - <> subfield refs: access fields of structured variables') +print(' - @{var.field}@ subfield refs: access fields of structured variables') print(' - {{field}} templates with Handlebars syntax') print(' - Subfield access in templates: {{user.name}}, {{user.email}}, {{user.tier}}') print(' - {{#if cond}}...{{else}}...{{/if}} conditionals (template-time)') print(' - {{#each list}}...{{/each}} iteration (template-time)') -print(' - <<#if flag>>...<>...<> conditionals (composition-time)') +print(' - @{#if flag}@...@{else}@...@{/if}@ conditionals (composition-time)') print(' - Structured variables: templates render inside dict string values') print(' - TemplateVariable: single-step get(inputs) with auto-rendering') print(' - Variable + render(): two-step manual rendering') diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 86d691c9e..0c2ccb9c8 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2657,7 +2657,7 @@ def template_var( Like ``var()``, but ``get(inputs)`` automatically renders Handlebars ``{{placeholder}}`` templates in the resolved value before returning. The pipeline is: - resolve → compose ``<>`` → render ``{{}}`` → deserialize. + resolve → compose ``@{refs}@`` → render ``{{}}`` → deserialize. ```py from pydantic import BaseModel diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index cce94f173..be9332779 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -115,7 +115,7 @@ class ResolvedVariable(Generic[T_co]): exception: Exception | None = None """Any exception that occurred during resolution.""" composed_from: list[ComposedReference] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] - """Variables that were composed into this value via <> expansion. + """Variables that were composed into this value via @{reference}@ expansion. Each entry is a ComposedReference for a referenced variable, including its label, version, reason, and any nested composed_from entries. @@ -588,7 +588,7 @@ def _check_reference_warnings( ) -> list[str]: """Check for reference warnings: non-existent refs and cycles. - Scans local variable defaults and server label values for <> + Scans local variable defaults and server label values for @{references}@ and validates that referenced variables exist and there are no cycles. """ from logfire.variables.composition import find_references @@ -628,7 +628,7 @@ def _check_reference_warnings( # 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.") + warnings_list.append(f"Variable '{variable.name}' references '@{{{ref_name}}}@' which does not exist.") # Check for cycles using DFS def _detect_cycles(graph: dict[str, set[str]]) -> list[list[str]]: diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index 1dedaa8f7..f10c995f1 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -1,9 +1,9 @@ -"""Variable composition: expand <> references in serialized values. +"""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 ``<>`` syntax and are expanded using -the Handlebars engine via character-swap, giving ``<<>>`` the full power of -Handlebars: ``<<#if>>``, ``<<#each>>``, ``<<#unless>>``, ``<<#with>>``, etc. +JSON strings. References use the ``@{variable_name}@`` syntax and are expanded using +the Handlebars engine, giving ``@{}@`` the full power of Handlebars: +``@{#if flag}@``, ``@{#each items}@``, ``@{#unless flag}@``, ``@{#with obj}@``, etc. Meanwhile, any ``{{runtime}}`` placeholders are preserved untouched for later template rendering. @@ -19,7 +19,7 @@ from dataclasses import dataclass, field from typing import Any, Callable, Optional, Tuple # noqa: UP035 -from logfire.variables.angle_bracket import render_once +from logfire.variables.reference_syntax import render_once __all__ = ( 'MAX_COMPOSITION_DEPTH', @@ -31,18 +31,18 @@ 'has_references', ) -# Matches unescaped << (not preceded by \). -# In JSON-serialized strings, a real backslash is \\, so \\<< is an escaped ref. -_HAS_ANGLE = re.compile(r'(?> or <> -_SIMPLE_REF = re.compile(r'(?>') +# Simple references: @{identifier}@ or @{identifier.field.subfield}@ +_SIMPLE_REF = re.compile(r'(?> — extracts the first identifier after the helper name. -_BLOCK_REF = re.compile(r'(?>)') +# Block helper references: @{#helper identifier ...}@ — extracts the first identifier after the helper name. +_BLOCK_REF = re.compile(r'(?> syntax but are Handlebars built-ins. +# These are valid in @{keyword}@ syntax but are Handlebars built-ins. _HBS_KEYWORDS = frozenset({'else', 'this'}) MAX_COMPOSITION_DEPTH = 20 @@ -58,7 +58,7 @@ class VariableCompositionCycleError(VariableCompositionError): @dataclass class ComposedReference: - """Metadata about a single <> that was encountered during expansion. + """Metadata about a single ``@{reference}@`` that was encountered during expansion. This is a lightweight dataclass used to track composition results without depending on ResolvedVariable, making it reusable from both the SDK and backend. @@ -85,8 +85,8 @@ class ComposedReference: def has_references(serialized_value: str) -> bool: - """Quick check for any unescaped ``<<`` in a serialized value.""" - return _HAS_ANGLE.search(serialized_value) is not None + """Quick check for any unescaped ``@{`` in a serialized value.""" + return _HAS_REFERENCE.search(serialized_value) is not None def expand_references( @@ -97,10 +97,10 @@ def expand_references( _visited: frozenset[str] = frozenset(), _depth: int = 0, ) -> tuple[str, list[ComposedReference]]: - """Expand <> references in a serialized variable value. + """Expand ``@{var}@`` references in a serialized variable value. - Uses the Handlebars engine via character-swap so that ``<<>>`` supports the - full Handlebars feature set (``<<#if>>``, ``<<#each>>``, etc.) while + Uses the Handlebars engine so that ``@{}@`` supports the full Handlebars + feature set (``@{#if flag}@``, ``@{#each items}@``, etc.) while preserving ``{{runtime}}`` placeholders untouched. Args: @@ -139,7 +139,7 @@ def expand_references( # Collect all unique base variable names referenced anywhere in the decoded value. all_ref_names = _collect_ref_names(decoded) if not all_ref_names: - # No references at all — return unchanged (but still unescape \<< → <<). + # No references at all — return unchanged (but still unescape \@{ → @{). expanded = _unescape_serialized(serialized_value) return expanded, composed @@ -227,13 +227,11 @@ def expand_references( context[ref_name] = raw_value # For unresolved variable names, add a self-referential context entry so that - # Handlebars renders <> back as literal "<>". The _protect_value - # function in render_once will entity-encode the <> characters in the value, - # preventing the swap from consuming them. + # Handlebars renders @{name}@ back as literal "@{name}@". for name in unresolved_names: - context[name] = f'<<{name}>>' + context[name] = f'@{{{name}}}@' - # Walk the decoded value and render each string through the Handlebars swap engine. + # Walk the decoded value and render each string through the reference-syntax Handlebars engine. rendered = _render_value(decoded, context) result_serialized = json.dumps(rendered) @@ -241,10 +239,10 @@ def expand_references( def find_references(serialized_value: str) -> list[str]: - """Find all <> references in a serialized value. + """Find all ``@{variable_name}@`` references in a serialized value. - Detects both simple ``<>`` and block ``<<#helper var>>`` patterns. - For dotted references like ``<>``, only the base variable name + Detects both simple ``@{var}@`` and block ``@{#helper var}@`` patterns. + For dotted references like ``@{var.field}@``, only the base variable name (first segment) is returned. This ensures correct cycle detection and reference graph building. @@ -257,7 +255,7 @@ def find_references(serialized_value: str) -> list[str]: seen: set[str] = set() result: list[str] = [] - # Simple references: <> or <> + # Simple references: @{var}@ or @{var.field}@ for match in _SIMPLE_REF.finditer(serialized_value): full_ref = match.group(1) var_name = full_ref.split('.')[0] @@ -265,7 +263,7 @@ def find_references(serialized_value: str) -> list[str]: seen.add(var_name) result.append(var_name) - # Block helper references: <<#if var>>, <<#each var>>, etc. + # Block helper references: @{#if var}@, @{#each var}@, etc. for match in _BLOCK_REF.finditer(serialized_value): var_name = match.group(1) if var_name not in seen and var_name not in _HBS_KEYWORDS: @@ -313,12 +311,12 @@ def _render_value(value: Any, context: dict[str, Any]) -> Any: """Recursively walk a decoded JSON value, rendering strings through Handlebars. Unresolved variable names should already be present in the context as their - literal ``<>`` text so that Handlebars preserves them. + literal ``@{name}@`` text so that Handlebars preserves them. """ if isinstance(value, str): if not has_references(value): - # Unescape \<< to << for non-reference strings. - return value.replace('\\<<', '<<') + # Unescape \@{ to @{ for non-reference strings. + return value.replace('\\@{', '@{') return render_once(value, context) if isinstance(value, dict): return {k: _render_value(v, context) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] @@ -328,9 +326,9 @@ def _render_value(value: Any, context: dict[str, Any]) -> Any: def _unescape_serialized(serialized: str) -> str: - r"""Unescape ``\<<`` to ``<<`` in a JSON-serialized string. + 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. + In JSON encoding, a literal backslash is ``\\``, so ``\@{`` in user content + appears as ``\\@{`` in the serialized JSON. """ - return serialized.replace('\\\\<<', '<<') + return serialized.replace('\\\\@{', '@{') diff --git a/logfire/variables/angle_bracket.py b/logfire/variables/reference_syntax.py similarity index 50% rename from logfire/variables/angle_bracket.py rename to logfire/variables/reference_syntax.py index 82c25298b..c538cc384 100644 --- a/logfire/variables/angle_bracket.py +++ b/logfire/variables/reference_syntax.py @@ -1,47 +1,45 @@ -"""Angle-bracket Handlebars: low-level swap primitives for <<>> rendering. +"""Reference-syntax Handlebars: low-level primitives for ``@{}@`` rendering. This module provides ``render_once`` which performs a single-pass render using -``<<>>`` as the delimiter instead of ``{{}}``. It is the engine behind variable -composition — it gives ``<<>>`` syntax the full power of Handlebars +``@{}@`` as the delimiter instead of ``{{}}``. It is the engine behind variable +composition — it gives ``@{}@`` syntax the full power of Handlebars (conditionals, loops, helpers, etc.) while preserving any ``{{}}`` runtime placeholders untouched. -Algorithm (swap + protect): - a. Protect ``{}<>`` characters in context values with HTML entities - b. Swap ``{↔<`` and ``}↔>`` in the template (so ``<<>>`` becomes ``{{}}``) +Algorithm: + a. Protect ``{{...}}`` runtime placeholders in the template + b. Convert ``@{...}@`` reference tags to standard Handlebars ``{{...}}`` tags c. Run standard Handlebars - d. Reverse swap - e. Unescape the entities we introduced + d. Restore the protected runtime placeholders + e. Unescape entities introduced to protect context values """ from __future__ import annotations +import re from typing import Any from pydantic_handlebars import SafeString, render as hbs_render -# --------------------------------------------------------------------------- -# Character swap table: { ↔ < and } ↔ > -# --------------------------------------------------------------------------- -_SWAP = str.maketrans('{}<>', '<>{}') +_REFERENCE_TAG = re.compile(r'(? in values to numeric entities that contain -# NO {}<> characters, so the reverse swap can't corrupt them. +# Protection: escape {} in values to numeric entities so rendered values can't +# be interpreted as Handlebars syntax during this composition pass. # --------------------------------------------------------------------------- _PROTECT = str.maketrans( { '{': '{', '}': '}', - '<': '<', - '>': '>', } ) def _unescape_protected(s: str) -> str: - """Undo only the four entities we introduced.""" - return s.replace('{', '{').replace('}', '}').replace('<', '<').replace('>', '>') + """Undo only the entities we introduced.""" + return s.replace('{', '{').replace('}', '}') def _protect_value(value: Any) -> Any: @@ -56,14 +54,15 @@ def _protect_value(value: Any) -> Any: # --------------------------------------------------------------------------- -# Core single-pass render: swap → Handlebars → unswap → unescape +# Core single-pass render: protect runtime placeholders → convert refs → render # --------------------------------------------------------------------------- def render_once(template: str, context: dict[str, Any]) -> str: - """Single-pass render: swap <<>>↔{{}}, run Handlebars, reverse swap, unescape.""" - swapped_template = template.translate(_SWAP) + """Single-pass render: convert ``@{}@`` tags, run Handlebars, restore ``{{}}``.""" + protected_template = template.replace('{{', _LEFT_RUNTIME_PLACEHOLDER).replace('}}', _RIGHT_RUNTIME_PLACEHOLDER) + handlebars_template = _REFERENCE_TAG.sub(r'{{\1}}', protected_template) safe_context = {k: _protect_value(v) for k, v in context.items()} - result: str = hbs_render(swapped_template, safe_context) - result = result.translate(_SWAP) - return _unescape_protected(result) + result: str = hbs_render(handlebars_template, safe_context) + result = result.replace(_LEFT_RUNTIME_PLACEHOLDER, '{{').replace(_RIGHT_RUNTIME_PLACEHOLDER, '}}') + return _unescape_protected(result).replace('\\@{', '@{') diff --git a/logfire/variables/template_validation.py b/logfire/variables/template_validation.py index 39ecf8133..49b915321 100644 --- a/logfire/variables/template_validation.py +++ b/logfire/variables/template_validation.py @@ -1,7 +1,7 @@ """Template validation: check ``{{field}}`` references against ``template_inputs_schema``. This module validates that Handlebars ``{{field}}`` references in template variable -values (including composed ``<>`` dependencies) match the declared +values (including composed ``@{ref}@`` dependencies) match the declared ``template_inputs_schema``. It uses ``pydantic_handlebars.check_template_compatibility`` for full AST-based schema checking (nested paths, block scopes, helpers). @@ -103,7 +103,7 @@ def validate_template_composition( """Validate that ``{{field}}`` references in a template variable match its schema. Walks the composition graph starting from *variable_name*, collecting all - template strings from the variable's values and its ``<>`` dependencies, + template strings from the variable's values and its ``@{ref}@`` dependencies, then uses AST-based schema checking via ``check_template_compatibility`` to find incompatible field references. diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 582a52bd7..dd1c8e323 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -303,7 +303,7 @@ def _expand_and_deserialize( span: logfire.LogfireSpan | None, render_fn: Callable[[str], str] | None = None, ) -> ResolvedVariable[T_co]: - """Expand <> in a serialized value, optionally render templates, then deserialize. + """Expand @{references}@ in a serialized value, optionally render templates, then deserialize. Handles composition between the provider fetch and Pydantic deserialization. When render_fn is provided, it is applied after composition and before deserialization. @@ -313,7 +313,7 @@ def _expand_and_deserialize( serialized_value = serialized_result.value composed: list[ComposedReference] = [] - # Expand <> if any are present + # Expand @{references}@ if any are present if has_references(serialized_value): def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str]: @@ -558,7 +558,7 @@ class TemplateVariable(_BaseVariable[T_co], Generic[T_co, InputsT]): Like ``Variable``, but ``get()`` requires ``inputs`` and automatically renders Handlebars ``{{placeholder}}`` templates in the resolved value before returning. - The pipeline is: resolve → compose ``<>`` → render ``{{}}`` → deserialize. + The pipeline is: resolve → compose ``@{refs}@`` → render ``{{}}`` → deserialize. """ inputs_type: type[InputsT] @@ -608,7 +608,7 @@ def get( The resolution pipeline is: 1. Fetch serialized value from provider (or use default) - 2. Expand ``<>`` composition references + 2. Expand ``@{variable_name}@`` composition references 3. Render ``{{placeholder}}`` Handlebars templates using ``inputs`` 4. Deserialize to the variable's type diff --git a/tests/test_template_validation.py b/tests/test_template_validation.py index 1486ddfb3..ed844bc80 100644 --- a/tests/test_template_validation.py +++ b/tests/test_template_validation.py @@ -233,11 +233,11 @@ def test_field_not_in_schema(self): assert issue.reference_path == [] def test_transitive_reference_issue(self): - """var_a references <>, var_b has {{field}} not in var_a's schema.""" + """var_a references @{var_b}@, var_b has {{field}} not in var_a's schema.""" schema = {'properties': {'name': {'type': 'string'}}} get_values = _make_get_all_serialized( { - 'var_a': {None: '"Hello {{name}} <>"'}, + 'var_a': {None: '"Hello {{name}} @{var_b}@"'}, 'var_b': {None: '"extra {{bad_field}}"'}, } ) @@ -271,8 +271,8 @@ def test_cycle_does_not_infinite_loop(self): schema = {'properties': {}} get_values = _make_get_all_serialized( { - 'a': {None: '"<>"'}, - 'b': {None: '"<>"'}, + 'a': {None: '"@{b}@"'}, + 'b': {None: '"@{a}@"'}, } ) # Should complete without hanging @@ -350,8 +350,8 @@ def test_transitive_chain(self): schema = {'properties': {'ok': {'type': 'string'}}} get_values = _make_get_all_serialized( { - 'a': {None: '"{{ok}} <>"'}, - 'b': {None: '"<>"'}, + 'a': {None: '"{{ok}} @{b}@"'}, + 'b': {None: '"@{c}@"'}, 'c': {None: '"{{deep_field}}"'}, } ) @@ -368,7 +368,7 @@ def test_duplicate_issue_dedup(self): # Two labels in a, both reference b which has the same field get_values = _make_get_all_serialized( { - 'a': {None: '"<>"', 'prod': '"<>"'}, + 'a': {None: '"@{b}@"', 'prod': '"@{b}@"'}, 'b': {None: '"{{field}}"'}, } ) @@ -382,7 +382,7 @@ def test_issue_reference_path_is_copy(self): schema = {'properties': {'allowed': {'type': 'string'}}} get_values = _make_get_all_serialized( { - 'a': {None: '"<> <>"'}, + 'a': {None: '"@{b}@ @{c}@"'}, 'b': {None: '"{{field_b}}"'}, 'c': {None: '"{{field_c}}"'}, } diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index cccdd4d3a..00b2b77a3 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -1,4 +1,4 @@ -"""Tests for variable composition (<> reference expansion).""" +"""Tests for variable composition (@{variable_name}@ reference expansion).""" # pyright: reportPrivateUsage=false @@ -51,16 +51,16 @@ def resolve_fn(ref_name: str) -> tuple[str | None, str | None, int | None, str]: class TestExpandReferences: def test_no_references(self): - """Values without <<>> are returned unchanged.""" + """Values without @{}@ are returned unchanged.""" resolve_fn = _make_resolve_fn({}) expanded, composed = expand_references('"hello world"', 'my_var', resolve_fn) assert expanded == '"hello world"' assert composed == [] def test_simple_string_reference(self): - """Simple <> expands to the referenced string value.""" + """Simple @{ref}@ expands to the referenced string value.""" resolve_fn = _make_resolve_fn({'greeting': '"Hello"'}) - expanded, composed = expand_references('"<> World"', 'my_var', resolve_fn) + expanded, composed = expand_references('"@{greeting}@ World"', 'my_var', resolve_fn) assert expanded == '"Hello World"' assert len(composed) == 1 assert composed[0].name == 'greeting' @@ -71,23 +71,23 @@ def test_simple_string_reference(self): assert composed[0].error is None def test_multiple_references(self): - """Multiple <> in one value are all expanded.""" + """Multiple @{refs}@ in one value are all expanded.""" resolve_fn = _make_resolve_fn( { 'greeting': '"Hello"', 'name': '"World"', } ) - expanded, composed = expand_references('"<> <>!"', 'my_var', resolve_fn) + expanded, composed = expand_references('"@{greeting}@ @{name}@!"', 'my_var', resolve_fn) assert expanded == '"Hello World!"' assert len(composed) == 2 assert composed[0].name == 'greeting' assert composed[1].name == 'name' def test_same_reference_multiple_times(self): - """The same <> used multiple times expands each occurrence.""" + """The same @{ref}@ used multiple times expands each occurrence.""" resolve_fn = _make_resolve_fn({'word': '"echo"'}) - expanded, composed = expand_references('"<> <>"', 'my_var', resolve_fn) + expanded, composed = expand_references('"@{word}@ @{word}@"', 'my_var', resolve_fn) assert expanded == '"echo echo"' # Handlebars resolves all occurrences in one pass, so only one ComposedReference assert len(composed) == 1 @@ -97,11 +97,11 @@ def test_nested_references(self): """References within referenced values are expanded recursively.""" resolve_fn = _make_resolve_fn( { - 'a': '"Hello <>"', + 'a': '"Hello @{b}@"', 'b': '"World"', } ) - expanded, composed = expand_references('"<>!"', 'my_var', resolve_fn) + expanded, composed = expand_references('"@{a}@!"', 'my_var', resolve_fn) assert expanded == '"Hello World!"' assert len(composed) == 1 assert composed[0].name == 'a' @@ -113,14 +113,14 @@ def test_cycle_detection(self): """Circular references are caught and the reference is left unexpanded.""" resolve_fn = _make_resolve_fn( { - 'a': '"<>"', - 'b': '"<>"', + 'a': '"@{b}@"', + 'b': '"@{a}@"', } ) # The cycle is caught inside expand_references; b tries to expand a # which is already in the visited set. - _, composed = expand_references('"<>"', 'my_var', resolve_fn) - # a expands, but when b tries to expand <>, it hits the cycle. + _, composed = expand_references('"@{a}@"', 'my_var', resolve_fn) + # a expands, but when b tries to expand @{a}@, it hits the cycle. # b is successfully resolved but its nested ref to a fails (cycle). assert len(composed) == 1 assert composed[0].name == 'a' @@ -128,7 +128,7 @@ def test_cycle_detection(self): assert len(composed[0].composed_from) == 1 b_ref = composed[0].composed_from[0] assert b_ref.name == 'b' - # b itself resolved, but its expansion of <> failed + # b itself resolved, but its expansion of @{a}@ failed assert len(b_ref.composed_from) == 1 assert b_ref.composed_from[0].name == 'a' assert b_ref.composed_from[0].error is not None @@ -136,12 +136,12 @@ def test_cycle_detection(self): def test_self_reference_cycle(self): """A variable referencing itself is caught.""" - resolve_fn = _make_resolve_fn({'a': '"<>"'}) + resolve_fn = _make_resolve_fn({'a': '"@{a}@"'}) # my_var references a, a references itself - _, composed = expand_references('"<>"', 'my_var', resolve_fn) + _, composed = expand_references('"@{a}@"', 'my_var', resolve_fn) assert len(composed) == 1 assert composed[0].name == 'a' - # a resolved, but its self-reference <> failed with cycle + # a resolved, but its self-reference @{a}@ failed with cycle assert len(composed[0].composed_from) == 1 assert composed[0].composed_from[0].name == 'a' assert composed[0].composed_from[0].error is not None @@ -153,11 +153,11 @@ def test_depth_limit(self): variables: dict[str, str | None] = {} for i in range(22): if i < 21: - variables[f'var_{i}'] = f'"<>"' + variables[f'var_{i}'] = f'"@{{var_{i + 1}}}@"' else: variables[f'var_{i}'] = '"end"' resolve_fn = _make_resolve_fn(variables) - _, composed = expand_references('"<>"', 'my_var', resolve_fn) + _, composed = expand_references('"@{var_0}@"', 'my_var', resolve_fn) # Should have error about depth limit somewhere in the chain assert len(composed) == 1 @@ -176,8 +176,8 @@ def test_depth_limit(self): def test_unresolvable_reference(self): """References to non-existent variables are left unexpanded.""" resolve_fn = _make_resolve_fn({}) - expanded, composed = expand_references('"Hello <>"', 'my_var', resolve_fn) - assert expanded == '"Hello <>"' + expanded, composed = expand_references('"Hello @{nonexistent}@"', 'my_var', resolve_fn) + assert expanded == '"Hello @{nonexistent}@"' assert len(composed) == 1 assert composed[0].name == 'nonexistent' assert composed[0].value is None @@ -186,15 +186,15 @@ def test_unresolvable_reference(self): def test_none_value_reference(self): """References to variables with None value are left unexpanded.""" resolve_fn = _make_resolve_fn({'missing': None}) - expanded, composed = expand_references('"Hello <>"', 'my_var', resolve_fn) - assert expanded == '"Hello <>"' + expanded, composed = expand_references('"Hello @{missing}@"', 'my_var', resolve_fn) + assert expanded == '"Hello @{missing}@"' assert len(composed) == 1 assert composed[0].value is None def test_non_string_reference(self): """Non-string variables (numbers) are rendered via Handlebars toString.""" resolve_fn = _make_resolve_fn({'number': '42'}) - expanded, composed = expand_references('"Value: <>"', 'my_var', resolve_fn) + expanded, composed = expand_references('"Value: @{number}@"', 'my_var', resolve_fn) assert expanded == '"Value: 42"' assert len(composed) == 1 assert composed[0].error is None @@ -202,7 +202,7 @@ def test_non_string_reference(self): def test_boolean_reference(self): """Boolean variables are rendered via Handlebars toString.""" resolve_fn = _make_resolve_fn({'flag': 'true'}) - expanded, composed = expand_references('"Flag: <>"', 'my_var', resolve_fn) + expanded, composed = expand_references('"Flag: @{flag}@"', 'my_var', resolve_fn) assert expanded == '"Flag: true"' assert len(composed) == 1 assert composed[0].error is None @@ -210,7 +210,7 @@ def test_boolean_reference(self): def test_object_reference(self): """Object variables are available in the Handlebars context.""" resolve_fn = _make_resolve_fn({'obj': '{"key": "value"}'}) - expanded, composed = expand_references('"Data: <>"', 'my_var', resolve_fn) + expanded, composed = expand_references('"Data: @{obj}@"', 'my_var', resolve_fn) # Handlebars renders objects via toString — typically [object Object] or similar result = json.loads(expanded) assert 'Data:' in result @@ -220,7 +220,7 @@ def test_object_reference(self): def test_structured_type_with_references(self): """References inside JSON string values of structured types expand correctly.""" resolve_fn = _make_resolve_fn({'safety': '"Be safe."'}) - serialized = json.dumps({'prompt': '<> Always.', 'model': 'gpt-4'}) + serialized = json.dumps({'prompt': '@{safety}@ Always.', 'model': 'gpt-4'}) expanded, composed = expand_references(serialized, 'my_var', resolve_fn) parsed = json.loads(expanded) assert parsed['prompt'] == 'Be safe. Always.' @@ -231,47 +231,47 @@ def test_structured_type_with_references(self): def test_json_encoding_newlines(self): """Newlines in referenced values are properly JSON-escaped.""" resolve_fn = _make_resolve_fn({'multi': '"Line1\\nLine2"'}) - expanded, _ = expand_references('"Before <> After"', 'my_var', resolve_fn) + expanded, _ = expand_references('"Before @{multi}@ After"', 'my_var', resolve_fn) result = json.loads(expanded) assert result == 'Before Line1\nLine2 After' def test_json_encoding_quotes(self): """Quotes in referenced values are properly JSON-escaped.""" resolve_fn = _make_resolve_fn({'quoted': '"She said \\"hello\\""'}) - expanded, _ = expand_references('"<>!"', 'my_var', resolve_fn) + expanded, _ = expand_references('"@{quoted}@!"', 'my_var', resolve_fn) result = json.loads(expanded) assert result == 'She said "hello"!' def test_json_encoding_unicode(self): """Unicode in referenced values works correctly.""" resolve_fn = _make_resolve_fn({'emoji': json.dumps('Hello 🌍')}) - expanded, _ = expand_references('"<>!"', 'my_var', resolve_fn) + expanded, _ = expand_references('"@{emoji}@!"', 'my_var', resolve_fn) result = json.loads(expanded) assert result == 'Hello 🌍!' def test_json_encoding_backslashes(self): """Backslashes in referenced values are properly JSON-escaped.""" resolve_fn = _make_resolve_fn({'path': json.dumps('C:\\Users\\test')}) - expanded, _ = expand_references('"Path: <>"', 'my_var', resolve_fn) + expanded, _ = expand_references('"Path: @{path}@"', 'my_var', resolve_fn) result = json.loads(expanded) assert result == 'Path: C:\\Users\\test' def test_escape_sequence(self): - r"""Escaped \<< is converted to literal <<. + r"""Escaped \@{ is converted to literal @{. - In serialized JSON, a literal backslash before << is encoded as \\<<. - The regex lookbehind prevents matching, and post-processing converts \<< to <<. + In serialized JSON, a literal backslash before @{ is encoded as \\@{. + The regex lookbehind prevents matching, and post-processing converts \@{ to @{. """ resolve_fn = _make_resolve_fn({'ref': '"expanded"'}) - # Build a JSON string that contains: not \<> but <> + # Build a JSON string that contains: not \@{ref}@ but @{ref}@ # In JSON encoding, backslash must be \\, so the raw JSON is: - # "not \\<> but <>" - raw_python_str = 'not \\<> but <>' + # "not \\@{ref}@ but @{ref}@" + raw_python_str = 'not \\@{ref}@ but @{ref}@' serialized = json.dumps(raw_python_str) - # serialized is: "not \\<> but <>" + # serialized is: "not \\@{ref}@ but @{ref}@" expanded, composed = expand_references(serialized, 'my_var', resolve_fn) result = json.loads(expanded) - assert result == 'not <> but expanded' + assert result == 'not @{ref}@ but expanded' # Only the real ref (second one) is in composed assert len(composed) == 1 assert composed[0].name == 'ref' @@ -279,18 +279,18 @@ def test_escape_sequence(self): def test_escape_only(self): r"""Only escaped references, no real references.""" resolve_fn = _make_resolve_fn({}) - raw_python_str = 'literal \\<>' + raw_python_str = 'literal \\@{tag}@' serialized = json.dumps(raw_python_str) expanded, composed = expand_references(serialized, 'my_var', resolve_fn) result = json.loads(expanded) - assert result == 'literal <>' + assert result == 'literal @{tag}@' assert composed == [] def test_invalid_json_reference(self): """References to values with invalid JSON are left unexpanded.""" resolve_fn = _make_resolve_fn({'bad': 'not json at all'}) - expanded, composed = expand_references('"<>"', 'my_var', resolve_fn) - assert expanded == '"<>"' + expanded, composed = expand_references('"@{bad}@"', 'my_var', resolve_fn) + assert expanded == '"@{bad}@"' assert len(composed) == 1 assert composed[0].error is not None assert 'non-JSON' in composed[0].error @@ -301,118 +301,118 @@ def test_no_references(self): assert find_references('"hello world"') == [] def test_single_reference(self): - assert find_references('"<>"') == ['greeting'] + assert find_references('"@{greeting}@"') == ['greeting'] def test_multiple_unique_references(self): - assert find_references('"<> <> <>"') == ['a', 'b', 'c'] + assert find_references('"@{a}@ @{b}@ @{c}@"') == ['a', 'b', 'c'] def test_duplicate_references(self): """Duplicates are deduplicated, order preserved.""" - assert find_references('"<> <> <>"') == ['a', 'b'] + assert find_references('"@{a}@ @{b}@ @{a}@"') == ['a', 'b'] def test_escaped_not_matched(self): - assert find_references(r'"\\<>"') == [] + assert find_references(r'"\\@{escaped}@"') == [] def test_mixed_escaped_and_real(self): - result = find_references(r'"\\<> <>"') + result = find_references(r'"\\@{escaped}@ @{real}@"') assert result == ['real'] def test_in_structured_json(self): - serialized = json.dumps({'prompt': '<>', 'other': '<>'}) + serialized = json.dumps({'prompt': '@{safety}@', 'other': '@{format}@'}) assert find_references(serialized) == ['safety', 'format'] def test_find_references_block_helpers(self): """find_references detects variable names from block helper syntax.""" - serialized = json.dumps('<<#if brand>>show<>hide<>') + serialized = json.dumps('@{#if brand}@show@{else}@hide@{/if}@') result = find_references(serialized) assert 'brand' in result def test_find_references_block_and_simple(self): """find_references finds both simple and block-helper references.""" - serialized = json.dumps('<> <<#if flag>>yes<>') + serialized = json.dumps('@{greeting}@ @{#if flag}@yes@{/if}@') result = find_references(serialized) assert 'greeting' in result assert 'flag' in result # ============================================================================= -# Tests for Handlebars-powered <<>> block helpers +# Tests for Handlebars-powered @{}@ block helpers # ============================================================================= class TestBlockHelpers: def test_block_if_true(self): - """<<#if flag>>yes<>no<> with truthy flag.""" + """@{#if flag}@yes@{else}@no@{/if}@ with truthy flag.""" resolve_fn = _make_resolve_fn({'flag': 'true'}) - expanded, composed = expand_references('"<<#if flag>>yes<>no<>"', 'my_var', resolve_fn) + expanded, composed = expand_references('"@{#if flag}@yes@{else}@no@{/if}@"', 'my_var', resolve_fn) assert json.loads(expanded) == 'yes' assert len(composed) == 1 assert composed[0].name == 'flag' def test_block_if_false(self): - """<<#if flag>>yes<>no<> with falsy flag.""" + """@{#if flag}@yes@{else}@no@{/if}@ with falsy flag.""" resolve_fn = _make_resolve_fn({'flag': 'false'}) - expanded, composed = expand_references('"<<#if flag>>yes<>no<>"', 'my_var', resolve_fn) + expanded, composed = expand_references('"@{#if flag}@yes@{else}@no@{/if}@"', 'my_var', resolve_fn) assert json.loads(expanded) == 'no' assert len(composed) == 1 assert composed[0].name == 'flag' def test_block_each(self): - """<<#each items>>- <><> iterates over a list.""" + """@{#each items}@- @{this}@@{/each}@ iterates over a list.""" resolve_fn = _make_resolve_fn({'items': '["a", "b", "c"]'}) - expanded, composed = expand_references('"<<#each items>><> <>"', 'my_var', resolve_fn) + expanded, composed = expand_references('"@{#each items}@@{this}@ @{/each}@"', 'my_var', resolve_fn) result = json.loads(expanded) assert result == 'a b c ' assert len(composed) == 1 assert composed[0].name == 'items' def test_block_unless(self): - """<<#unless flag>>shown<> with falsy flag.""" + """@{#unless flag}@shown@{/unless}@ with falsy flag.""" resolve_fn = _make_resolve_fn({'flag': 'false'}) - expanded, _ = expand_references('"<<#unless flag>>shown<>"', 'my_var', resolve_fn) + expanded, _ = expand_references('"@{#unless flag}@shown@{/unless}@"', 'my_var', resolve_fn) assert json.loads(expanded) == 'shown' def test_block_unless_truthy(self): - """<<#unless flag>>shown<> with truthy flag shows nothing.""" + """@{#unless flag}@shown@{/unless}@ with truthy flag shows nothing.""" resolve_fn = _make_resolve_fn({'flag': 'true'}) - expanded, _ = expand_references('"<<#unless flag>>shown<>"', 'my_var', resolve_fn) + expanded, _ = expand_references('"@{#unless flag}@shown@{/unless}@"', 'my_var', resolve_fn) assert json.loads(expanded) == '' def test_block_with(self): - """<<#with config>><><> accesses nested fields.""" + """@{#with config}@@{name}@@{/with}@ accesses nested fields.""" resolve_fn = _make_resolve_fn({'config': '{"name": "acme"}'}) - expanded, _ = expand_references('"<<#with config>><><>"', 'my_var', resolve_fn) + expanded, _ = expand_references('"@{#with config}@@{name}@@{/with}@"', 'my_var', resolve_fn) assert json.loads(expanded) == 'acme' def test_block_if_with_composition(self): - """<<#if brand>><><> — conditional with dotted access.""" + """@{#if brand}@@{brand.tagline}@@{/if}@ — conditional with dotted access.""" resolve_fn = _make_resolve_fn({'brand': '{"tagline": "Build faster"}'}) - expanded, _ = expand_references('"<<#if brand>><><>"', 'my_var', resolve_fn) + expanded, _ = expand_references('"@{#if brand}@@{brand.tagline}@@{/if}@"', 'my_var', resolve_fn) assert json.loads(expanded) == 'Build faster' - def test_mixed_angle_and_curly_preserved(self): - """<> expands, {{user.name}} is preserved for later rendering.""" + def test_reference_and_curly_placeholders_preserved(self): + """@{greeting}@ expands, {{user.name}} is preserved for later rendering.""" resolve_fn = _make_resolve_fn({'greeting': '"Hello"'}) - expanded, _ = expand_references('"<> {{user.name}}"', 'my_var', resolve_fn) + expanded, _ = expand_references('"@{greeting}@ {{user.name}}"', 'my_var', resolve_fn) assert json.loads(expanded) == 'Hello {{user.name}}' - def test_escape_angle_bracket(self): - r"""Escaped \<> becomes literal <> in output.""" + def test_escape_reference_syntax(self): + r"""Escaped \@{ref}@ becomes literal @{ref}@ in output.""" resolve_fn = _make_resolve_fn({'ref': '"expanded"'}) - raw_python_str = '\\<>' + raw_python_str = '\\@{ref}@' serialized = json.dumps(raw_python_str) expanded, _ = expand_references(serialized, 'my_var', resolve_fn) result = json.loads(expanded) - assert result == '<>' + assert result == '@{ref}@' def test_escape_mixed(self): - r"""Escaped \<> stays literal, real <> expands.""" + r"""Escaped \@{escaped}@ stays literal, real @{real}@ expands.""" resolve_fn = _make_resolve_fn({'escaped': '"X"', 'real': '"expanded"'}) - raw_python_str = '\\<> <>' + raw_python_str = '\\@{escaped}@ @{real}@' serialized = json.dumps(raw_python_str) expanded, _ = expand_references(serialized, 'my_var', resolve_fn) result = json.loads(expanded) - assert result == '<> expanded' + assert result == '@{escaped}@ expanded' # ============================================================================= @@ -445,10 +445,10 @@ def _make_variables_config(**variables: str | None) -> VariablesConfig: class TestCompositionIntegration: def test_simple_reference(self, config_kwargs: dict[str, Any]): - """End-to-end: variable with <> is resolved with composition.""" + """End-to-end: variable with @{ref}@ is resolved with composition.""" variables_config = _make_variables_config( greeting='"Hello"', - main='"<> World"', + main='"@{greeting}@ World"', ) config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) lf = logfire.configure(**config_kwargs) @@ -464,8 +464,8 @@ def test_nested_reference(self, config_kwargs: dict[str, Any]): """A→B→C chain resolves fully.""" variables_config = _make_variables_config( c='"end"', - b='"<>_b"', - a='"<>_a"', + b='"@{c}@_b"', + a='"@{b}@_a"', ) config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) lf = logfire.configure(**config_kwargs) @@ -480,8 +480,8 @@ def test_nested_reference(self, config_kwargs: dict[str, Any]): def test_cycle_falls_back_gracefully(self, config_kwargs: dict[str, Any]): """Cycles in references cause graceful fallback to default.""" variables_config = _make_variables_config( - a='"<>"', - b='"<>"', + a='"@{b}@"', + b='"@{a}@"', ) config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) lf = logfire.configure(**config_kwargs) @@ -489,22 +489,22 @@ def test_cycle_falls_back_gracefully(self, config_kwargs: dict[str, Any]): var = lf.var(name='a', default='fallback', type=str) result = var.get() # The cycle in b trying to reference a (which is in the visited set) means - # b's expansion fails, b is left as <> inside a's value. - # So a's value becomes "<>" (the literal unexpanded ref from b's failed expansion). + # b's expansion fails, b is left as @{a}@ inside a's value. + # So a's value becomes "@{a}@" (the literal unexpanded ref from b's failed expansion). # Actually the value should still deserialize as a string, just with unexpanded refs. assert isinstance(result.value, str) def test_nonexistent_reference_left_unexpanded(self, config_kwargs: dict[str, Any]): """References to non-existent variables are left as-is.""" variables_config = _make_variables_config( - main='"Hello <>"', + main='"Hello @{nonexistent}@"', ) config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) lf = logfire.configure(**config_kwargs) var = lf.var(name='main', default='fallback', type=str) result = var.get() - assert result.value == 'Hello <>' + assert result.value == 'Hello @{nonexistent}@' def test_non_string_reference_expanded(self, config_kwargs: dict[str, Any]): """Non-string variables are now expanded via Handlebars.""" @@ -521,10 +521,10 @@ def test_non_string_reference_expanded(self, config_kwargs: dict[str, Any]): 'main': VariableConfig( name='main', json_schema={'type': 'string'}, - labels={'production': LabeledValue(version=1, serialized_value='"Value: <>"')}, + labels={'production': LabeledValue(version=1, serialized_value='"Value: @{number}@"')}, rollout=Rollout(labels={'production': 1.0}), overrides=[], - latest_version=LatestVersion(version=1, serialized_value='"Value: <>"'), + latest_version=LatestVersion(version=1, serialized_value='"Value: @{number}@"'), ), } variables_config = VariablesConfig(variables=configs) @@ -543,7 +543,7 @@ class AgentConfig(BaseModel): model: str safety_value = json.dumps('Be safe.') - agent_value = json.dumps({'prompt': '<> Always.', 'model': 'gpt-4'}) + agent_value = json.dumps({'prompt': '@{safety}@ Always.', 'model': 'gpt-4'}) configs: dict[str, VariableConfig] = { 'safety': VariableConfig( @@ -578,7 +578,7 @@ def test_no_composition_for_context_override(self, config_kwargs: dict[str, Any] """Context overrides return typed values directly, no composition.""" variables_config = _make_variables_config( greeting='"Hello"', - main='"<> World"', + main='"@{greeting}@ World"', ) config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) lf = logfire.configure(**config_kwargs) @@ -594,7 +594,7 @@ def test_composition_with_explicit_label(self, config_kwargs: dict[str, Any]): """Composition works when using explicit label parameter.""" variables_config = _make_variables_config( greeting='"Hello"', - main='"<> World"', + main='"@{greeting}@ World"', ) config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) lf = logfire.configure(**config_kwargs) @@ -608,7 +608,7 @@ def test_span_attributes_with_composition(self, config_kwargs: dict[str, Any], e """Span attributes include composed_from when composition occurs.""" variables_config = _make_variables_config( greeting='"Hello"', - main='"<> World"', + main='"@{greeting}@ World"', ) config_kwargs['variables'] = LocalVariablesOptions(config=variables_config, instrument=True) lf = logfire.configure(**config_kwargs) @@ -670,10 +670,10 @@ def test_no_value_no_composition(self, config_kwargs: dict[str, Any]): config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) lf = logfire.configure(**config_kwargs) - var = lf.var(name='main', default='<> fallback', type=str) + var = lf.var(name='main', default='@{greeting}@ fallback', type=str) result = var.get() # Default is returned as-is (no composition on defaults) - assert result.value == '<> fallback' + assert result.value == '@{greeting}@ fallback' assert result.composed_from == [] diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index 807da7c65..402312c73 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -307,7 +307,7 @@ class TestCompositionThenRendering: """Test the full pipeline: resolve → compose → render.""" def test_composition_then_render(self, config_kwargs: dict[str, Any]): - """<> are expanded first, then {{placeholders}} are rendered.""" + """@{references}@ are expanded first, then {{placeholders}} are rendered.""" variables_config = VariablesConfig( variables={ 'snippet': VariableConfig( @@ -323,7 +323,7 @@ def test_composition_then_render(self, config_kwargs: dict[str, Any]): labels={ 'production': LabeledValue( version=1, - serialized_value=json.dumps('Hello {{user_name}}. <>'), + serialized_value=json.dumps('Hello {{user_name}}. @{snippet}@'), ), }, rollout=Rollout(labels={'production': 1.0}), @@ -334,7 +334,7 @@ def test_composition_then_render(self, config_kwargs: dict[str, Any]): lf = _make_lf(variables_config, config_kwargs) var = lf.var('full_prompt', type=str, default='default') resolved = var.get() - # After composition, <> is expanded but {{placeholders}} remain + # After composition, @{snippet}@ is expanded but {{placeholders}} remain assert resolved.value == 'Hello {{user_name}}. Welcome to {{company}}!' # After rendering, all {{placeholders}} are filled rendered = resolved.render({'user_name': 'Alice', 'company': 'Acme Corp'}) @@ -361,7 +361,7 @@ class Inputs(BaseModel): assert resolved.value == 'Hello Alice!' def test_composition_then_render(self, config_kwargs: dict[str, Any]): - """<> expanded first, then {{}} rendered with inputs.""" + """@{refs}@ expanded first, then {{}} rendered with inputs.""" class Inputs(BaseModel): user_name: str @@ -382,7 +382,7 @@ class Inputs(BaseModel): labels={ 'production': LabeledValue( version=1, - serialized_value=json.dumps('Hello {{user_name}}. <>'), + serialized_value=json.dumps('Hello {{user_name}}. @{snippet}@'), ), }, rollout=Rollout(labels={'production': 1.0}), From 24f70c540d51ba65f21d2d7e224f583f6d569a4b Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 11:01:19 +0300 Subject: [PATCH 04/40] Make Handlebars support lazy optional --- logfire/_internal/main.py | 5 ++- logfire/variables/_handlebars.py | 45 +++++++++++++++++++++++ logfire/variables/abstract.py | 5 +-- logfire/variables/composition.py | 4 +- logfire/variables/reference_syntax.py | 13 ++++--- logfire/variables/template_validation.py | 3 +- logfire/variables/variable.py | 2 +- tests/test_logfire_api.py | 1 + tests/test_variable_templates.py | 47 ++++++++++++++++++++++++ 9 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 logfire/variables/_handlebars.py diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 0c2ccb9c8..129b2c8d8 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2572,8 +2572,11 @@ def var( Template rendering example: ```py + import logfire from pydantic import BaseModel + logfire.configure() + class PromptInputs(BaseModel): user_name: str @@ -2680,7 +2683,7 @@ class PromptInputs(BaseModel): ) with prompt.get(PromptInputs(user_name='Alice')) as resolved: - print(resolved.value) # "Hello Alice" + assert resolved.value == 'Hello Alice' ``` Args: diff --git a/logfire/variables/_handlebars.py b/logfire/variables/_handlebars.py new file mode 100644 index 000000000..758a5b08e --- /dev/null +++ b/logfire/variables/_handlebars.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +class _FallbackHandlebarsError(Exception): + """Fallback exception class used when pydantic-handlebars is unavailable.""" + + +try: + from pydantic_handlebars import HandlebarsError as _ImportedHandlebarsError +except ImportError: # pragma: no cover + _ImportedHandlebarsError = _FallbackHandlebarsError + +HandlebarsError: type[Exception] = _ImportedHandlebarsError + + +class HandlebarsDependencyError(ImportError): + """Raised when a Handlebars feature is used without pydantic-handlebars installed.""" + + +def _dependency_error() -> HandlebarsDependencyError: + return HandlebarsDependencyError( + 'Handlebars template rendering requires the `pydantic-handlebars` package, ' + 'which is only installed by the `logfire[variables]` extra on Python 3.10 and later.' + ) + + +def get_handlebars_renderer() -> tuple[type[str], Callable[..., str]]: + """Return pydantic-handlebars SafeString and render, or raise a helpful error.""" + try: + from pydantic_handlebars import SafeString, render + except ImportError as exc: # pragma: no cover + raise _dependency_error() from exc + return SafeString, render + + +def check_template_compatibility(templates: list[str], schema: dict[str, Any]) -> Any: + """Run pydantic-handlebars schema compatibility checking.""" + try: + from pydantic_handlebars import check_template_compatibility as _check_template_compatibility + except ImportError as exc: # pragma: no cover + raise _dependency_error() from exc + return _check_template_compatibility(templates, schema) diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index be9332779..ac01a7068 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -240,10 +240,9 @@ def render_serialized_string(serialized_json: str, inputs: Any) -> str: Returns: The rendered JSON string. """ - from pydantic_handlebars import SafeString, render as hbs_render + from logfire.variables._handlebars import get_handlebars_renderer - safe_string_cls: type[str] = SafeString - render_fn: Callable[..., str] = hbs_render + safe_string_cls, render_fn = get_handlebars_renderer() context = _inputs_to_context(inputs) diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index f10c995f1..6f5df4ef0 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -19,8 +19,6 @@ from dataclasses import dataclass, field from typing import Any, Callable, Optional, Tuple # noqa: UP035 -from logfire.variables.reference_syntax import render_once - __all__ = ( 'MAX_COMPOSITION_DEPTH', 'VariableCompositionError', @@ -317,6 +315,8 @@ def _render_value(value: Any, context: dict[str, Any]) -> Any: if not has_references(value): # Unescape \@{ to @{ for non-reference strings. return value.replace('\\@{', '@{') + from logfire.variables.reference_syntax import render_once + return render_once(value, context) if isinstance(value, dict): return {k: _render_value(v, context) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] diff --git a/logfire/variables/reference_syntax.py b/logfire/variables/reference_syntax.py index c538cc384..0663fc643 100644 --- a/logfire/variables/reference_syntax.py +++ b/logfire/variables/reference_syntax.py @@ -19,7 +19,7 @@ import re from typing import Any -from pydantic_handlebars import SafeString, render as hbs_render +from logfire.variables._handlebars import get_handlebars_renderer _REFERENCE_TAG = re.compile(r'(? str: return s.replace('{', '{').replace('}', '}') -def _protect_value(value: Any) -> Any: +def _protect_value(value: Any, safe_string_cls: type[str]) -> Any: """Recursively protect string values, preserving structure for dicts/lists.""" if isinstance(value, str): - return SafeString(value.translate(_PROTECT)) + return safe_string_cls(value.translate(_PROTECT)) if isinstance(value, dict): - return {k: _protect_value(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] + return {k: _protect_value(v, safe_string_cls) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] if isinstance(value, list): - return [_protect_value(v) for v in value] # pyright: ignore[reportUnknownVariableType] + return [_protect_value(v, safe_string_cls) for v in value] # pyright: ignore[reportUnknownVariableType] return value # bools, ints, None, etc. — pass through @@ -60,9 +60,10 @@ def _protect_value(value: Any) -> Any: def render_once(template: str, context: dict[str, Any]) -> str: """Single-pass render: convert ``@{}@`` tags, run Handlebars, restore ``{{}}``.""" + safe_string_cls, hbs_render = get_handlebars_renderer() protected_template = template.replace('{{', _LEFT_RUNTIME_PLACEHOLDER).replace('}}', _RIGHT_RUNTIME_PLACEHOLDER) handlebars_template = _REFERENCE_TAG.sub(r'{{\1}}', protected_template) - safe_context = {k: _protect_value(v) for k, v in context.items()} + safe_context = {k: _protect_value(v, safe_string_cls) for k, v in context.items()} result: str = hbs_render(handlebars_template, safe_context) result = result.replace(_LEFT_RUNTIME_PLACEHOLDER, '{{').replace(_RIGHT_RUNTIME_PLACEHOLDER, '}}') return _unescape_protected(result).replace('\\@{', '@{') diff --git a/logfire/variables/template_validation.py b/logfire/variables/template_validation.py index 49b915321..b92eed69f 100644 --- a/logfire/variables/template_validation.py +++ b/logfire/variables/template_validation.py @@ -18,8 +18,7 @@ from dataclasses import dataclass, field from typing import Any -from pydantic_handlebars import check_template_compatibility - +from logfire.variables._handlebars import check_template_compatibility from logfire.variables.composition import find_references __all__ = ( diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index dd1c8e323..0bcf0655f 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -11,9 +11,9 @@ from opentelemetry.trace import get_current_span from pydantic import TypeAdapter, ValidationError -from pydantic_handlebars import HandlebarsError from typing_extensions import TypeIs +from logfire.variables._handlebars import HandlebarsError from logfire.variables.composition import ( ComposedReference, VariableCompositionError, diff --git a/tests/test_logfire_api.py b/tests/test_logfire_api.py index 85ada5197..5789dd78c 100644 --- a/tests/test_logfire_api.py +++ b/tests/test_logfire_api.py @@ -131,6 +131,7 @@ def test_runtime(logfire_api_factory: Callable[[], ModuleType], module_name: str # Variables APIs are intentionally not in logfire-api — users of variables should use the full SDK for name in [ 'var', + 'template_var', 'variables', 'variables_clear', 'variables_get', diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index 402312c73..6a6e1e082 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -5,6 +5,11 @@ from __future__ import annotations import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path from typing import Any import pytest @@ -40,6 +45,48 @@ def _simple_config(name: str, serialized_value: str) -> VariablesConfig: ) +def test_import_logfire_without_pydantic_handlebars(): + """pydantic-handlebars is optional unless a Handlebars feature is used.""" + root = Path(__file__).parents[1] + env = os.environ.copy() + env['PYTHONPATH'] = f'{root}{os.pathsep}{env.get("PYTHONPATH", "")}' + code = textwrap.dedent( + """ + import builtins + + real_import = builtins.__import__ + + def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'pydantic_handlebars' or name.startswith('pydantic_handlebars.'): + raise ImportError('blocked pydantic_handlebars') + return real_import(name, globals, locals, fromlist, level) + + builtins.__import__ = blocked_import + + import logfire + from logfire.variables.abstract import render_serialized_string + + assert logfire.var + + try: + render_serialized_string('"Hello {{name}}"', {'name': 'Alice'}) + except ImportError as exc: + assert 'pydantic-handlebars' in str(exc) + else: + raise AssertionError('rendering should require pydantic-handlebars') + """ + ) + result = subprocess.run( + [sys.executable, '-c', code], + cwd=root, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + # ============================================================================= # ResolvedVariable.render() tests # ============================================================================= From ae02b79c7b4e9e4bfd3435a18f7f5afdfdae85cd Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 11:13:03 +0300 Subject: [PATCH 05/40] Address variable composition review findings --- .../templates-and-composition.md | 6 ++- logfire/variables/abstract.py | 9 +++- logfire/variables/composition.py | 17 +++---- logfire/variables/reference_syntax.py | 45 +++++++++--------- logfire/variables/variable.py | 28 +++-------- tests/test_push_variables.py | 47 +++++++++++++++++++ tests/test_variable_composition.py | 30 +++++++++++- tests/test_variable_templates.py | 12 +++++ tests/test_variables.py | 4 +- 9 files changed, 139 insertions(+), 59 deletions(-) diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md index 973cdb1e8..694d19cf3 100644 --- a/docs/reference/advanced/managed-variables/templates-and-composition.md +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -186,9 +186,9 @@ with agent_prompt.get() as resolved: When `safety_rules` is updated in the Logfire UI, all variables that reference `@{safety_rules}@` automatically pick up the new value — no code changes or redeployment required. -### Composition with Handlebars Power +### Composition Control Flow -The `@{}@` syntax supports the full Handlebars feature set — not just simple variable substitution. You can use conditionals, loops, and more: +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: | Syntax | Description | |--------|-------------| @@ -197,6 +197,8 @@ The `@{}@` syntax supports the full Handlebars feature set — not just simple v | `@{#if variable}@...@{else}@...@{/if}@` | Conditional on whether a variable is set | | `@{#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}@`. + ### Composition Tracking Every `@{ref}@` expansion is recorded in the resolution result. You can inspect which variables were composed and their values: diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index ac01a7068..7dd5ef8f0 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -281,6 +281,8 @@ def _render_json_value(value: Any, hbs_render: Callable[..., str], context: dict Only string values are rendered; dicts and lists are walked recursively. """ if isinstance(value, str): + if '{{' not in value: + return value return hbs_render(value, context) if isinstance(value, dict): return {k: _render_json_value(v, hbs_render, context) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] @@ -712,6 +714,9 @@ def _compute_diff( server_normalized = json.dumps(server_schema, sort_keys=True) if server_schema else '{}' schema_changed = local_normalized != server_normalized + local_template_inputs_normalized = json.dumps(template_inputs_schema, sort_keys=True) + server_template_inputs_normalized = json.dumps(server_var.template_inputs_schema, sort_keys=True) + template_inputs_schema_changed = local_template_inputs_normalized != server_template_inputs_normalized # Check if description differs (for warning purposes) # Normalize: treat None and empty string as equivalent @@ -719,7 +724,7 @@ def _compute_diff( server_desc_normalized = server_description or None description_differs = local_desc_normalized != server_desc_normalized - if schema_changed: + if schema_changed or template_inputs_schema_changed: # Schema changed - check label value compatibility incompatible = _check_all_label_compatibility(variable, server_var) @@ -733,6 +738,7 @@ def _compute_diff( local_description=local_description, server_description=server_description, description_differs=description_differs, + template_inputs_schema=template_inputs_schema, ) ) else: @@ -888,6 +894,7 @@ def _update_variable_schema( rollout=existing.rollout, overrides=existing.overrides, json_schema=change.local_schema, + template_inputs_schema=change.template_inputs_schema, ) provider.update_variable(change.name, config) diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index 6f5df4ef0..e98399a49 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -2,8 +2,8 @@ This module provides pure functions for expanding variable references in serialized JSON strings. References use the ``@{variable_name}@`` syntax and are expanded using -the Handlebars engine, giving ``@{}@`` the full power of Handlebars: -``@{#if flag}@``, ``@{#each items}@``, ``@{#unless flag}@``, ``@{#with obj}@``, etc. +a Handlebars-compatible subset: simple references, dotted field reads, and block +helpers whose condition/iterable is a top-level referenced variable. Meanwhile, any ``{{runtime}}`` placeholders are preserved untouched for later template rendering. @@ -97,9 +97,10 @@ def expand_references( ) -> tuple[str, list[ComposedReference]]: """Expand ``@{var}@`` references in a serialized variable value. - Uses the Handlebars engine so that ``@{}@`` supports the full Handlebars - feature set (``@{#if flag}@``, ``@{#each items}@``, etc.) while - preserving ``{{runtime}}`` placeholders untouched. + 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 + untouched. Args: serialized_value: The raw JSON-serialized variable value. @@ -178,12 +179,12 @@ def expand_references( unresolved_names.add(ref_name) continue - # Recursively expand references within the resolved value (if it's a string). + # Recursively expand references within the resolved value. nested_composed: list[ComposedReference] = [] - if isinstance(raw_value, str) and has_references(json.dumps(raw_value)): + if has_references(ref_serialized): try: expanded_serialized, nested_composed = expand_references( - json.dumps(raw_value), + ref_serialized, ref_name, resolve_fn, _visited=visited, diff --git a/logfire/variables/reference_syntax.py b/logfire/variables/reference_syntax.py index 0663fc643..6194610f7 100644 --- a/logfire/variables/reference_syntax.py +++ b/logfire/variables/reference_syntax.py @@ -2,9 +2,8 @@ This module provides ``render_once`` which performs a single-pass render using ``@{}@`` as the delimiter instead of ``{{}}``. It is the engine behind variable -composition — it gives ``@{}@`` syntax the full power of Handlebars -(conditionals, loops, helpers, etc.) while preserving any ``{{}}`` runtime -placeholders untouched. +composition — it gives ``@{}@`` syntax a Handlebars-compatible subset while +preserving any ``{{}}`` runtime placeholders untouched. Algorithm: a. Protect ``{{...}}`` runtime placeholders in the template @@ -22,30 +21,18 @@ from logfire.variables._handlebars import get_handlebars_renderer _REFERENCE_TAG = re.compile(r'(? str: - """Undo only the entities we introduced.""" - return s.replace('{', '{').replace('}', '}') +def _sentinel(name: str, template: str) -> str: + """Return a per-template sentinel unlikely to collide with user content.""" + return f'\x00logfire-{name}-{id(template)}\x00' def _protect_value(value: Any, safe_string_cls: type[str]) -> Any: - """Recursively protect string values, preserving structure for dicts/lists.""" + """Recursively mark string values safe, preserving structure for dicts/lists.""" if isinstance(value, str): - return safe_string_cls(value.translate(_PROTECT)) + return safe_string_cls(value) if isinstance(value, dict): return {k: _protect_value(v, safe_string_cls) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] if isinstance(value, list): @@ -61,9 +48,19 @@ def _protect_value(value: Any, safe_string_cls: type[str]) -> Any: def render_once(template: str, context: dict[str, Any]) -> str: """Single-pass render: convert ``@{}@`` tags, run Handlebars, restore ``{{}}``.""" safe_string_cls, hbs_render = get_handlebars_renderer() - protected_template = template.replace('{{', _LEFT_RUNTIME_PLACEHOLDER).replace('}}', _RIGHT_RUNTIME_PLACEHOLDER) + left_runtime_placeholder = _sentinel('left-runtime-placeholder', template) + right_runtime_placeholder = _sentinel('right-runtime-placeholder', template) + escaped_reference_start = _sentinel('escaped-reference-start', template) + protected_template = ( + template.replace(_ESCAPED_REFERENCE_START, escaped_reference_start) + .replace('{{', left_runtime_placeholder) + .replace('}}', right_runtime_placeholder) + ) handlebars_template = _REFERENCE_TAG.sub(r'{{\1}}', protected_template) safe_context = {k: _protect_value(v, safe_string_cls) for k, v in context.items()} result: str = hbs_render(handlebars_template, safe_context) - result = result.replace(_LEFT_RUNTIME_PLACEHOLDER, '{{').replace(_RIGHT_RUNTIME_PLACEHOLDER, '}}') - return _unescape_protected(result).replace('\\@{', '@{') + return ( + result.replace(left_runtime_placeholder, '{{') + .replace(right_runtime_placeholder, '}}') + .replace(escaped_reference_start, '@{') + ) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 0bcf0655f..07e053034 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -264,17 +264,7 @@ def _resolve( serialized_result, provider, targeting_key, attributes, span, render_fn=render_fn ) - except ( # Safety net: providers and resolve functions are user-defined and may raise any of these - ValidationError, - ValueError, - TypeError, - KeyError, - AttributeError, - RuntimeError, - OSError, - HandlebarsError, - VariableCompositionError, - ) as e: + except Exception as e: if span and serialized_result is not None: # pragma: no cover span.set_attribute('invalid_serialized_label', serialized_result.label) span.set_attribute('invalid_serialized_value', serialized_result.value) @@ -283,16 +273,12 @@ def _resolve( def _render_default(self, default: Any, render_fn: Callable[[str], str]) -> T_co: """Serialize the default value, apply render_fn, then deserialize back.""" - try: - serialized = self.type_adapter.dump_json(default).decode('utf-8') - rendered = render_fn(serialized) - result = self._deserialize(rendered) - if isinstance(result, (ValidationError, ValueError)): - raise result - return result - except (ValidationError, ValueError, TypeError, HandlebarsError): - # If rendering the default fails, return the original default - return default + serialized = self.type_adapter.dump_json(default).decode('utf-8') + rendered = render_fn(serialized) + result = self._deserialize(rendered) + if isinstance(result, (ValidationError, ValueError)): + raise result + return result def _expand_and_deserialize( self, diff --git a/tests/test_push_variables.py b/tests/test_push_variables.py index a04d9f011..d48ea5e62 100644 --- a/tests/test_push_variables.py +++ b/tests/test_push_variables.py @@ -7,6 +7,7 @@ from typing import Any import pytest +from pydantic import BaseModel import logfire from logfire.variables.abstract import ( @@ -15,6 +16,7 @@ ValidationReport, VariableChange, VariableDiff, + _apply_changes, _check_label_compatibility, _check_type_label_compatibility, _compute_diff, @@ -23,6 +25,7 @@ _get_json_schema, ) from logfire.variables.config import LabeledValue, LabelRef, LatestVersion, Rollout, VariableConfig, VariablesConfig +from logfire.variables.local import LocalVariableProvider from logfire.variables.variable import Variable @@ -220,6 +223,50 @@ def test_compute_diff_schema_change(mock_logfire_instance: MockLogfire) -> None: assert diff.has_changes is True +def test_compute_diff_template_inputs_schema_change(mock_logfire_instance: MockLogfire) -> None: + """A template inputs schema change is pushed even if the value schema is unchanged.""" + + class NewInputs(BaseModel): + user_name: str + + var = Variable[str]( + name='prompt', + default='Hello {{user_name}}', + type=str, + template_inputs=NewInputs, + logfire_instance=mock_logfire_instance, # type: ignore + ) + server_config = VariablesConfig( + variables={ + 'prompt': VariableConfig( + name='prompt', + json_schema={'type': 'string'}, + template_inputs_schema={'type': 'object', 'properties': {'old_name': {'type': 'string'}}}, + labels={}, + rollout=Rollout(labels={}), + overrides=[], + ) + } + ) + + diff = _compute_diff([var], server_config) + + assert len(diff.changes) == 1 + change = diff.changes[0] + assert change.change_type == 'update_schema' + assert change.template_inputs_schema is not None + assert 'user_name' in change.template_inputs_schema['properties'] + + provider = LocalVariableProvider(server_config) + _apply_changes(provider, diff, server_config) + updated_config = provider.get_variable_config('prompt') + assert updated_config is not None + updated_schema = updated_config.template_inputs_schema + assert updated_schema is not None + assert 'user_name' in updated_schema['properties'] + assert 'old_name' not in updated_schema['properties'] + + def test_compute_diff_orphaned_variables(mock_logfire_instance: MockLogfire) -> None: """Test detection of orphaned server variables.""" var = Variable[bool]( diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 00b2b77a3..09dc3cc58 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -109,6 +109,22 @@ def test_nested_references(self): assert len(composed[0].composed_from) == 1 assert composed[0].composed_from[0].name == 'b' + def test_nested_references_in_structured_value(self): + """References within structured referenced values are expanded recursively.""" + resolve_fn = _make_resolve_fn( + { + 'config': '{"prompt": "Hello @{name}@", "model": "gpt-4"}', + 'name': '"Alice"', + } + ) + expanded, composed = expand_references('"@{config.prompt}@ using @{config.model}@"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'Hello Alice using gpt-4' + assert len(composed) == 1 + assert composed[0].name == 'config' + assert composed[0].value == '{"prompt": "Hello Alice", "model": "gpt-4"}' + assert len(composed[0].composed_from) == 1 + assert composed[0].composed_from[0].name == 'name' + def test_cycle_detection(self): """Circular references are caught and the reference is left unexpanded.""" resolve_fn = _make_resolve_fn( @@ -336,7 +352,7 @@ def test_find_references_block_and_simple(self): # ============================================================================= -# Tests for Handlebars-powered @{}@ block helpers +# Tests for Handlebars-compatible @{}@ block helpers # ============================================================================= @@ -414,6 +430,18 @@ def test_escape_mixed(self): result = json.loads(expanded) assert result == '@{escaped}@ expanded' + def test_referenced_html_entities_are_preserved(self): + """Literal HTML entities in referenced values are not treated as internal escapes.""" + resolve_fn = _make_resolve_fn({'ref': json.dumps('literal { and }')}) + expanded, _ = expand_references('"@{ref}@"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'literal { and }' + + def test_referenced_escaped_reference_is_preserved(self): + r"""Escaped reference syntax inside referenced values keeps its backslash.""" + 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}@' + # ============================================================================= # Integration tests using LocalVariableProvider diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index 6a6e1e082..3b0fb035a 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -258,6 +258,18 @@ def test_render_code_default_string(self, config_kwargs: dict[str, Any]): rendered = resolved.render({'name': 'Alice'}) assert rendered == 'Hello Alice!' + def test_template_var_invalid_default_records_exception(self, config_kwargs: dict[str, Any]): + """Rendering failures in code defaults are exposed on the resolution result.""" + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + var = lf.template_var('prompt', type=str, default='Hello {{#if name}}', inputs_type=dict) + + resolved = var.get({'name': 'Alice'}) + + assert resolved.value == 'Hello {{#if name}}' + assert resolved.exception is not None + assert resolved._reason == 'other_error' + class TestRenderErrors: """Test error handling in render().""" diff --git a/tests/test_variables.py b/tests/test_variables.py index bf9813872..b6cafcfab 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -2181,7 +2181,7 @@ def test_exception_handling_in_get_details(self, config_kwargs: dict[str, Any]): original = lf.config._variable_provider.get_serialized_value def failing_get(*args: Any, **kwargs: Any) -> ResolvedVariable[str | None]: - raise RuntimeError('Provider failed!') + raise IndexError('Provider failed!') lf.config._variable_provider.get_serialized_value = failing_get @@ -2189,7 +2189,7 @@ def failing_get(*args: Any, **kwargs: Any) -> ResolvedVariable[str | None]: details = var.get() assert details.value == 'fallback' assert details._reason == 'other_error' - assert isinstance(details.exception, RuntimeError) + assert isinstance(details.exception, IndexError) # Restore original lf.config._variable_provider.get_serialized_value = original From 4ffdaff9af9e8f5e6ed38172a161123a7c5f7efa Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 11:28:07 +0300 Subject: [PATCH 06/40] Skip Handlebars tests without optional dependency --- logfire/_internal/main.py | 4 ++-- tests/test_template_validation.py | 11 +++++++++++ tests/test_variable_composition.py | 10 ++++++++++ tests/test_variable_templates.py | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 129b2c8d8..17dcbf7e5 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2571,7 +2571,7 @@ def var( Template rendering example: - ```py + ```py skip-run="true" skip-reason="requires-pydantic-handlebars" import logfire from pydantic import BaseModel @@ -2662,7 +2662,7 @@ def template_var( templates in the resolved value before returning. The pipeline is: resolve → compose ``@{refs}@`` → render ``{{}}`` → deserialize. - ```py + ```py skip-run="true" skip-reason="requires-pydantic-handlebars" from pydantic import BaseModel import logfire diff --git a/tests/test_template_validation.py b/tests/test_template_validation.py index ed844bc80..0525e725f 100644 --- a/tests/test_template_validation.py +++ b/tests/test_template_validation.py @@ -4,6 +4,10 @@ from __future__ import annotations +from importlib.util import find_spec + +import pytest + from logfire.variables.template_validation import ( TemplateFieldIssue, TemplateValidationResult, @@ -13,6 +17,12 @@ validate_template_composition, ) +HAS_PYDANTIC_HANDLEBARS = find_spec('pydantic_handlebars') is not None +requires_handlebars = pytest.mark.skipif( + not HAS_PYDANTIC_HANDLEBARS, + reason='pydantic-handlebars requires Python 3.10+', +) + # ============================================================================= # find_template_fields # ============================================================================= @@ -204,6 +214,7 @@ def get_all_serialized_values(name: str) -> dict[str | None, str]: return get_all_serialized_values +@requires_handlebars class TestValidateTemplateComposition: def test_all_fields_valid(self): """All {{field}} references match schema properties — no issues.""" diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 09dc3cc58..4dfd03222 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -5,6 +5,7 @@ from __future__ import annotations import json +from importlib.util import find_spec from typing import Any import pytest @@ -28,6 +29,12 @@ VariablesConfig, ) +HAS_PYDANTIC_HANDLEBARS = find_spec('pydantic_handlebars') is not None +requires_handlebars = pytest.mark.skipif( + not HAS_PYDANTIC_HANDLEBARS, + reason='pydantic-handlebars requires Python 3.10+', +) + # ============================================================================= # Tests for the pure composition functions (expand_references, find_references) # ============================================================================= @@ -49,6 +56,7 @@ def resolve_fn(ref_name: str) -> tuple[str | None, str | None, int | None, str]: return resolve_fn +@requires_handlebars class TestExpandReferences: def test_no_references(self): """Values without @{}@ are returned unchanged.""" @@ -356,6 +364,7 @@ def test_find_references_block_and_simple(self): # ============================================================================= +@requires_handlebars class TestBlockHelpers: def test_block_if_true(self): """@{#if flag}@yes@{else}@no@{/if}@ with truthy flag.""" @@ -471,6 +480,7 @@ def _make_variables_config(**variables: str | None) -> VariablesConfig: return VariablesConfig(variables=configs) +@requires_handlebars class TestCompositionIntegration: def test_simple_reference(self, config_kwargs: dict[str, Any]): """End-to-end: variable with @{ref}@ is resolved with composition.""" diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index 3b0fb035a..d2d93b16d 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -9,6 +9,7 @@ import subprocess import sys import textwrap +from importlib.util import find_spec from pathlib import Path from typing import Any @@ -24,6 +25,12 @@ VariablesConfig, ) +HAS_PYDANTIC_HANDLEBARS = find_spec('pydantic_handlebars') is not None +requires_handlebars = pytest.mark.skipif( + not HAS_PYDANTIC_HANDLEBARS, + reason='pydantic-handlebars requires Python 3.10+', +) + def _make_lf(variables_config: VariablesConfig, config_kwargs: dict[str, Any]) -> logfire.Logfire: """Create a Logfire instance with LocalVariablesOptions for testing.""" @@ -92,6 +99,7 @@ def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): # ============================================================================= +@requires_handlebars class TestRenderSimpleString: """Test rendering string variables with Handlebars templates.""" @@ -170,6 +178,7 @@ def test_no_templates(self, config_kwargs: dict[str, Any]): assert rendered == 'Hello world!' +@requires_handlebars class TestRenderWithPydanticInputs: """Test rendering with Pydantic model inputs.""" @@ -210,6 +219,7 @@ class UserInfo(BaseModel): assert rendered == 'User Alice from London, UK' +@requires_handlebars class TestRenderStructuredType: """Test rendering structured types (Pydantic models) where string fields contain templates.""" @@ -243,6 +253,7 @@ class PromptConfig(BaseModel): assert rendered.max_tokens == 100 +@requires_handlebars class TestRenderCodeDefault: """Test rendering when using code default values (no remote configuration).""" @@ -271,6 +282,7 @@ def test_template_var_invalid_default_records_exception(self, config_kwargs: dic assert resolved._reason == 'other_error' +@requires_handlebars class TestRenderErrors: """Test error handling in render().""" @@ -362,6 +374,7 @@ def test_none_by_default(self): # ============================================================================= +@requires_handlebars class TestCompositionThenRendering: """Test the full pipeline: resolve → compose → render.""" @@ -405,6 +418,7 @@ def test_composition_then_render(self, config_kwargs: dict[str, Any]): # ============================================================================= +@requires_handlebars class TestTemplateVariable: """Test TemplateVariable[T, InputsT] — single-step get(inputs) rendering.""" From fcbff7733c403c4b74b8030767df6a80723fb3da Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 12:09:40 +0300 Subject: [PATCH 07/40] Cover variable composition edge cases --- tests/test_push_variables.py | 135 +++++++++++++++++++++++++++++ tests/test_template_validation.py | 31 +++++++ tests/test_variable_composition.py | 49 +++++++++++ tests/test_variable_templates.py | 100 +++++++++++++++++++++ 4 files changed, 315 insertions(+) diff --git a/tests/test_push_variables.py b/tests/test_push_variables.py index d48ea5e62..378b0e3ce 100644 --- a/tests/test_push_variables.py +++ b/tests/test_push_variables.py @@ -11,6 +11,7 @@ import logfire from logfire.variables.abstract import ( + DescriptionDifference, LabelCompatibility, LabelValidationError, ValidationReport, @@ -300,6 +301,104 @@ def test_compute_diff_orphaned_variables(mock_logfire_instance: MockLogfire) -> assert 'my_feature' not in diff.orphaned_server_variables +def test_compute_diff_reference_warnings(mock_logfire_instance: MockLogfire) -> None: + """Reference warnings include missing references and cycles.""" + var_a = Variable[str]( + name='var_a', + default='@{missing}@ @{var_b}@', + type=str, + logfire_instance=mock_logfire_instance, # type: ignore + ) + var_b = Variable[str]( + name='var_b', + default='@{var_a}@', + type=str, + logfire_instance=mock_logfire_instance, # type: ignore + ) + server_config = VariablesConfig( + variables={ + 'var_a': VariableConfig( + name='var_a', + json_schema={'type': 'string'}, + labels={'production': LabeledValue(version=1, serialized_value='"@{server_missing}@"')}, + latest_version=LatestVersion(version=1, serialized_value='"@{server_latest_missing}@"'), + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + 'var_b': VariableConfig( + name='var_b', + json_schema={'type': 'string'}, + labels={}, + rollout=Rollout(labels={}), + overrides=[], + ), + } + ) + + diff = _compute_diff([var_a, var_b], server_config) + + assert any("'var_a' references '@{missing}@'" in warning for warning in diff.reference_warnings) + assert any("'var_a' references '@{server_missing}@'" in warning for warning in diff.reference_warnings) + assert any("'var_a' references '@{server_latest_missing}@'" in warning for warning in diff.reference_warnings) + assert any('Reference cycle detected: var_a -> var_b -> var_a' in warning for warning in diff.reference_warnings) + + +def test_compute_diff_reference_warning_scan_handles_unserializable_default( + mock_logfire_instance: MockLogfire, +) -> None: + """Reference scanning tolerates defaults that cannot be serialized.""" + var = Variable[object]( + name='opaque', + default=object(), + type=object, + logfire_instance=mock_logfire_instance, # type: ignore + ) + server_config = VariablesConfig( + variables={ + 'opaque': VariableConfig( + name='opaque', + json_schema={}, + labels={}, + rollout=Rollout(labels={}), + overrides=[], + ), + } + ) + + diff = _compute_diff([var], server_config) + + assert diff.reference_warnings == [] + + +def test_compute_diff_reference_warning_scan_skips_already_visited_nodes( + mock_logfire_instance: MockLogfire, +) -> None: + """Cycle detection handles shared reference graph nodes without duplicate traversal.""" + var_a = Variable[str]( + name='var_a', + default='@{shared}@', + type=str, + logfire_instance=mock_logfire_instance, # type: ignore + ) + var_b = Variable[str]( + name='var_b', + default='@{shared}@', + type=str, + logfire_instance=mock_logfire_instance, # type: ignore + ) + shared = Variable[str]( + name='shared', + default='value', + type=str, + logfire_instance=mock_logfire_instance, # type: ignore + ) + server_config = VariablesConfig(variables={}) + + diff = _compute_diff([var_a, var_b, shared], server_config) + + assert diff.reference_warnings == [] + + def test_format_diff_creates() -> None: """Test diff formatting for creates.""" diff = VariableDiff( @@ -336,6 +435,42 @@ def test_format_diff_updates() -> None: assert 'updated_feature' in output +def test_format_diff_reference_warnings() -> None: + """Reference warnings are shown in the formatted diff.""" + diff = VariableDiff( + changes=[], + orphaned_server_variables=[], + reference_warnings=["Variable 'a' references '@{missing}@' which does not exist."], + ) + + output = _format_diff(diff) + + assert 'Reference warnings' in output + assert 'missing' in output + + +def test_validation_report_format_reference_and_description_warnings() -> None: + """Validation reports include informational reference and description warnings.""" + report = ValidationReport( + errors=[], + variables_checked=1, + variables_not_on_server=[], + description_differences=[ + DescriptionDifference(variable_name='prompt', local_description='local', server_description=None) + ], + reference_warnings=["Variable 'prompt' references '@{missing}@' which does not exist."], + ) + + output = report.format(colors=False) + + assert 'Validation passed' in output + assert 'Description differences' in output + assert 'Local: local' in output + assert 'Server: (none)' in output + assert 'Reference warnings' in output + assert 'missing' in output + + def test_variable_diff_has_changes_true() -> None: """Test has_changes when there are changes.""" diff = VariableDiff( diff --git a/tests/test_template_validation.py b/tests/test_template_validation.py index 0525e725f..bd5e7196f 100644 --- a/tests/test_template_validation.py +++ b/tests/test_template_validation.py @@ -5,6 +5,7 @@ from __future__ import annotations from importlib.util import find_spec +from types import SimpleNamespace import pytest @@ -227,6 +228,25 @@ def test_all_fields_valid(self): result = validate_template_composition('my_var', schema, get_values) assert result.issues == [] + def test_non_error_template_compatibility_issues_are_ignored(self, monkeypatch: pytest.MonkeyPatch): + """Only hard compatibility errors are surfaced as template field issues.""" + schema = {'properties': {'name': {'type': 'string'}}} + get_values = _make_get_all_serialized({'my_var': {None: '"Hello {{name}}"'}}) + + def check_with_warning(*_args: object) -> SimpleNamespace: + return SimpleNamespace( + issues=[SimpleNamespace(severity='warning', field_path='name')], + ) + + monkeypatch.setattr( + 'logfire.variables.template_validation.check_template_compatibility', + check_with_warning, + ) + + result = validate_template_composition('my_var', schema, get_values) + + assert result.issues == [] + def test_field_not_in_schema(self): """A {{field}} not in schema properties produces an issue.""" schema = {'properties': {'name': {'type': 'string'}}} @@ -447,6 +467,17 @@ def test_no_cycle(self): result = detect_composition_cycles('a', {'b'}, get_refs) assert result is None + def test_non_target_cycle_does_not_count(self): + """A cycle elsewhere in the graph is ignored unless it reaches the target.""" + get_refs = _make_get_all_references( + { + 'b': {'c'}, + 'c': {'b'}, + } + ) + result = detect_composition_cycles('a', {'b'}, get_refs) + assert result is None + def test_direct_self_reference(self): """A references itself.""" get_refs = _make_get_all_references( diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 4dfd03222..4c7644319 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -58,6 +58,13 @@ def resolve_fn(ref_name: str) -> tuple[str | None, str | None, int | None, str]: @requires_handlebars class TestExpandReferences: + def test_invalid_serialized_value_is_returned_unchanged(self): + """Non-JSON values cannot be composed and are returned as-is.""" + expanded, composed = expand_references('not json', 'my_var', _make_resolve_fn({})) + + assert expanded == 'not json' + assert composed == [] + def test_no_references(self): """Values without @{}@ are returned unchanged.""" resolve_fn = _make_resolve_fn({}) @@ -252,6 +259,23 @@ def test_structured_type_with_references(self): assert len(composed) == 1 assert composed[0].name == 'safety' + def test_list_with_references(self): + """Composition walks lists and leaves non-string values unchanged.""" + resolve_fn = _make_resolve_fn({'greeting': '"Hello"', 'name': '"Alice"'}) + serialized = json.dumps(['@{greeting}@ @{name}@', 42, {'nested': '@{name}@'}]) + + expanded, composed = expand_references(serialized, 'my_var', resolve_fn) + + assert json.loads(expanded) == ['Hello Alice', 42, {'nested': 'Alice'}] + 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.""" + expanded, composed = expand_references(json.dumps('@{#if this}@yes@{/if}@'), 'my_var', _make_resolve_fn({})) + + assert json.loads(expanded) == '@{#if this}@yes@{/if}@' + assert composed == [] + def test_json_encoding_newlines(self): """Newlines in referenced values are properly JSON-escaped.""" resolve_fn = _make_resolve_fn({'multi': '"Line1\\nLine2"'}) @@ -358,6 +382,10 @@ def test_find_references_block_and_simple(self): assert 'greeting' in result assert 'flag' in result + def test_find_references_ignores_handlebars_keywords(self): + serialized = json.dumps('@{this}@ @{#if this}@yes@{else}@no@{/if}@') + assert find_references(serialized) == [] + # ============================================================================= # Tests for Handlebars-compatible @{}@ block helpers @@ -498,6 +526,27 @@ def test_simple_reference(self, config_kwargs: dict[str, Any]): assert result.composed_from[0].name == 'greeting' assert result.composed_from[0].value == 'Hello' + 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( + main='"@{greeting}@ World"', + greeting='"Hello"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + def raise_composition_error(*args: Any, **kwargs: Any) -> Any: + raise VariableCompositionError('forced composition failure') + + monkeypatch.setattr('logfire.variables.variable.expand_references', raise_composition_error) + + var = lf.var(name='main', default='fallback', type=str) + result = var.get() + + assert result.value == 'fallback' + assert result.exception is not None + assert result._reason == 'other_error' + def test_nested_reference(self, config_kwargs: dict[str, Any]): """A→B→C chain resolves fully.""" variables_config = _make_variables_config( diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index d2d93b16d..78fe89598 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -18,6 +18,7 @@ import logfire from logfire._internal.config import LocalVariablesOptions +from logfire.variables.abstract import ResolvedVariable from logfire.variables.config import ( LabeledValue, Rollout, @@ -99,6 +100,22 @@ def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): # ============================================================================= +def test_render_requires_serialized_value(): + """render() fails clearly if the resolution did not preserve serialized JSON.""" + resolved = ResolvedVariable(name='prompt', value='Hello', _reason='context_override', _deserializer=lambda x: x) + + with pytest.raises(ValueError, match='no serialized value available'): + resolved.render() + + +def test_render_requires_deserializer(): + """render() fails clearly if it cannot deserialize the rendered JSON.""" + resolved = ResolvedVariable(name='prompt', value='Hello', _reason='resolved', _serialized_value='"Hello"') + + with pytest.raises(ValueError, match='no deserializer available'): + resolved.render() + + @requires_handlebars class TestRenderSimpleString: """Test rendering string variables with Handlebars templates.""" @@ -252,6 +269,30 @@ class PromptConfig(BaseModel): assert rendered.temperature == 0.7 assert rendered.max_tokens == 100 + def test_model_with_template_list_fields(self, config_kwargs: dict[str, Any]): + """Rendering walks lists and leaves non-string values unchanged.""" + + class PromptConfig(BaseModel): + messages: list[str] + count: int + + serialized = json.dumps( + { + 'messages': ['Hello {{user_name}}', 'static'], + 'count': 2, + } + ) + + lf = _make_lf(_simple_config('config', serialized), config_kwargs) + var = lf.var( + 'config', + type=PromptConfig, + default=PromptConfig(messages=['default'], count=1), + ) + resolved = var.get() + rendered = resolved.render({'user_name': 'Alice'}) + assert rendered == PromptConfig(messages=['Hello Alice', 'static'], count=2) + @requires_handlebars class TestRenderCodeDefault: @@ -281,6 +322,15 @@ def test_template_var_invalid_default_records_exception(self, config_kwargs: dic assert resolved.exception is not None assert resolved._reason == 'other_error' + def test_render_default_raises_rendered_validation_error(self, config_kwargs: dict[str, Any]): + """_render_default raises validation errors from the rendered JSON.""" + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + var = lf.var('count', type=int, default=0) + + with pytest.raises(ValueError): + var._render_default(0, lambda _: '"not an int"') + @requires_handlebars class TestRenderErrors: @@ -294,6 +344,19 @@ def test_render_invalid_inputs_type(self, config_kwargs: dict[str, Any]): with pytest.raises(TypeError, match='Expected a dict, Mapping, or Pydantic model'): resolved.render(42) + def test_render_raises_deserializer_exception(self): + """render() raises validation/deserialization errors after template rendering.""" + resolved = ResolvedVariable( + name='prompt', + value='Hello {{name}}', + _reason='resolved', + _serialized_value=json.dumps('Hello {{name}}'), + _deserializer=lambda _: ValueError('bad rendered value'), + ) + + with pytest.raises(ValueError, match='bad rendered value'): + resolved.render({'name': 'Alice'}) + # ============================================================================= # template_inputs parameter tests @@ -433,6 +496,43 @@ class Inputs(BaseModel): resolved = var.get(Inputs(name='Alice')) assert resolved.value == 'Hello Alice!' + def test_invalid_name_error(self, config_kwargs: dict[str, Any]): + """template_var() applies the same Python identifier name validation as var().""" + + class Inputs(BaseModel): + name: str + + lf = logfire.configure(**config_kwargs) + with pytest.raises(ValueError, match='Invalid variable name'): + lf.template_var('not-valid', type=str, default='x', inputs_type=Inputs) + + def test_remote_render_error_records_exception(self, config_kwargs: dict[str, Any]): + """Invalid remote templates fall back and record the render exception.""" + + class Inputs(BaseModel): + name: str + + lf = _make_lf(_simple_config('prompt', json.dumps('Hello {{#if name}}')), config_kwargs) + var = lf.template_var('prompt', type=str, default='fallback', inputs_type=Inputs) + + resolved = var.get(Inputs(name='Alice')) + + assert resolved.value == 'fallback' + assert resolved.exception is not None + assert resolved._reason == 'other_error' + + def test_unserializable_override_keeps_get_usable(self, config_kwargs: dict[str, Any]): + """get() tolerates values that cannot be serialized for later render() support.""" + marker = object() + lf = logfire.configure(**config_kwargs) + var = lf.var('opaque', type=object, default=object()) + + with var.override(marker): + resolved = var.get() + + assert resolved.value is marker + assert resolved._serialized_value is None + def test_composition_then_render(self, config_kwargs: dict[str, Any]): """@{refs}@ expanded first, then {{}} rendered with inputs.""" From 10d2d5207dc8f885cff15f052766693789498d7c Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 12:21:36 +0300 Subject: [PATCH 08/40] Cover pytest plugin exception fallback --- tests/otel_integrations/test_pytest_plugin.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/otel_integrations/test_pytest_plugin.py b/tests/otel_integrations/test_pytest_plugin.py index d86c01054..5d99943c0 100644 --- a/tests/otel_integrations/test_pytest_plugin.py +++ b/tests/otel_integrations/test_pytest_plugin.py @@ -430,6 +430,29 @@ def test_failure(): assert 'exception.stacktrace' in exc_attrs +def test_failed_test_ignores_record_exception_runtime_error() -> None: + """Failed test reporting should tolerate RuntimeError while recording exception details.""" + span = mock.Mock() + span.record_exception.side_effect = RuntimeError('generator raised StopIteration') + item = mock.Mock() + item.stash.get.return_value = span + report = mock.Mock(when='call', failed=True, skipped=False, duration=0.1, outcome='failed') + call = mock.Mock() + call.excinfo.typename = 'ValueError' + call.excinfo.value = ValueError('bad value') + outcome = mock.Mock() + outcome.get_result.return_value = report + + hook = pytest_plugin.pytest_runtest_makereport(item, call) + next(hook) + + with pytest.raises(StopIteration): + hook.send(outcome) + + span.set_status.assert_called_once() + span.record_exception.assert_called_once_with(call.excinfo.value) + + def test_skipped_test_with_reason(logfire_pytester: pytest.Pytester): """Skipped tests should create a span with basic test attributes. From 8589f54345aecf79e148731b9b0f3f169467e514 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 13:04:23 +0300 Subject: [PATCH 09/40] Address variable composition review follow-ups --- logfire/variables/_handlebars.py | 14 +++++++++++--- logfire/variables/template_validation.py | 8 ++++---- logfire/variables/variable.py | 13 ++++++++++--- tests/test_template_validation.py | 4 ++++ tests/test_variable_templates.py | 22 ++++++++++++++++++++++ tests/test_variables.py | 19 ++++++++++++++++++- 6 files changed, 69 insertions(+), 11 deletions(-) diff --git a/logfire/variables/_handlebars.py b/logfire/variables/_handlebars.py index 758a5b08e..208842fb3 100644 --- a/logfire/variables/_handlebars.py +++ b/logfire/variables/_handlebars.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import cache from typing import Any @@ -27,6 +28,7 @@ def _dependency_error() -> HandlebarsDependencyError: ) +@cache def get_handlebars_renderer() -> tuple[type[str], Callable[..., str]]: """Return pydantic-handlebars SafeString and render, or raise a helpful error.""" try: @@ -36,10 +38,16 @@ def get_handlebars_renderer() -> tuple[type[str], Callable[..., str]]: return SafeString, render -def check_template_compatibility(templates: list[str], schema: dict[str, Any]) -> Any: - """Run pydantic-handlebars schema compatibility checking.""" +@cache +def _get_template_compatibility_checker() -> Callable[[list[str], dict[str, Any]], Any]: + """Return pydantic-handlebars schema compatibility checker, or raise a helpful error.""" try: from pydantic_handlebars import check_template_compatibility as _check_template_compatibility except ImportError as exc: # pragma: no cover raise _dependency_error() from exc - return _check_template_compatibility(templates, schema) + return _check_template_compatibility + + +def check_template_compatibility(templates: list[str], schema: dict[str, Any]) -> Any: + """Run pydantic-handlebars schema compatibility checking.""" + return _get_template_compatibility_checker()(templates, schema) diff --git a/logfire/variables/template_validation.py b/logfire/variables/template_validation.py index b92eed69f..6df14980e 100644 --- a/logfire/variables/template_validation.py +++ b/logfire/variables/template_validation.py @@ -29,10 +29,10 @@ 'find_template_fields', ) -# Matches {{identifier}} — simple Handlebars variable references. +# Matches {{identifier}} and {{path.to.identifier}} Handlebars variable references. # Excludes block helpers ({{#if}}), closing tags ({{/if}}), partials ({{> name}}), -# comments ({{! text}}), and triple-stache ({{{raw}}}). -TEMPLATE_FIELD_PATTERN = re.compile(r'\{\{\s*([a-zA-Z_]\w*)\s*\}\}') +# and comments ({{! text}}). +TEMPLATE_FIELD_PATTERN = re.compile(r'\{\{\s*([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)\s*\}\}') @dataclass @@ -57,7 +57,7 @@ class TemplateValidationResult: def find_template_fields(text: str) -> set[str]: - """Find all ``{{field}}`` references in a string. + """Find all ``{{field}}`` or ``{{path.to.field}}`` references in a string. Returns: Set of field names found in the text. diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 07e053034..3a2a063c8 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -50,6 +50,15 @@ _VARIABLE_OVERRIDES: ContextVar[dict[str, Any] | None] = ContextVar('_VARIABLE_OVERRIDES', default=None) +def _record_exception(exception: BaseException, span: logfire.LogfireSpan) -> None: + """Record an exception on a span, ignoring a CPython traceback extraction bug.""" + try: + span.record_exception(exception) + except RuntimeError as exc: + if 'generator raised StopIteration' not in str(exc): + raise + + @dataclass class _TargetingContextData: """Internal data structure for targeting context.""" @@ -505,9 +514,7 @@ def _get_result_and_record_span( ) span.set_attributes(attrs) if result.exception: - span.record_exception( - result.exception, - ) + _record_exception(result.exception, span) return result diff --git a/tests/test_template_validation.py b/tests/test_template_validation.py index bd5e7196f..30892db43 100644 --- a/tests/test_template_validation.py +++ b/tests/test_template_validation.py @@ -94,6 +94,10 @@ def test_field_with_underscore(self): result = find_template_fields('{{user_name}}') assert result == {'user_name'} + def test_dotted_path(self): + result = find_template_fields('{{user.name}} {{ account.plan.tier }}') + assert result == {'user.name', 'account.plan.tier'} + def test_field_with_digits(self): result = find_template_fields('{{item1}}') assert result == {'item1'} diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index 78fe89598..33fdb01ad 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -4,6 +4,7 @@ from __future__ import annotations +import builtins import json import os import subprocess @@ -18,6 +19,7 @@ import logfire from logfire._internal.config import LocalVariablesOptions +from logfire.variables import _handlebars from logfire.variables.abstract import ResolvedVariable from logfire.variables.config import ( LabeledValue, @@ -95,6 +97,26 @@ def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): assert result.returncode == 0, result.stderr +@requires_handlebars +def test_handlebars_import_helpers_are_memoized(monkeypatch: pytest.MonkeyPatch): + """Successful pydantic-handlebars imports are cached after the first lookup.""" + renderer = _handlebars.get_handlebars_renderer() + schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}} + _handlebars.check_template_compatibility(['Hello {{name}}'], schema) + + real_import = builtins.__import__ + + def blocked_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == 'pydantic_handlebars' or name.startswith('pydantic_handlebars.'): + raise AssertionError('pydantic_handlebars should be cached') + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', blocked_import) + + assert _handlebars.get_handlebars_renderer() == renderer + _handlebars.check_template_compatibility(['Hello {{name}}'], schema) + + # ============================================================================= # ResolvedVariable.render() tests # ============================================================================= diff --git a/tests/test_variables.py b/tests/test_variables.py index b6cafcfab..9a6272ad6 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -38,7 +38,7 @@ ) from logfire.variables.local import LocalVariableProvider from logfire.variables.remote import LogfireRemoteVariableProvider -from logfire.variables.variable import is_resolve_function +from logfire.variables.variable import _record_exception, is_resolve_function # ============================================================================= # Test Condition Classes @@ -2194,6 +2194,23 @@ def failing_get(*args: Any, **kwargs: Any) -> ResolvedVariable[str | None]: # Restore original lf.config._variable_provider.get_serialized_value = original + def test_record_exception_ignores_cpython_traceback_bug(self): + span = unittest.mock.Mock() + error = ValueError('Provider failed!') + span.record_exception.side_effect = RuntimeError('generator raised StopIteration') + + _record_exception(error, span) + + span.record_exception.assert_called_once_with(error) + + def test_record_exception_reraises_other_runtime_errors(self): + span = unittest.mock.Mock() + error = ValueError('Provider failed!') + span.record_exception.side_effect = RuntimeError('unexpected recording failure') + + with pytest.raises(RuntimeError, match='unexpected recording failure'): + _record_exception(error, span) + def test_variables_build_config(self, config_kwargs: dict[str, Any]): """Test that variables_build_config on a Logfire instance delegates to VariablesConfig.from_variables.""" lf = logfire.configure(**config_kwargs) From 402d99559de0fb84933fe549baab22929622a3dd Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 13:19:17 +0300 Subject: [PATCH 10/40] Address Cubic variable review comments --- logfire/variables/composition.py | 40 ++++++++++++++++++++++++++---- logfire/variables/variable.py | 2 ++ tests/test_template_validation.py | 8 +++--- tests/test_variable_composition.py | 25 +++++++++++++++++++ tests/test_variables.py | 2 ++ 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index e98399a49..c84d1df27 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -231,7 +231,7 @@ def expand_references( context[name] = f'@{{{name}}}@' # Walk the decoded value and render each string through the reference-syntax Handlebars engine. - rendered = _render_value(decoded, context) + rendered = _render_value(decoded, context, unresolved_names) result_serialized = json.dumps(rendered) return result_serialized, composed @@ -306,7 +306,7 @@ def _walk(v: Any) -> None: return result -def _render_value(value: Any, context: dict[str, Any]) -> Any: +def _render_value(value: Any, context: dict[str, Any], unresolved_names: set[str]) -> Any: """Recursively walk a decoded JSON value, rendering strings through Handlebars. Unresolved variable names should already be present in the context as their @@ -318,11 +318,41 @@ def _render_value(value: Any, context: dict[str, Any]) -> Any: return value.replace('\\@{', '@{') from logfire.variables.reference_syntax import render_once - return render_once(value, context) + 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 + return _restore_unresolved_refs(rendered, protected_refs) if isinstance(value, dict): - return {k: _render_value(v, context) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] + return { + k: _render_value(v, context, unresolved_names) + for k, v in value.items() # pyright: ignore[reportUnknownVariableType] + } if isinstance(value, list): - return [_render_value(v, context) for v in value] # pyright: ignore[reportUnknownVariableType] + return [_render_value(v, context, unresolved_names) for v in value] # pyright: ignore[reportUnknownVariableType] + return value + + +def _protect_unresolved_dotted_refs(value: str, unresolved_names: set[str]) -> tuple[str, dict[str, str]]: + """Replace unresolved dotted reference tags with sentinels before Handlebars rendering.""" + if not unresolved_names: + return value, {} + + protected_refs: dict[str, str] = {} + + def replace(match: re.Match[str]) -> str: + full_ref = match.group(1) + if '.' not in full_ref or full_ref.split('.')[0] not in unresolved_names: + return match.group(0) + sentinel = f'\x00logfire-unresolved-ref-{len(protected_refs)}-{id(value)}\x00' + protected_refs[sentinel] = match.group(0) + return sentinel + + return _SIMPLE_REF.sub(replace, value), protected_refs + + +def _restore_unresolved_refs(value: str, protected_refs: dict[str, str]) -> str: + """Restore unresolved reference sentinels after Handlebars rendering.""" + for sentinel, ref in protected_refs.items(): + value = value.replace(sentinel, ref) return value diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 3a2a063c8..206d69a04 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -367,6 +367,8 @@ def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str] value=default, exception=value_or_exc, _reason=reason, + label=serialized_result.label, + version=serialized_result.version, composed_from=composed, ) diff --git a/tests/test_template_validation.py b/tests/test_template_validation.py index 30892db43..eab3235e3 100644 --- a/tests/test_template_validation.py +++ b/tests/test_template_validation.py @@ -326,16 +326,16 @@ def test_no_template_fields(self): result = validate_template_composition('my_var', schema, get_values) assert result.issues == [] - def test_empty_schema_no_restrictions(self): - """With empty properties, any field is allowed (no declared properties to conflict with).""" - schema = {'properties': {}} + def test_empty_object_schema_rejects_fields(self): + """With an object schema and no declared properties, template fields are invalid.""" + schema = {'type': 'object', 'properties': {}} get_values = _make_get_all_serialized( { 'my_var': {None: '"{{a}} {{b}}"'}, } ) result = validate_template_composition('my_var', schema, get_values) - assert result.issues == [] + assert {issue.field_name for issue in result.issues} == {'a', 'b'} def test_unknown_fields_with_declared_properties(self): """When schema declares properties, unlisted fields are issues.""" diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 4c7644319..ffd8f2176 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -214,6 +214,31 @@ def test_unresolvable_reference(self): assert composed[0].value is None assert composed[0].reason == 'unrecognized_variable' + def test_unresolvable_dotted_reference(self): + """Dotted references to non-existent variables are left unexpanded.""" + resolve_fn = _make_resolve_fn({}) + expanded, composed = expand_references('"Hello @{nonexistent.field}@"', 'my_var', resolve_fn) + assert expanded == '"Hello @{nonexistent.field}@"' + assert len(composed) == 1 + assert composed[0].name == 'nonexistent' + assert composed[0].value is None + assert composed[0].reason == 'unrecognized_variable' + + def test_unresolvable_dotted_reference_preserves_resolved_refs(self): + """Protecting unresolved dotted refs doesn't block other references in the same value.""" + resolve_fn = _make_resolve_fn({'known': '"there"'}) + expanded, composed = expand_references('"Hi @{known}@ @{missing.field}@"', 'my_var', resolve_fn) + assert expanded == '"Hi there @{missing.field}@"' + assert [ref.name for ref in composed] == ['known', 'missing'] + + def test_unresolvable_simple_and_dotted_reference_same_base(self): + """Simple and dotted unresolved refs for the same base are both preserved.""" + resolve_fn = _make_resolve_fn({}) + expanded, composed = expand_references('"@{missing}@ @{missing.field}@"', 'my_var', resolve_fn) + assert expanded == '"@{missing}@ @{missing.field}@"' + assert len(composed) == 1 + assert composed[0].name == 'missing' + def test_none_value_reference(self): """References to variables with None value are left unexpanded.""" resolve_fn = _make_resolve_fn({'missing': None}) diff --git a/tests/test_variables.py b/tests/test_variables.py index 9a6272ad6..844a44d53 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -1637,6 +1637,8 @@ def test_get_details_with_validation_error(self, config_kwargs: dict[str, Any], assert details.value == 999 assert details.exception is not None assert details._reason == 'validation_error' + assert details.label == 'default' + assert details.version == 1 def test_get_uses_default_when_no_config(self, config_kwargs: dict[str, Any]): config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) From 5fbadd2cd04cfceeb798dd9ac28eb951efaa8154 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 14:19:53 +0300 Subject: [PATCH 11/40] Narrow pytest exception recording fallback --- logfire/_internal/integrations/pytest.py | 5 +++-- tests/otel_integrations/test_pytest_plugin.py | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/logfire/_internal/integrations/pytest.py b/logfire/_internal/integrations/pytest.py index 3e18a61c5..fe7c306ee 100644 --- a/logfire/_internal/integrations/pytest.py +++ b/logfire/_internal/integrations/pytest.py @@ -457,10 +457,11 @@ def pytest_runtest_makereport( # Branch coverage: excinfo.value is always present for failed tests in normal pytest execution try: span.record_exception(call.excinfo.value) - except RuntimeError: + except RuntimeError as e: # CPython 3.11+ can raise "generator raised StopIteration" from # traceback.extract_tb when processing certain bytecode positions. - pass + if str(e) != 'generator raised StopIteration': + raise elif report.skipped: # pragma: no cover # TODO: this needs improvement in processing skip reasons skip_reason = '' diff --git a/tests/otel_integrations/test_pytest_plugin.py b/tests/otel_integrations/test_pytest_plugin.py index 5d99943c0..1a763a727 100644 --- a/tests/otel_integrations/test_pytest_plugin.py +++ b/tests/otel_integrations/test_pytest_plugin.py @@ -453,6 +453,28 @@ def test_failed_test_ignores_record_exception_runtime_error() -> None: span.record_exception.assert_called_once_with(call.excinfo.value) +def test_failed_test_reraises_unexpected_record_exception_runtime_error() -> None: + """Failed test reporting should only suppress the known CPython traceback bug.""" + span = mock.Mock() + span.record_exception.side_effect = RuntimeError('unexpected recording failure') + item = mock.Mock() + item.stash.get.return_value = span + report = mock.Mock(when='call', failed=True, skipped=False, duration=0.1, outcome='failed') + call = mock.Mock() + call.excinfo.typename = 'ValueError' + call.excinfo.value = ValueError('bad value') + outcome = mock.Mock() + outcome.get_result.return_value = report + + hook = pytest_plugin.pytest_runtest_makereport(item, call) + next(hook) + + with pytest.raises(RuntimeError, match='unexpected recording failure'): + hook.send(outcome) + + span.record_exception.assert_called_once_with(call.excinfo.value) + + def test_skipped_test_with_reason(logfire_pytester: pytest.Pytester): """Skipped tests should create a span with basic test attributes. From 0b2d469c7fb6bde881f6b9da20dd64b06dbfb728 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 15:02:06 +0300 Subject: [PATCH 12/40] Send template input schemas for remote variables --- logfire/variables/remote.py | 3 +++ tests/test_variables.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/logfire/variables/remote.py b/logfire/variables/remote.py index b780a658f..900c8abfb 100644 --- a/logfire/variables/remote.py +++ b/logfire/variables/remote.py @@ -542,6 +542,9 @@ def _config_to_api_body(self, config: VariableConfig) -> dict[str, Any]: if config.example is not None: body['example'] = config.example + if config.template_inputs_schema is not None: + body['template_inputs_schema'] = config.template_inputs_schema + # Include labels if present if config.labels: body['labels'] = { diff --git a/tests/test_variables.py b/tests/test_variables.py index 844a44d53..ecd9b1296 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -2486,6 +2486,41 @@ def test_create_variable_with_aliases_and_example(self) -> None: finally: provider.shutdown() + def test_create_variable_with_template_inputs_schema(self) -> None: + """Test creating a template variable sends the template inputs schema.""" + request_mocker = requests_mock_module.Mocker() + request_mocker.get('http://localhost:8000/v1/variables/', json={'variables': {}}) + post_adapter = request_mocker.post('http://localhost:8000/v1/variables/', json={'name': 'template_var'}) + with request_mocker: + provider = LogfireRemoteVariableProvider( + base_url=REMOTE_BASE_URL, + token=REMOTE_TOKEN, + options=VariablesOptions(block_before_first_resolve=False, polling_interval=timedelta(seconds=60)), + ) + try: + template_inputs_schema = { + 'type': 'object', + 'properties': {'user_name': {'type': 'string'}}, + 'required': ['user_name'], + } + config = VariableConfig( + name='template_var', + labels={'v1': LabeledValue(version=1, serialized_value='"Hello {{user_name}}"')}, + rollout=Rollout(labels={'v1': 1.0}), + overrides=[], + description='Template variable', + json_schema={'type': 'string'}, + template_inputs_schema=template_inputs_schema, + ) + result = provider.create_variable(config) + assert result.name == 'template_var' + + assert post_adapter.last_request is not None + request_body = post_adapter.last_request.json() + assert request_body['template_inputs_schema'] == template_inputs_schema + finally: + provider.shutdown() + def test_create_variable_already_exists(self) -> None: from logfire.variables.abstract import VariableAlreadyExistsError From 052fe6ce48855c29648f63fc40326e5f453e5582 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 16:06:05 +0300 Subject: [PATCH 13/40] Address remaining variable review comments --- .../configuration-reference.md | 2 +- examples/python/variable_composition_demo.py | 2 +- logfire/_internal/main.py | 23 +++++++++++++++++++ tests/type_checking.py | 18 ++++++++++++++- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/reference/advanced/managed-variables/configuration-reference.md b/docs/reference/advanced/managed-variables/configuration-reference.md index bb1c44dee..07f56f13d 100644 --- a/docs/reference/advanced/managed-variables/configuration-reference.md +++ b/docs/reference/advanced/managed-variables/configuration-reference.md @@ -15,7 +15,7 @@ | `description` | Human-readable description (optional) | | `aliases` | Alternative names that resolve to this variable (optional, for migrations) | | `example` | JSON-serialized example value, used as starting point when creating versions in the UI (optional) | -| `template_inputs_schema` | JSON Schema for template `{{placeholder}}` inputs (optional, set automatically by `logfire.template_var()`) | +| `template_inputs_schema` | JSON Schema for template `{{placeholder}}` inputs (optional, set automatically when template inputs are declared, e.g. via `logfire.template_var()` or `logfire.var(..., template_inputs=...)`) | **LabeledValue** — A label with an inline serialized value: diff --git a/examples/python/variable_composition_demo.py b/examples/python/variable_composition_demo.py index 7ff7f0278..360938054 100644 --- a/examples/python/variable_composition_demo.py +++ b/examples/python/variable_composition_demo.py @@ -557,7 +557,7 @@ def section(title: str) -> None: section('10. Composition-Time Conditionals: @{#if}@ with feature flags') print('The banner_message variable uses @{#if beta_enabled}@ at composition time.') -print('This conditional is resolved when @{}@ references are expanded, NOT at') +print('This conditional is resolved when @{...}@ references are expanded, NOT at') print('template render time. The beta_enabled variable controls which branch appears.') print() diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 17dcbf7e5..11d19c710 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -143,6 +143,7 @@ ExcInfo = Union[SysExcInfo, BaseException, bool, None] T = TypeVar('T') +InputsT = TypeVar('InputsT') class Logfire: @@ -2647,6 +2648,28 @@ class PromptInputs(BaseModel): return variable + @overload + def template_var( + self, + name: str, + *, + type: type[T], + default: T | ResolveFunction[T], + inputs_type: type[dict[Any, Any]], + description: str | None = None, + ) -> TemplateVariable[T, dict[Any, Any]]: ... + + @overload + def template_var( + self, + name: str, + *, + type: type[T], + default: T | ResolveFunction[T], + inputs_type: type[InputsT], + description: str | None = None, + ) -> TemplateVariable[T, InputsT]: ... + def template_var( self, name: str, diff --git a/tests/type_checking.py b/tests/type_checking.py index 62054d07e..25233f01f 100644 --- a/tests/type_checking.py +++ b/tests/type_checking.py @@ -1,7 +1,9 @@ from typing import assert_type +from pydantic import BaseModel + import logfire -from logfire.variables import Variable +from logfire.variables import TemplateVariable, Variable # Documenting the current behavior: including a default of an incompatible type extends the union rather than producing # a type error. This is arguably a feature, not a bug — the `type` is only used for validating provider values, not the @@ -11,3 +13,17 @@ # type error, so the above argument is just a way of turning lemons into lemonade. my_variable_2 = logfire.Logfire().var(name='my_variable_2', default=None, type=int) assert_type(my_variable_2, Variable[int | None]) + + +class PromptInputs(BaseModel): + name: str + + +my_template_variable = logfire.Logfire().template_var( + name='my_template_variable', + default='Hello {{name}}', + type=str, + inputs_type=PromptInputs, +) +assert_type(my_template_variable, TemplateVariable[str, PromptInputs]) +assert_type(my_template_variable.get(PromptInputs(name='Alice')).value, str) From 2e8c2b2bf8b85052d43180f60b74c693520cde1a Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Mon, 11 May 2026 12:55:42 +0300 Subject: [PATCH 14/40] Document template variable dependency --- .../advanced/managed-variables/index.md | 7 +++++++ .../templates-and-composition.md | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/reference/advanced/managed-variables/index.md b/docs/reference/advanced/managed-variables/index.md index 73756b623..460100aac 100644 --- a/docs/reference/advanced/managed-variables/index.md +++ b/docs/reference/advanced/managed-variables/index.md @@ -117,6 +117,13 @@ With managed variables, you can iterate safely in production: For AI applications, variables often contain prompt templates with placeholders that get filled in at runtime. **Template variables** support this natively with Handlebars `{{placeholder}}` syntax: +!!! note "Install the variables extra for templates" + Template rendering requires the `pydantic-handlebars` package, which is installed by the `logfire[variables]` extra on Python 3.10 and later. + + ```bash + pip install 'logfire[variables]' + ``` + ```python skip="true" from pydantic import BaseModel diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md index 694d19cf3..bd6b1424d 100644 --- a/docs/reference/advanced/managed-variables/templates-and-composition.md +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -4,6 +4,15 @@ Managed variables can contain **Handlebars templates** (`{{placeholder}}`) and * This is especially useful for AI applications where prompts are built from reusable fragments and personalized with request-specific data. +!!! note "Install the variables extra" + Template rendering requires the `pydantic-handlebars` package, which is installed by the `logfire[variables]` extra on Python 3.10 and later: + + ```bash + pip install 'logfire[variables]' + ``` + + Without this extra, `logfire.template_var()` can still be defined, but resolution falls back to the unrendered value and exposes the dependency error on `resolved.exception`. + ## Template Variables A **template variable** is a variable whose value contains `{{placeholder}}` expressions that are rendered with typed inputs at resolution time. Define one with `logfire.template_var()`: @@ -255,11 +264,4 @@ The system detects circular references at write time. If variable A references ` ## Requirements -Template rendering and composition require the [`pydantic-handlebars`](https://github.com/pydantic/pydantic-handlebars) library, which is included in the `variables` extra: - -```bash -pip install 'logfire[variables]' -``` - -!!! note "Python 3.10+" - `pydantic-handlebars` requires Python 3.10 or later. On Python 3.9, basic variable features (`logfire.var()` without templates or composition) still work, but template rendering is not available. +`pydantic-handlebars` requires Python 3.10 or later. On Python 3.9, basic variable features (`logfire.var()` without templates or composition) still work, but template rendering is not available. From 94d3c408dec91cc11d66d7bdad72be940a7a2df1 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Mon, 11 May 2026 14:23:17 +0300 Subject: [PATCH 15/40] Fail early without template variable dependency --- .../templates-and-composition.md | 2 +- logfire/_internal/main.py | 9 +++++++++ logfire/variables/_handlebars.py | 5 +++++ tests/test_variable_templates.py | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md index bd6b1424d..bc0f991cf 100644 --- a/docs/reference/advanced/managed-variables/templates-and-composition.md +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -11,7 +11,7 @@ This is especially useful for AI applications where prompts are built from reusa pip install 'logfire[variables]' ``` - Without this extra, `logfire.template_var()` can still be defined, but resolution falls back to the unrendered value and exposes the dependency error on `resolved.exception`. + Without this extra, `logfire.template_var()` raises an error immediately so your application does not silently use an unrendered template. ## Template Variables diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 11d19c710..225d53297 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2636,6 +2636,11 @@ class PromptInputs(BaseModel): f"A variable with name '{name}' has already been registered. Each variable must have a unique name." ) + if template_inputs is not None: + from logfire.variables._handlebars import ensure_handlebars_available + + ensure_handlebars_available() + variable = Variable[T]( name, default=default, @@ -2734,6 +2739,10 @@ class PromptInputs(BaseModel): f"A variable with name '{name}' has already been registered. Each variable must have a unique name." ) + from logfire.variables._handlebars import ensure_handlebars_available + + ensure_handlebars_available() + variable = TemplateVariable[T, Any]( name, type=type, diff --git a/logfire/variables/_handlebars.py b/logfire/variables/_handlebars.py index 208842fb3..c49394e63 100644 --- a/logfire/variables/_handlebars.py +++ b/logfire/variables/_handlebars.py @@ -28,6 +28,11 @@ def _dependency_error() -> HandlebarsDependencyError: ) +def ensure_handlebars_available() -> None: + """Raise a helpful error if pydantic-handlebars is unavailable.""" + get_handlebars_renderer() + + @cache def get_handlebars_renderer() -> tuple[type[str], Callable[..., str]]: """Return pydantic-handlebars SafeString and render, or raise a helpful error.""" diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index 33fdb01ad..f6dcef07e 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -77,6 +77,21 @@ def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): from logfire.variables.abstract import render_serialized_string assert logfire.var + assert logfire.var('plain_var', type=str, default='Hello') + + try: + logfire.var('templated_var', type=str, default='Hello {{name}}', template_inputs=dict) + except ImportError as exc: + assert 'pydantic-handlebars' in str(exc) + else: + raise AssertionError('template_inputs should require pydantic-handlebars') + + try: + logfire.template_var('template_var', type=str, default='Hello {{name}}', inputs_type=dict) + except ImportError as exc: + assert 'pydantic-handlebars' in str(exc) + else: + raise AssertionError('template_var should require pydantic-handlebars') try: render_serialized_string('"Hello {{name}}"', {'name': 'Alice'}) From 67e785e708e71e42c55182f0c9a6bb1cbcb498e3 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Mon, 11 May 2026 16:11:05 +0300 Subject: [PATCH 16/40] Address variable composition review cleanup --- .../managed-variables/templates-and-composition.md | 4 +--- logfire/variables/composition.py | 6 +++--- tests/test_variable_composition.py | 8 +++----- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md index bc0f991cf..02c9aedfa 100644 --- a/docs/reference/advanced/managed-variables/templates-and-composition.md +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -126,9 +126,7 @@ agent_config = logfire.template_var( ), inputs_type=UserContext, ) -``` -```python skip="true" with agent_config.get(UserContext(user_name='Alice', tier='premium')) as resolved: print(resolved.value.instructions) # "You are helping Alice, a premium customer." print(resolved.value.model) # "openai:gpt-4o-mini" (unchanged) @@ -260,7 +258,7 @@ with chat_prompt.get(ChatInputs(user_name='Alice', language='French')) as resolv ### Cycle Detection -The system detects circular references at write time. If variable A references `@{B}@` and variable B references `@{A}@`, pushing this configuration will produce an error. This prevents infinite loops during resolution. +The system detects circular references during validation. If variable A references `@{B}@` and variable B references `@{A}@`, `logfire.variables_validate()` reports the cycle, and `logfire.variables_push(strict=True)` fails instead of applying the invalid configuration. This prevents infinite loops during resolution. ## Requirements diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index c84d1df27..50a67a29b 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -92,7 +92,7 @@ def expand_references( variable_name: str, resolve_fn: ResolveFn, *, - _visited: frozenset[str] = frozenset(), + _visited: tuple[str, ...] = (), _depth: int = 0, ) -> tuple[str, list[ComposedReference]]: """Expand ``@{var}@`` references in a serialized variable value. @@ -107,7 +107,7 @@ def expand_references( variable_name: Name of the variable being expanded (for cycle detection). resolve_fn: Function that resolves a variable name to (serialized_value, label, version, reason). - _visited: Internal - set of variable names in the current expansion chain. + _visited: Internal - ordered variable names in the current expansion chain. _depth: Internal - current recursion depth. Returns: @@ -126,7 +126,7 @@ def expand_references( if variable_name in _visited: raise VariableCompositionCycleError(f'Circular reference detected: {" -> ".join(_visited)} -> {variable_name}') - visited = _visited | {variable_name} + visited = (*_visited, variable_name) composed: list[ComposedReference] = [] # JSON-decode the serialized value so we can work with actual strings. diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index ffd8f2176..cb9ef8045 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -162,8 +162,7 @@ def test_cycle_detection(self): # b itself resolved, but its expansion of @{a}@ failed assert len(b_ref.composed_from) == 1 assert b_ref.composed_from[0].name == 'a' - assert b_ref.composed_from[0].error is not None - assert 'Circular reference' in b_ref.composed_from[0].error + assert b_ref.composed_from[0].error == 'Circular reference detected: my_var -> a -> b -> a' def test_self_reference_cycle(self): """A variable referencing itself is caught.""" @@ -175,8 +174,7 @@ def test_self_reference_cycle(self): # a resolved, but its self-reference @{a}@ failed with cycle assert len(composed[0].composed_from) == 1 assert composed[0].composed_from[0].name == 'a' - assert composed[0].composed_from[0].error is not None - assert 'Circular reference' in composed[0].composed_from[0].error + assert composed[0].composed_from[0].error == 'Circular reference detected: my_var -> a -> a' def test_depth_limit(self): """Chains exceeding MAX_COMPOSITION_DEPTH are caught.""" @@ -804,7 +802,7 @@ def test_direct_cycle_error(self): '"test"', 'a', _make_resolve_fn({}), - _visited=frozenset({'a'}), + _visited=('a',), ) def test_direct_depth_error(self): From 85331caf2246d21fbba661e76d15ac4e33890cd9 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Mon, 11 May 2026 16:24:38 +0300 Subject: [PATCH 17/40] Fix runnable managed variables docs example --- .../advanced/managed-variables/templates-and-composition.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md index 02c9aedfa..cf7e4de3d 100644 --- a/docs/reference/advanced/managed-variables/templates-and-composition.md +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -129,7 +129,9 @@ agent_config = logfire.template_var( with agent_config.get(UserContext(user_name='Alice', tier='premium')) as resolved: print(resolved.value.instructions) # "You are helping Alice, a premium customer." + #> You are helping Alice, a premium customer. print(resolved.value.model) # "openai:gpt-4o-mini" (unchanged) + #> openai:gpt-4o-mini ``` ### Ad-hoc Rendering with `resolved.render()` From 15bdf984bdf82db92ab8822f7f1435541af0f735 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Mon, 11 May 2026 17:23:14 +0300 Subject: [PATCH 18/40] Fix variable composition default handling --- .../templates-and-composition.md | 4 +- logfire/_internal/main.py | 3 +- logfire/variables/abstract.py | 18 +++-- logfire/variables/variable.py | 79 ++++++++++++++++++- tests/test_push_variables.py | 34 +++++++- tests/test_variable_composition.py | 53 ++++++++++--- 6 files changed, 166 insertions(+), 25 deletions(-) diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md index cf7e4de3d..3b93d0483 100644 --- a/docs/reference/advanced/managed-variables/templates-and-composition.md +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -128,9 +128,9 @@ agent_config = logfire.template_var( ) with agent_config.get(UserContext(user_name='Alice', tier='premium')) as resolved: - print(resolved.value.instructions) # "You are helping Alice, a premium customer." + print(resolved.value.instructions) #> You are helping Alice, a premium customer. - print(resolved.value.model) # "openai:gpt-4o-mini" (unchanged) + print(resolved.value.model) #> openai:gpt-4o-mini ``` diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 225d53297..749a33857 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2792,7 +2792,8 @@ def variables_push( registered with this Logfire instance will be pushed. dry_run: If True, only show what would change without applying. yes: If True, skip confirmation prompt. - strict: If True, fail if any existing label values are incompatible with new schemas. + strict: If True, fail if any existing label values are incompatible with new schemas + or any reference warnings are found. Returns: True if changes were applied (or would be applied in dry_run mode), False otherwise. diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index 7dd5ef8f0..2ab0b19c0 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -390,8 +390,8 @@ def has_errors(self) -> bool: @property def is_valid(self) -> bool: - """Return False if there are any validation errors or any variables not defined in the (possibly remote) config.""" - return len(self.errors) == 0 and len(self.variables_not_on_server) == 0 + """Return False if there are validation errors, missing variables, or reference warnings.""" + return len(self.errors) == 0 and len(self.variables_not_on_server) == 0 and len(self.reference_warnings) == 0 def format(self, *, colors: bool = True) -> str: """Format the validation report for human-readable output. @@ -453,8 +453,8 @@ def format(self, *, colors: bool = True) -> str: # Summary line if not self.is_valid: - error_count = variables_with_errors + len(self.variables_not_on_server) - lines.append(f'\n{red}Validation failed: {error_count} error(s) found.{reset}') + issue_count = variables_with_errors + len(self.variables_not_on_server) + len(self.reference_warnings) + lines.append(f'\n{red}Validation failed: {issue_count} issue(s) found.{reset}') else: lines.append(f'\n{green}Validation passed: All {self.variables_checked} variable(s) are valid.{reset}') @@ -1273,7 +1273,8 @@ def push_variables( variables: Variable instances to push. dry_run: If True, only show what would change without applying. yes: If True, skip confirmation prompt. - strict: If True, fail if any existing label values are incompatible with new schemas. + strict: If True, fail if any existing label values are incompatible with new schemas + or any reference warnings are found. Returns: True if changes were applied (or would be applied in dry_run mode), False otherwise. @@ -1301,6 +1302,13 @@ def push_variables( # Show diff print(_format_diff(diff)) + if diff.reference_warnings and strict: + print( + f'\n{ANSI_RED}Error: Reference warnings found.\n' + f'Fix these references or set strict=False to proceed anyway.{ANSI_RESET}' + ) + return False + # Check for incompatible label values across all change types incompatible_changes = [c for c in diff.changes if c.incompatible_labels] if incompatible_changes: diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 206d69a04..e2b650e0f 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -186,6 +186,7 @@ def __init__( self.description = description self.template_inputs_type = template_inputs + self._variable_registry = logfire_instance._variables # pyright: ignore[reportPrivateUsage] self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='variables') self.type_adapter = TypeAdapter[T_co](type) @@ -264,10 +265,17 @@ def _resolve( serialized_result = provider.get_serialized_value(self.name, targeting_key, attributes) if serialized_result.value is None: - default = self._get_default(targeting_key, attributes) - if render_fn is not None: - default = self._render_default(default, render_fn) - return _with_value(serialized_result, default) + default_result = self._resolve_serialized_default( + serialized_result, + provider, + targeting_key, + attributes, + span, + render_fn=render_fn, + ) + if default_result is not None: + return default_result + return _with_value(serialized_result, self._get_default(targeting_key, attributes)) return self._expand_and_deserialize( serialized_result, provider, targeting_key, attributes, span, render_fn=render_fn @@ -313,6 +321,10 @@ def _expand_and_deserialize( def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str]: ref_resolved = provider.get_serialized_value(ref_name, targeting_key, attributes) + if ref_resolved.value is None and (ref_variable := self._variable_registry.get(ref_name)) is not None: + ref_default = ref_variable._get_serialized_default(targeting_key, attributes) + if ref_default is not None: + return (ref_default, None, None, 'code_default') return ( ref_resolved.value, ref_resolved.label, @@ -326,6 +338,17 @@ def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str] self.name, resolve_ref, ) + if composition_error := _first_composition_error(composed): + default = self._get_default(targeting_key, attributes) + return ResolvedVariable( + name=self.name, + value=default, + exception=VariableCompositionError(composition_error), + _reason='other_error', + label=serialized_result.label, + version=serialized_result.version, + composed_from=composed, + ) except VariableCompositionError as e: default = self._get_default(targeting_key, attributes) return ResolvedVariable( @@ -391,6 +414,44 @@ def _get_default( else: return self.default + def _get_serialized_default( + self, targeting_key: str | None = None, merged_attributes: Mapping[str, Any] | None = None + ) -> str | None: + """Return the code default serialized as JSON, or None if serialization fails.""" + try: + default = self._get_default(targeting_key, merged_attributes) + return self.type_adapter.dump_json(default).decode('utf-8') + except (ValueError, TypeError, RuntimeError): + return None + + def _resolve_serialized_default( + self, + serialized_result: ResolvedVariable[str | None], + provider: VariableProvider, + targeting_key: str | None, + attributes: Mapping[str, Any] | None, + span: logfire.LogfireSpan | None, + render_fn: Callable[[str], str] | None = None, + ) -> ResolvedVariable[T_co] | None: + """Resolve the code default through composition/rendering when needed.""" + serialized_default = self._get_serialized_default(targeting_key, attributes) + if serialized_default is None: + return None + if render_fn is None and not has_references(serialized_default): + return None + + result = self._expand_and_deserialize( + ResolvedVariable(name=self.name, value=serialized_default, _reason='missing_config'), + provider, + targeting_key, + attributes, + span, + render_fn=render_fn, + ) + if result._reason == 'resolved': # pyright: ignore[reportPrivateUsage] + result._reason = serialized_result._reason # pyright: ignore[reportPrivateUsage] + return result + def _get_merged_attributes(self, attributes: Mapping[str, Any] | None = None) -> Mapping[str, Any]: from logfire._internal.config import LocalVariablesOptions, VariablesOptions @@ -639,6 +700,16 @@ def _with_value(details: ResolvedVariable[Any], new_value: T_co) -> ResolvedVari return replace(details, value=new_value) +def _first_composition_error(composed: list[ComposedReference]) -> str | None: + """Return the first nested composition error, if any.""" + for ref in composed: + if ref.error is not None: + return ref.error + if nested_error := _first_composition_error(ref.composed_from): + return nested_error + return None + + @contextmanager def targeting_context( targeting_key: str, diff --git a/tests/test_push_variables.py b/tests/test_push_variables.py index 378b0e3ce..b1e82ae61 100644 --- a/tests/test_push_variables.py +++ b/tests/test_push_variables.py @@ -3,7 +3,7 @@ # pyright: reportPrivateUsage=false from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any import pytest @@ -35,6 +35,7 @@ class MockLogfire: """Mock Logfire instance for testing.""" config: Any = None + _variables: dict[str, object] = field(default_factory=dict[str, object]) def with_settings(self, **kwargs: Any) -> MockLogfire: """Return self for chaining.""" @@ -463,12 +464,39 @@ def test_validation_report_format_reference_and_description_warnings() -> None: output = report.format(colors=False) - assert 'Validation passed' in output + assert 'Validation failed' in output assert 'Description differences' in output assert 'Local: local' in output assert 'Server: (none)' in output assert 'Reference warnings' in output - assert 'missing' in output + + +def test_validation_report_reference_warnings_are_invalid() -> None: + """Reference warnings make validation invalid so strict push paths can fail on cycles.""" + report = ValidationReport( + errors=[], + variables_checked=1, + variables_not_on_server=[], + description_differences=[], + reference_warnings=['Reference cycle detected: prompt -> prompt'], + ) + + assert report.is_valid is False + assert report.has_errors is True + + +def test_push_variables_strict_fails_with_reference_warnings(mock_logfire_instance: MockLogfire) -> None: + """Strict push fails when reference warnings such as cycles are present.""" + provider = LocalVariableProvider(VariablesConfig(variables={})) + var = Variable[str]( + name='prompt', + default='@{prompt}@', + type=str, + logfire_instance=mock_logfire_instance, # type: ignore + ) + + assert provider.push_variables([var], strict=True, yes=True) is False + assert provider.get_all_variables_config().variables == {} def test_variable_diff_has_changes_true() -> None: diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index cb9ef8045..68fd78442 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -588,7 +588,7 @@ def test_nested_reference(self, config_kwargs: dict[str, Any]): assert result.composed_from[0].composed_from[0].name == 'c' def test_cycle_falls_back_gracefully(self, config_kwargs: dict[str, Any]): - """Cycles in references cause graceful fallback to default.""" + """Cycles in references are surfaced on the top-level result.""" variables_config = _make_variables_config( a='"@{b}@"', b='"@{a}@"', @@ -598,11 +598,11 @@ def test_cycle_falls_back_gracefully(self, config_kwargs: dict[str, Any]): var = lf.var(name='a', default='fallback', type=str) result = var.get() - # The cycle in b trying to reference a (which is in the visited set) means - # b's expansion fails, b is left as @{a}@ inside a's value. - # So a's value becomes "@{a}@" (the literal unexpanded ref from b's failed expansion). - # Actually the value should still deserialize as a string, just with unexpanded refs. - assert isinstance(result.value, str) + assert result.value == 'fallback' + assert isinstance(result.exception, VariableCompositionError) + assert result._reason == 'other_error' + assert len(result.composed_from) == 1 + assert result.composed_from[0].composed_from[0].error == 'Circular reference detected: a -> b -> a' def test_nonexistent_reference_left_unexpanded(self, config_kwargs: dict[str, Any]): """References to non-existent variables are left as-is.""" @@ -765,7 +765,7 @@ def test_span_attributes_without_composition(self, config_kwargs: dict[str, Any] assert 'composed_from' not in attrs def test_no_value_no_composition(self, config_kwargs: dict[str, Any]): - """When variable resolves to None (code default), no composition happens.""" + """References in code defaults are expanded when a provider has no selected value.""" variables_config = VariablesConfig( variables={ 'main': VariableConfig( @@ -780,11 +780,44 @@ def test_no_value_no_composition(self, config_kwargs: dict[str, Any]): config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) lf = logfire.configure(**config_kwargs) + lf.var(name='greeting', default='Hello', type=str) var = lf.var(name='main', default='@{greeting}@ fallback', type=str) result = var.get() - # Default is returned as-is (no composition on defaults) - assert result.value == '@{greeting}@ fallback' - assert result.composed_from == [] + assert result.value == 'Hello fallback' + assert len(result.composed_from) == 1 + assert result.composed_from[0].name == 'greeting' + assert result.composed_from[0].reason == 'code_default' + + def test_reference_falls_back_to_registered_code_default(self, config_kwargs: dict[str, Any]): + """A composed reference uses a registered variable's default when the provider has no selected value.""" + variables_config = VariablesConfig( + variables={ + 'main': VariableConfig( + name='main', + json_schema={'type': 'string'}, + labels={'production': LabeledValue(version=1, serialized_value='"@{greeting}@ from provider"')}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + 'greeting': VariableConfig( + name='greeting', + json_schema={'type': 'string'}, + labels={}, + rollout=Rollout(labels={}), + overrides=[], + ), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + lf.var(name='greeting', default='Hello', type=str) + var = lf.var(name='main', default='fallback', type=str) + result = var.get() + assert result.value == 'Hello from provider' + assert len(result.composed_from) == 1 + assert result.composed_from[0].name == 'greeting' + assert result.composed_from[0].reason == 'code_default' class TestCompositionExceptions: From 543e822bfdfb11bcb0ca2c8ff7b2b394b1757412 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Mon, 11 May 2026 18:02:43 +0300 Subject: [PATCH 19/40] Clarify provider no-value composition test --- tests/test_variable_composition.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 68fd78442..20b69617c 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -764,19 +764,12 @@ def test_span_attributes_without_composition(self, config_kwargs: dict[str, Any] attrs = dict(main_span.attributes or {}) assert 'composed_from' not in attrs - def test_no_value_no_composition(self, config_kwargs: dict[str, Any]): + @pytest.mark.parametrize('register_main', [False, True], ids=['unregistered', 'registered_no_selected_value']) + def test_code_default_composition_when_provider_has_no_value( + self, config_kwargs: dict[str, Any], register_main: bool + ): """References in code defaults are expanded when a provider has no selected value.""" - variables_config = VariablesConfig( - variables={ - 'main': VariableConfig( - name='main', - json_schema={'type': 'string'}, - labels={}, - rollout=Rollout(labels={}), - overrides=[], - ), - } - ) + variables_config = _make_variables_config(main=None) if register_main else VariablesConfig(variables={}) config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) lf = logfire.configure(**config_kwargs) From c068526c1a18ef9fafe5f4d2b34aea60b1ae42a9 Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 07:34:03 -0600 Subject: [PATCH 20/40] Drop dict overload for template_var Require parametrising dict in usage; aligns the typing surface with the rest of the API and removes the dict[Any, Any] coercion footgun. --- logfire/_internal/main.py | 26 ++------------------------ tests/test_variable_templates.py | 15 ++++++--------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 749a33857..82b6bedaa 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2653,18 +2653,6 @@ class PromptInputs(BaseModel): return variable - @overload - def template_var( - self, - name: str, - *, - type: type[T], - default: T | ResolveFunction[T], - inputs_type: type[dict[Any, Any]], - description: str | None = None, - ) -> TemplateVariable[T, dict[Any, Any]]: ... - - @overload def template_var( self, name: str, @@ -2673,17 +2661,7 @@ 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], - default: T | ResolveFunction[T], - inputs_type: type[Any], - description: str | None = None, - ) -> TemplateVariable[T, Any]: + ) -> TemplateVariable[T, InputsT]: """Define a managed template variable with integrated rendering. Like ``var()``, but ``get(inputs)`` automatically renders Handlebars ``{{placeholder}}`` @@ -2743,7 +2721,7 @@ class PromptInputs(BaseModel): ensure_handlebars_available() - variable = TemplateVariable[T, Any]( + variable = TemplateVariable[T, InputsT]( name, type=type, default=default, diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index f6dcef07e..e207084bf 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -349,11 +349,15 @@ def test_render_code_default_string(self, config_kwargs: dict[str, Any]): def test_template_var_invalid_default_records_exception(self, config_kwargs: dict[str, Any]): """Rendering failures in code defaults are exposed on the resolution result.""" + + class Inputs(BaseModel): + name: str + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) lf = logfire.configure(**config_kwargs) - var = lf.template_var('prompt', type=str, default='Hello {{#if name}}', inputs_type=dict) + var = lf.template_var('prompt', type=str, default='Hello {{#if name}}', inputs_type=Inputs) - resolved = var.get({'name': 'Alice'}) + resolved = var.get(Inputs(name='Alice')) assert resolved.value == 'Hello {{#if name}}' assert resolved.exception is not None @@ -755,10 +759,3 @@ class MyInputs(BaseModel): assert config.template_inputs_schema['type'] == 'object' assert 'user_name' in config.template_inputs_schema['properties'] assert 'count' in config.template_inputs_schema['properties'] - - def test_dict_inputs(self, config_kwargs: dict[str, Any]): - """Passing a dict as inputs works (via Mapping path).""" - lf = _make_lf(_simple_config('greeting', json.dumps('Hello {{name}}!')), config_kwargs) - var = lf.template_var('greeting', type=str, default='default', inputs_type=dict) - resolved = var.get({'name': 'Alice'}) - assert resolved.value == 'Hello Alice!' From 8a3a1a7485c22e6eb10d7bcf97a22527feb4e98c Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 07:51:06 -0600 Subject: [PATCH 21/40] Drop render() and template_inputs= on var() Removes the two-step `var.get()` + `resolved.render(inputs)` flow and the `template_inputs=` parameter on `var()`. Template rendering is now exclusively done through `template_var()`, which provides single-step typed get(inputs). --- .../configuration-reference.md | 2 +- .../templates-and-composition.md | 23 -- examples/python/variable_composition_demo.py | 50 +-- logfire/_internal/main.py | 38 +- logfire/variables/abstract.py | 64 --- logfire/variables/config.py | 2 +- logfire/variables/variable.py | 14 +- tests/test_push_variables.py | 6 +- tests/test_variable_templates.py | 372 ------------------ 9 files changed, 29 insertions(+), 542 deletions(-) diff --git a/docs/reference/advanced/managed-variables/configuration-reference.md b/docs/reference/advanced/managed-variables/configuration-reference.md index 07f56f13d..2dd451ad0 100644 --- a/docs/reference/advanced/managed-variables/configuration-reference.md +++ b/docs/reference/advanced/managed-variables/configuration-reference.md @@ -15,7 +15,7 @@ | `description` | Human-readable description (optional) | | `aliases` | Alternative names that resolve to this variable (optional, for migrations) | | `example` | JSON-serialized example value, used as starting point when creating versions in the UI (optional) | -| `template_inputs_schema` | JSON Schema for template `{{placeholder}}` inputs (optional, set automatically when template inputs are declared, e.g. via `logfire.template_var()` or `logfire.var(..., template_inputs=...)`) | +| `template_inputs_schema` | JSON Schema for template `{{placeholder}}` inputs (optional, set automatically when template inputs are declared via `logfire.template_var()`) | **LabeledValue** — A label with an inline serialized value: diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md index 3b93d0483..1ff93f1c3 100644 --- a/docs/reference/advanced/managed-variables/templates-and-composition.md +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -134,29 +134,6 @@ with agent_config.get(UserContext(user_name='Alice', tier='premium')) as resolve #> openai:gpt-4o-mini ``` -### Ad-hoc Rendering with `resolved.render()` - -For regular variables (created with `logfire.var()`) that happen to contain template syntax, you can render them after resolution using `resolved.render()`: - -```python skip="true" -from pydantic import BaseModel - -import logfire - - -class Inputs(BaseModel): - user_name: str - - -prompt = logfire.var('prompt', type=str, default='Hello {{user_name}}') - -with prompt.get() as resolved: - rendered = resolved.render(Inputs(user_name='Alice')) - print(rendered) # "Hello Alice" -``` - -This is useful when you want the flexibility to render templates on some code paths but not others. - ### Template Validation When a template variable is pushed to Logfire (via `logfire.variables_push()`), the `template_inputs_schema` is synced alongside the variable's JSON schema. The system validates that all `{{field}}` references in variable values (including values reachable through composition) are compatible with the declared schema. diff --git a/examples/python/variable_composition_demo.py b/examples/python/variable_composition_demo.py index 360938054..9706df2ad 100644 --- a/examples/python/variable_composition_demo.py +++ b/examples/python/variable_composition_demo.py @@ -13,9 +13,8 @@ 6. Handlebars conditionals: {{#if}}, {{else}}, {{/if}} 7. Handlebars iteration: {{#each items}}...{{/each}} 8. TemplateVariable: single-step get(inputs) with automatic rendering - 9. Variable.get() + .render(inputs): two-step manual rendering - 10. Rollout overrides with attribute-based conditions - 11. Composition-time conditionals: @{#if flag}@...@{else}@...@{/if}@ + 9. Rollout overrides with attribute-based conditions + 10. Composition-time conditionals: @{#if flag}@...@{else}@...@{/if}@ """ from __future__ import annotations @@ -308,12 +307,12 @@ class OnboardingInputs(BaseModel): default={'tagline': 'Default tagline', 'color': '#000', 'support_url': 'https://example.com'}, ) -# A Variable with template_inputs — uses two-step get() + render() -system_prompt_var = logfire.var( +# A TemplateVariable — single-step get(inputs) with auto-rendering +system_prompt_var = logfire.template_var( 'system_prompt', type=str, default='Hello {{user.name}}, how can I help with {{topic}}?', - template_inputs=PromptInputs, + inputs_type=PromptInputs, ) # TemplateVariables — single-step get(inputs) with automatic rendering @@ -373,21 +372,20 @@ def section(title: str) -> None: section('2. Nested Composition: system_prompt -> support_footer -> support_email') -# Get the raw (unrendered) system prompt to see composition in action -raw_result = system_prompt_var.get() -print('After composition (before template rendering):') -print(f' label={raw_result.label}, version={raw_result.version}') +nested_inputs = PromptInputs( + user=UserProfile(name='Alice', email='alice@example.com', tier='premium'), + topic='billing', +) +nested_result = system_prompt_var.get(nested_inputs) +print(f'label={nested_result.label}, version={nested_result.version}') print() -# Show the composed value — @{refs}@ are expanded but {{fields}} remain -composed_value = raw_result.value -# Since templates haven't been rendered yet, {{...}} placeholders are literal -print('Composed value ({{placeholders}} still present):') -for line in composed_value.split('\n'): +print('Resolved value (composition + template rendering applied):') +for line in nested_result.value.split('\n'): print(f' {line}') -print(f'\nComposed from {len(raw_result.composed_from)} top-level reference(s):') -for ref in raw_result.composed_from: +print(f'\nComposed from {len(nested_result.composed_from)} top-level reference(s):') +for ref in nested_result.composed_from: print(f' - @{{{ref.name}}}@ -> "{ref.value}"') # Show nested references (e.g. support_footer -> support_email) for nested in ref.composed_from: @@ -461,12 +459,10 @@ def section(title: str) -> None: user = UserProfile(name='Alice Johnson', email='alice@example.com', tier='premium') inputs = PromptInputs(user=user, topic='billing questions') -# Two-step: get() then render() -with system_prompt_var.get() as resolved: - rendered = resolved.render(inputs) +rendered_result = system_prompt_var.get(inputs) print('Rendered system prompt:') -for line in rendered.split('\n'): +for line in rendered_result.value.split('\n'): print(f' {line}') # --------------------------------------------------------------------------- @@ -537,19 +533,14 @@ def section(title: str) -> None: section('9. Explicit Label Selection: Choosing a specific label') -verbose_result = system_prompt_var.get(label='production') -concise_result = system_prompt_var.get(label='concise') +verbose_result = system_prompt_var.get(inputs, label='production') +concise_result = system_prompt_var.get(inputs, label='concise') print('Production prompt (first 80 chars):') print(f' "{verbose_result.value[:80]}..."') print('\nConcise prompt:') print(f' "{concise_result.value}"') -# Now render the concise one with template inputs -rendered_concise = concise_result.render(inputs) -print('\nConcise prompt rendered:') -print(f' "{rendered_concise}"') - # --------------------------------------------------------------------------- # 14. Demo: Composition-time conditionals (@{#if}@ at composition time) # --------------------------------------------------------------------------- @@ -578,7 +569,7 @@ def section(title: str) -> None: section('11. Context Manager: Baggage propagation for observability') -with system_prompt_var.get() as resolved: +with system_prompt_var.get(inputs) as resolved: print('Inside context manager:') print(f' Variable: {resolved.name}') print(f' Label: {resolved.label}') @@ -605,7 +596,6 @@ def section(title: str) -> None: print(' - @{#if flag}@...@{else}@...@{/if}@ conditionals (composition-time)') print(' - Structured variables: templates render inside dict string values') print(' - TemplateVariable: single-step get(inputs) with auto-rendering') -print(' - Variable + render(): two-step manual rendering') print(' - Rollout overrides: attribute-based label selection') print(' - Explicit label selection: get(label="concise")') print(' - Context manager: baggage propagation for observability') diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 82b6bedaa..a53cdcae6 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2524,7 +2524,6 @@ def var( *, default: T, description: str | None = None, - template_inputs: type[Any] | None = None, ) -> Variable[T]: ... @overload @@ -2535,7 +2534,6 @@ def var( type: type[T], default: T | ResolveFunction[T], description: str | None = None, - template_inputs: type[Any] | None = None, ) -> Variable[T]: ... def var( @@ -2545,7 +2543,6 @@ def var( type: type[T] | None = None, default: T | ResolveFunction[T], description: str | None = None, - template_inputs: type[Any] | None = None, ) -> Variable[T]: """Define a managed variable. @@ -2570,30 +2567,8 @@ def var( ... ``` - Template rendering example: - - ```py skip-run="true" skip-reason="requires-pydantic-handlebars" - import logfire - from pydantic import BaseModel - - logfire.configure() - - - class PromptInputs(BaseModel): - user_name: str - is_premium: bool = False - - - prompt = logfire.var( - 'system_prompt', - type=str, - default='Hello {{user_name}}', - template_inputs=PromptInputs, - ) - - with prompt.get() as resolved: - rendered = resolved.render(PromptInputs(user_name='Alice')) - ``` + For variables with Handlebars ``{{placeholder}}`` templates that need runtime inputs, + use [`template_var()`][logfire.Logfire.template_var] instead. Args: name: Unique identifier for the variable. Must match the name configured in the @@ -2606,9 +2581,6 @@ class PromptInputs(BaseModel): Can also be a callable with `targeting_key` and `attributes` parameters (requires `type` to be set explicitly). description: Optional human-readable description of what the variable controls. - template_inputs: Optional Pydantic model type describing the expected template inputs - for Handlebars ``{{placeholder}}`` rendering. When set, the JSON Schema of this - model is pushed to the server and used by the UI for autocomplete and preview. """ from logfire.variables.variable import Variable, is_resolve_function @@ -2636,18 +2608,12 @@ class PromptInputs(BaseModel): f"A variable with name '{name}' has already been registered. Each variable must have a unique name." ) - if template_inputs is not None: - from logfire.variables._handlebars import ensure_handlebars_available - - ensure_handlebars_available() - variable = Variable[T]( name, default=default, type=tp, logfire_instance=self, description=description, - template_inputs=template_inputs, ) self._variables[name] = variable diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index 2ab0b19c0..79792bb01 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -120,17 +120,6 @@ class ResolvedVariable(Generic[T_co]): Each entry is a ComposedReference for a referenced variable, including its label, version, reason, and any nested composed_from entries. """ - _serialized_value: str | None = None - """Internal: the post-composition, pre-deserialization JSON string. - - Used by render() to apply Handlebars template rendering on the serialized - form before deserializing to the variable's type. - """ - _deserializer: Callable[[str], Any] | None = None - """Internal: function to deserialize a JSON string to the variable's type. - - Returns the deserialized value or an Exception on failure. - """ def __post_init__(self): self._exit_stack = ExitStack() @@ -149,59 +138,6 @@ def __enter__(self): def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: self._exit_stack.__exit__(exc_type, exc_val, exc_tb) - def render(self, inputs: Any = None) -> T_co: - """Render Handlebars templates in this variable's value. - - Operates on the serialized JSON (post-composition), renders all ``{{placeholder}}`` - expressions using the provided inputs, then deserializes to the variable's type. - - For ``str`` variables, this renders the template and returns a string. - For structured variables (e.g., Pydantic models), all string values containing - ``{{placeholders}}`` are rendered while non-string fields pass through unchanged. - - Args: - inputs: Template context values. Can be a Pydantic ``BaseModel`` (uses ``model_dump()``), - a ``dict``, or any ``Mapping``. If ``None``, renders with an empty context. - - Returns: - The rendered value, typed as the variable's type ``T_co``. - - Raises: - ValueError: If no serialized value is available for rendering. - ImportError: If ``pydantic-handlebars`` is not installed. - - Example: - ```python skip="true" - from pydantic import BaseModel - - - class Inputs(BaseModel): - user_name: str - - - prompt = logfire.var('prompt', type=str, default='Hello {{user_name}}') - with prompt.get() as resolved: - rendered = resolved.render(Inputs(user_name='Alice')) - # rendered == "Hello Alice" - ``` - """ - if self._serialized_value is None: - raise ValueError( - 'Cannot render template: no serialized value available. ' - 'This can happen if the variable resolved to a context override ' - 'or if serialization of the default value failed.' - ) - if self._deserializer is None: - raise ValueError('Cannot render template: no deserializer available.') - - rendered_json = render_serialized_string(self._serialized_value, inputs) - - # Deserialize the rendered JSON - result = self._deserializer(rendered_json) - if isinstance(result, Exception): - raise result - return result - def _inputs_to_context(inputs: Any) -> dict[str, Any]: """Convert inputs (Pydantic model, dict, or Mapping) to a template context dict. diff --git a/logfire/variables/config.py b/logfire/variables/config.py index b07827d03..e88b7e516 100644 --- a/logfire/variables/config.py +++ b/logfire/variables/config.py @@ -316,7 +316,7 @@ class VariableConfig(BaseModel): """JSON Schema describing the expected template inputs for Handlebars rendering. When set, the variable's values can contain {{placeholder}} Handlebars syntax. - The schema is derived from a Pydantic model passed as `template_inputs` to `logfire.var()`. + The schema is derived from the `inputs_type` model passed to `logfire.template_var()`. """ # NOTE: Context-based targeting_key can be set via targeting_context() from logfire.variables. # TODO(DavidM): Consider adding remotely-managed targeting_key_attribute for automatic attribute-based targeting. diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index e2b650e0f..8eeaddc57 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -176,8 +176,8 @@ def __init__( default: Default value to use when no configuration is found, or a function that computes the default based on targeting_key and attributes. description: Optional human-readable description of what this variable controls. - template_inputs: Optional Pydantic model type describing the expected template inputs - for Handlebars rendering. When set, values can contain ``{{placeholder}}`` syntax. + template_inputs: Internal hook used by ``TemplateVariable`` to declare the expected + template inputs type. Not exposed via the public ``logfire.var()`` API. logfire_instance: The Logfire instance this variable is associated with. Used to determine config, etc. """ self.name = name @@ -402,8 +402,6 @@ def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str] version=serialized_result.version, _reason='resolved', composed_from=composed, - _serialized_value=serialized_value, - _deserializer=self._deserialize, ) def _get_default( @@ -540,14 +538,6 @@ def _get_result_and_record_span( ) ) result = self._resolve(targeting_key, merged_attributes, span, label, render_fn=render_fn) - # Ensure rendering support is always available - if result._deserializer is None: # pyright: ignore[reportPrivateUsage] - result._deserializer = self._deserialize # pyright: ignore[reportPrivateUsage] - if result._serialized_value is None and result.value is not None: # pyright: ignore[reportPrivateUsage] - try: - result._serialized_value = self.type_adapter.dump_json(result.value).decode('utf-8') # pyright: ignore[reportPrivateUsage] - except (ValueError, TypeError, RuntimeError): - pass if span is not None: # Serialize value safely for OTel span attributes, which only support primitives. # Try to JSON serialize the value; if that fails, fall back to string representation. diff --git a/tests/test_push_variables.py b/tests/test_push_variables.py index b1e82ae61..972559528 100644 --- a/tests/test_push_variables.py +++ b/tests/test_push_variables.py @@ -27,7 +27,7 @@ ) from logfire.variables.config import LabeledValue, LabelRef, LatestVersion, Rollout, VariableConfig, VariablesConfig from logfire.variables.local import LocalVariableProvider -from logfire.variables.variable import Variable +from logfire.variables.variable import TemplateVariable, Variable @dataclass @@ -231,11 +231,11 @@ def test_compute_diff_template_inputs_schema_change(mock_logfire_instance: MockL class NewInputs(BaseModel): user_name: str - var = Variable[str]( + var = TemplateVariable[str, NewInputs]( name='prompt', default='Hello {{user_name}}', type=str, - template_inputs=NewInputs, + inputs_type=NewInputs, logfire_instance=mock_logfire_instance, # type: ignore ) server_config = VariablesConfig( diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index e207084bf..47c82f93c 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -20,7 +20,6 @@ import logfire from logfire._internal.config import LocalVariablesOptions from logfire.variables import _handlebars -from logfire.variables.abstract import ResolvedVariable from logfire.variables.config import ( LabeledValue, Rollout, @@ -79,13 +78,6 @@ def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): assert logfire.var assert logfire.var('plain_var', type=str, default='Hello') - try: - logfire.var('templated_var', type=str, default='Hello {{name}}', template_inputs=dict) - except ImportError as exc: - assert 'pydantic-handlebars' in str(exc) - else: - raise AssertionError('template_inputs should require pydantic-handlebars') - try: logfire.template_var('template_var', type=str, default='Hello {{name}}', inputs_type=dict) except ImportError as exc: @@ -132,314 +124,6 @@ def blocked_import(name: str, *args: Any, **kwargs: Any) -> Any: _handlebars.check_template_compatibility(['Hello {{name}}'], schema) -# ============================================================================= -# ResolvedVariable.render() tests -# ============================================================================= - - -def test_render_requires_serialized_value(): - """render() fails clearly if the resolution did not preserve serialized JSON.""" - resolved = ResolvedVariable(name='prompt', value='Hello', _reason='context_override', _deserializer=lambda x: x) - - with pytest.raises(ValueError, match='no serialized value available'): - resolved.render() - - -def test_render_requires_deserializer(): - """render() fails clearly if it cannot deserialize the rendered JSON.""" - resolved = ResolvedVariable(name='prompt', value='Hello', _reason='resolved', _serialized_value='"Hello"') - - with pytest.raises(ValueError, match='no deserializer available'): - resolved.render() - - -@requires_handlebars -class TestRenderSimpleString: - """Test rendering string variables with Handlebars templates.""" - - def test_simple_placeholder(self, config_kwargs: dict[str, Any]): - """Simple {{placeholder}} replacement in a string variable.""" - lf = _make_lf(_simple_config('greeting', json.dumps('Hello {{name}}!')), config_kwargs) - var = lf.var('greeting', type=str, default='default') - resolved = var.get() - assert resolved.value == 'Hello {{name}}!' - rendered = resolved.render({'name': 'Alice'}) - assert rendered == 'Hello Alice!' - - def test_multiple_placeholders(self, config_kwargs: dict[str, Any]): - """Multiple {{placeholders}} in a single string.""" - lf = _make_lf( - _simple_config('prompt', json.dumps('Hello {{user_name}}, welcome to {{company}}!')), - config_kwargs, - ) - var = lf.var('prompt', type=str, default='default') - resolved = var.get() - rendered = resolved.render({'user_name': 'Bob', 'company': 'Acme'}) - assert rendered == 'Hello Bob, welcome to Acme!' - - def test_conditional_template(self, config_kwargs: dict[str, Any]): - """Handlebars #if conditional in a string variable.""" - lf = _make_lf( - _simple_config('prompt', json.dumps('Hello {{name}}.{{#if is_premium}} Premium member!{{/if}}')), - config_kwargs, - ) - var = lf.var('prompt', type=str, default='default') - resolved = var.get() - - rendered_premium = resolved.render({'name': 'Alice', 'is_premium': True}) - assert rendered_premium == 'Hello Alice. Premium member!' - - rendered_basic = resolved.render({'name': 'Bob', 'is_premium': False}) - assert rendered_basic == 'Hello Bob.' - - def test_each_helper(self, config_kwargs: dict[str, Any]): - """Handlebars #each iteration in a string variable.""" - lf = _make_lf( - _simple_config( - 'prompt', - json.dumps('Items: {{#each items}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}'), - ), - config_kwargs, - ) - var = lf.var('prompt', type=str, default='default') - resolved = var.get() - rendered = resolved.render({'items': ['apple', 'banana', 'cherry']}) - assert rendered == 'Items: apple, banana, cherry' - - def test_no_html_escaping(self, config_kwargs: dict[str, Any]): - """String values should NOT be HTML-escaped (not an HTML context).""" - lf = _make_lf(_simple_config('prompt', json.dumps('Value: {{value}}')), config_kwargs) - var = lf.var('prompt', type=str, default='default') - resolved = var.get() - # These characters would normally be HTML-escaped by Handlebars - rendered = resolved.render({'value': ''}) - assert rendered == 'Value: ' - - def test_empty_context(self, config_kwargs: dict[str, Any]): - """Rendering with no inputs leaves placeholders as empty strings.""" - lf = _make_lf(_simple_config('prompt', json.dumps('Hello {{name}}!')), config_kwargs) - var = lf.var('prompt', type=str, default='default') - resolved = var.get() - rendered = resolved.render() - assert rendered == 'Hello !' - - def test_no_templates(self, config_kwargs: dict[str, Any]): - """Rendering a value with no {{placeholders}} returns the value unchanged.""" - lf = _make_lf(_simple_config('prompt', json.dumps('Hello world!')), config_kwargs) - var = lf.var('prompt', type=str, default='default') - resolved = var.get() - rendered = resolved.render({'name': 'unused'}) - assert rendered == 'Hello world!' - - -@requires_handlebars -class TestRenderWithPydanticInputs: - """Test rendering with Pydantic model inputs.""" - - def test_pydantic_model_inputs(self, config_kwargs: dict[str, Any]): - """Rendering with a Pydantic model as inputs.""" - - class PromptInputs(BaseModel): - user_name: str - is_premium: bool = False - - lf = _make_lf( - _simple_config('prompt', json.dumps('Welcome {{user_name}}!{{#if is_premium}} VIP{{/if}}')), - config_kwargs, - ) - var = lf.var('prompt', type=str, default='default', template_inputs=PromptInputs) - resolved = var.get() - rendered = resolved.render(PromptInputs(user_name='Alice', is_premium=True)) - assert rendered == 'Welcome Alice! VIP' - - def test_nested_model_inputs(self, config_kwargs: dict[str, Any]): - """Rendering with nested Pydantic model fields using dot notation.""" - - class Address(BaseModel): - city: str - country: str - - class UserInfo(BaseModel): - name: str - address: Address - - lf = _make_lf( - _simple_config('prompt', json.dumps('User {{name}} from {{address.city}}, {{address.country}}')), - config_kwargs, - ) - var = lf.var('prompt', type=str, default='default', template_inputs=UserInfo) - resolved = var.get() - rendered = resolved.render(UserInfo(name='Alice', address=Address(city='London', country='UK'))) - assert rendered == 'User Alice from London, UK' - - -@requires_handlebars -class TestRenderStructuredType: - """Test rendering structured types (Pydantic models) where string fields contain templates.""" - - def test_model_with_template_fields(self, config_kwargs: dict[str, Any]): - """Rendering a Pydantic model where string fields contain {{placeholders}}.""" - - class PromptConfig(BaseModel): - system_prompt: str - temperature: float - max_tokens: int - - serialized = json.dumps( - { - 'system_prompt': 'Hello {{user_name}}, how can I help?', - 'temperature': 0.7, - 'max_tokens': 100, - } - ) - - lf = _make_lf(_simple_config('config', serialized), config_kwargs) - var = lf.var( - 'config', - type=PromptConfig, - default=PromptConfig(system_prompt='default', temperature=0.5, max_tokens=50), - ) - resolved = var.get() - rendered = resolved.render({'user_name': 'Alice'}) - assert isinstance(rendered, PromptConfig) - assert rendered.system_prompt == 'Hello Alice, how can I help?' - assert rendered.temperature == 0.7 - assert rendered.max_tokens == 100 - - def test_model_with_template_list_fields(self, config_kwargs: dict[str, Any]): - """Rendering walks lists and leaves non-string values unchanged.""" - - class PromptConfig(BaseModel): - messages: list[str] - count: int - - serialized = json.dumps( - { - 'messages': ['Hello {{user_name}}', 'static'], - 'count': 2, - } - ) - - lf = _make_lf(_simple_config('config', serialized), config_kwargs) - var = lf.var( - 'config', - type=PromptConfig, - default=PromptConfig(messages=['default'], count=1), - ) - resolved = var.get() - rendered = resolved.render({'user_name': 'Alice'}) - assert rendered == PromptConfig(messages=['Hello Alice', 'static'], count=2) - - -@requires_handlebars -class TestRenderCodeDefault: - """Test rendering when using code default values (no remote configuration).""" - - def test_render_code_default_string(self, config_kwargs: dict[str, Any]): - """Rendering a code default string that contains templates.""" - config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) - lf = logfire.configure(**config_kwargs) - var = lf.var('prompt', type=str, default='Hello {{name}}!') - resolved = var.get() - # Value is the code default - assert resolved.value == 'Hello {{name}}!' - # Rendering should still work - rendered = resolved.render({'name': 'Alice'}) - assert rendered == 'Hello Alice!' - - def test_template_var_invalid_default_records_exception(self, config_kwargs: dict[str, Any]): - """Rendering failures in code defaults are exposed on the resolution result.""" - - class Inputs(BaseModel): - name: str - - config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) - lf = logfire.configure(**config_kwargs) - var = lf.template_var('prompt', type=str, default='Hello {{#if name}}', inputs_type=Inputs) - - resolved = var.get(Inputs(name='Alice')) - - assert resolved.value == 'Hello {{#if name}}' - assert resolved.exception is not None - assert resolved._reason == 'other_error' - - def test_render_default_raises_rendered_validation_error(self, config_kwargs: dict[str, Any]): - """_render_default raises validation errors from the rendered JSON.""" - config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) - lf = logfire.configure(**config_kwargs) - var = lf.var('count', type=int, default=0) - - with pytest.raises(ValueError): - var._render_default(0, lambda _: '"not an int"') - - -@requires_handlebars -class TestRenderErrors: - """Test error handling in render().""" - - def test_render_invalid_inputs_type(self, config_kwargs: dict[str, Any]): - """Passing a non-dict/non-model to render() raises TypeError.""" - lf = _make_lf(_simple_config('prompt', json.dumps('Hello {{name}}')), config_kwargs) - var = lf.var('prompt', type=str, default='default') - resolved = var.get() - with pytest.raises(TypeError, match='Expected a dict, Mapping, or Pydantic model'): - resolved.render(42) - - def test_render_raises_deserializer_exception(self): - """render() raises validation/deserialization errors after template rendering.""" - resolved = ResolvedVariable( - name='prompt', - value='Hello {{name}}', - _reason='resolved', - _serialized_value=json.dumps('Hello {{name}}'), - _deserializer=lambda _: ValueError('bad rendered value'), - ) - - with pytest.raises(ValueError, match='bad rendered value'): - resolved.render({'name': 'Alice'}) - - -# ============================================================================= -# template_inputs parameter tests -# ============================================================================= - - -class TestTemplateInputsParam: - """Test the template_inputs parameter on logfire.var().""" - - def test_template_inputs_schema_in_config(self, config_kwargs: dict[str, Any]): - """template_inputs generates JSON Schema in the variable config.""" - - class MyInputs(BaseModel): - user_name: str - count: int = 5 - - lf = logfire.configure(**config_kwargs) - var = lf.var('prompt', type=str, default='Hello {{user_name}}', template_inputs=MyInputs) - config = var.to_config() - assert config.template_inputs_schema is not None - assert config.template_inputs_schema['type'] == 'object' - assert 'user_name' in config.template_inputs_schema['properties'] - assert 'count' in config.template_inputs_schema['properties'] - - def test_no_template_inputs(self, config_kwargs: dict[str, Any]): - """Without template_inputs, schema is None.""" - lf = logfire.configure(**config_kwargs) - var = lf.var('prompt', type=str, default='Hello') - config = var.to_config() - assert config.template_inputs_schema is None - - def test_template_inputs_stored_on_variable(self, config_kwargs: dict[str, Any]): - """template_inputs_type is stored on the Variable instance.""" - - class MyInputs(BaseModel): - name: str - - lf = logfire.configure(**config_kwargs) - var = lf.var('prompt', type=str, default='Hello', template_inputs=MyInputs) - assert var.template_inputs_type is MyInputs - - # ============================================================================= # VariableConfig.template_inputs_schema tests # ============================================================================= @@ -473,50 +157,6 @@ def test_none_by_default(self): assert config.template_inputs_schema is None -# ============================================================================= -# Composition + rendering pipeline tests -# ============================================================================= - - -@requires_handlebars -class TestCompositionThenRendering: - """Test the full pipeline: resolve → compose → render.""" - - def test_composition_then_render(self, config_kwargs: dict[str, Any]): - """@{references}@ are expanded first, then {{placeholders}} are rendered.""" - variables_config = VariablesConfig( - variables={ - 'snippet': VariableConfig( - name='snippet', - labels={ - 'production': LabeledValue(version=1, serialized_value=json.dumps('Welcome to {{company}}!')), - }, - rollout=Rollout(labels={'production': 1.0}), - overrides=[], - ), - 'full_prompt': VariableConfig( - name='full_prompt', - labels={ - 'production': LabeledValue( - version=1, - serialized_value=json.dumps('Hello {{user_name}}. @{snippet}@'), - ), - }, - rollout=Rollout(labels={'production': 1.0}), - overrides=[], - ), - }, - ) - lf = _make_lf(variables_config, config_kwargs) - var = lf.var('full_prompt', type=str, default='default') - resolved = var.get() - # After composition, @{snippet}@ is expanded but {{placeholders}} remain - assert resolved.value == 'Hello {{user_name}}. Welcome to {{company}}!' - # After rendering, all {{placeholders}} are filled - rendered = resolved.render({'user_name': 'Alice', 'company': 'Acme Corp'}) - assert rendered == 'Hello Alice. Welcome to Acme Corp!' - - # ============================================================================= # TemplateVariable tests # ============================================================================= @@ -562,18 +202,6 @@ class Inputs(BaseModel): assert resolved.exception is not None assert resolved._reason == 'other_error' - def test_unserializable_override_keeps_get_usable(self, config_kwargs: dict[str, Any]): - """get() tolerates values that cannot be serialized for later render() support.""" - marker = object() - lf = logfire.configure(**config_kwargs) - var = lf.var('opaque', type=object, default=object()) - - with var.override(marker): - resolved = var.get() - - assert resolved.value is marker - assert resolved._serialized_value is None - def test_composition_then_render(self, config_kwargs: dict[str, Any]): """@{refs}@ expanded first, then {{}} rendered with inputs.""" From 3b2cb6b7a381b229c1a5c7eb8f7665e45b47aca4 Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 08:05:56 -0600 Subject: [PATCH 22/40] Promote _reason to public, expose code_default at top level Renames `ResolvedVariable._reason` to `ResolvedVariable.reason` and extracts a shared `ResolutionReason` Literal in composition.py used by both `ResolvedVariable.reason` and `ComposedReference.reason`. The top-level reason is now `'code_default'` whenever the provider has no value and the code default is used, mirroring the existing child-level signal. --- logfire/variables/abstract.py | 27 ++++----- logfire/variables/composition.py | 31 +++++++++-- logfire/variables/config.py | 4 +- logfire/variables/remote.py | 2 +- logfire/variables/variable.py | 57 +++++++++---------- tests/test_variable_composition.py | 16 +++++- tests/test_variable_templates.py | 2 +- tests/test_variables.py | 88 +++++++++++++++--------------- 8 files changed, 125 insertions(+), 102 deletions(-) diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index 79792bb01..282075273 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -9,13 +9,14 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeVar +from logfire.variables.composition import ComposedReference, ResolutionReason + SyncMode = Literal['merge', 'replace'] if TYPE_CHECKING: from pydantic import TypeAdapter import logfire - from logfire.variables.composition import ComposedReference from logfire.variables.config import VariableConfig, VariablesConfig, VariableTypeConfig from logfire.variables.variable import _BaseVariable @@ -96,18 +97,10 @@ class ResolvedVariable(Generic[T_co]): """The name of the variable.""" value: T_co """The resolved value of the variable.""" - _reason: Literal[ - 'resolved', - 'context_override', - 'missing_config', - 'unrecognized_variable', - 'validation_error', - 'other_error', - 'no_provider', - ] # we might eventually make this public, but I didn't want to yet - """Internal field indicating how the value was resolved.""" - # Note: I had to put _reason before fields with defaults due to lack of kw_only - # Note: When we drop support for python 3.9, move _reason to the end + reason: ResolutionReason + """How the variable was resolved (see ``ResolutionReason`` for possible values).""" + # Note: ``reason`` is declared before fields with defaults because we don't use kw_only=True + # on Python<3.10; move it to the end when 3.9 support is dropped. label: str | None = None """The name of the selected label, if any.""" version: int | None = None @@ -882,11 +875,11 @@ def get_serialized_value_for_label( """ config = self.get_variable_config(variable_name) if config is None: - return ResolvedVariable(name=variable_name, value=None, _reason='unrecognized_variable') + return ResolvedVariable(name=variable_name, value=None, reason='unrecognized_variable') labeled_value = config.labels.get(label) if labeled_value is None: - return ResolvedVariable(name=variable_name, value=None, _reason='resolved') + return ResolvedVariable(name=variable_name, value=None, reason='resolved') serialized, version = config.follow_ref(labeled_value) return ResolvedVariable( @@ -894,7 +887,7 @@ def get_serialized_value_for_label( value=serialized, label=label, version=version, - _reason='resolved', + reason='resolved', ) def refresh(self, force: bool = False): @@ -1636,7 +1629,7 @@ def get_serialized_value( Returns: A ResolvedVariable with value=None. """ - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') def get_variable_config(self, name: str) -> VariableConfig | None: """Return None for all variable lookups. diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index 50a67a29b..73d731b39 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -17,10 +17,11 @@ import json import re from dataclasses import dataclass, field -from typing import Any, Callable, Optional, Tuple # noqa: UP035 +from typing import Any, Callable, Literal, Optional, Tuple # noqa: UP035 __all__ = ( 'MAX_COMPOSITION_DEPTH', + 'ResolutionReason', 'VariableCompositionError', 'VariableCompositionCycleError', 'ComposedReference', @@ -29,6 +30,28 @@ 'has_references', ) +ResolutionReason = Literal[ + 'resolved', + 'context_override', + 'missing_config', + 'unrecognized_variable', + 'validation_error', + 'other_error', + 'no_provider', + 'code_default', +] +"""Why a variable (or a composed reference) resolved to its final value. + +- ``resolved``: provider returned a value that was used as-is. +- ``context_override``: a value set via ``Variable.override(...)`` was used. +- ``missing_config``: the variable exists on the provider but the targeting/rollout produced no value. +- ``unrecognized_variable``: the provider has no entry for the variable. +- ``validation_error``: the serialized value failed deserialization. +- ``other_error``: composition, rendering or other error during resolution. +- ``no_provider``: no provider is configured. +- ``code_default``: the variable's code-default was used because the provider had no value. +""" + # Matches unescaped @{ (not preceded by \). # In JSON-serialized strings, a real backslash is \\, so \\@{ is an escaped ref. _HAS_REFERENCE = re.compile(r'(? (serialized_value, label, version, reason) -ResolveFn = Callable[[str], Tuple[Optional[str], Optional[str], Optional[int], str]] # noqa: UP006 +ResolveFn = Callable[[str], Tuple[Optional[str], Optional[str], Optional[int], ResolutionReason]] # noqa: UP006 def has_references(serialized_value: str) -> bool: diff --git a/logfire/variables/config.py b/logfire/variables/config.py index e88b7e516..f3fcdcce0 100644 --- a/logfire/variables/config.py +++ b/logfire/variables/config.py @@ -513,7 +513,7 @@ def resolve_serialized_value( """ variable_config = self._get_variable_config(name) if variable_config is None: - return ResolvedVariable(name=name, value=None, _reason='unrecognized_variable') + return ResolvedVariable(name=name, value=None, reason='unrecognized_variable') serialized_value, selected_label, version = variable_config.resolve_value( targeting_key, attributes, label=label @@ -523,7 +523,7 @@ def resolve_serialized_value( value=serialized_value, label=selected_label, version=version, - _reason='resolved', + reason='resolved', ) def _get_variable_config(self, name: VariableName) -> VariableConfig | None: diff --git a/logfire/variables/remote.py b/logfire/variables/remote.py index 900c8abfb..b1b303431 100644 --- a/logfire/variables/remote.py +++ b/logfire/variables/remote.py @@ -344,7 +344,7 @@ def get_serialized_value( self.refresh() if self._config is None: - return ResolvedVariable(name=variable_name, value=None, _reason='missing_config') + return ResolvedVariable(name=variable_name, value=None, reason='missing_config') return self._config.resolve_serialized_value(variable_name, targeting_key, attributes) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 8eeaddc57..c21d9e4a3 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Generator, Mapping, Sequence from contextlib import ExitStack, contextmanager from contextvars import ContextVar -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from importlib.util import find_spec from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar @@ -16,6 +16,7 @@ from logfire.variables._handlebars import HandlebarsError from logfire.variables.composition import ( ComposedReference, + ResolutionReason, VariableCompositionError, expand_references, has_references, @@ -249,7 +250,7 @@ def _resolve( # that still gets rendered with inputs. if render_fn is not None: context_value = self._render_default(context_value, render_fn) - return ResolvedVariable(name=self.name, value=context_value, _reason='context_override') + return ResolvedVariable(name=self.name, value=context_value, reason='context_override') provider = self.logfire_instance.config.get_variable_provider() @@ -266,7 +267,6 @@ def _resolve( if serialized_result.value is None: default_result = self._resolve_serialized_default( - serialized_result, provider, targeting_key, attributes, @@ -275,7 +275,14 @@ def _resolve( ) if default_result is not None: return default_result - return _with_value(serialized_result, self._get_default(targeting_key, attributes)) + # Provider had no value; surface that the code default was used. + return ResolvedVariable( + name=self.name, + value=self._get_default(targeting_key, attributes), + label=serialized_result.label, + version=serialized_result.version, + reason='code_default', + ) return self._expand_and_deserialize( serialized_result, provider, targeting_key, attributes, span, render_fn=render_fn @@ -286,7 +293,7 @@ def _resolve( span.set_attribute('invalid_serialized_label', serialized_result.label) span.set_attribute('invalid_serialized_value', serialized_result.value) default = self._get_default(targeting_key, attributes) - return ResolvedVariable(name=self.name, value=default, exception=e, _reason='other_error') + return ResolvedVariable(name=self.name, value=default, exception=e, reason='other_error') def _render_default(self, default: Any, render_fn: Callable[[str], str]) -> T_co: """Serialize the default value, apply render_fn, then deserialize back.""" @@ -319,7 +326,9 @@ def _expand_and_deserialize( # Expand @{references}@ if any are present if has_references(serialized_value): - def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str]: + def resolve_ref( + ref_name: str, + ) -> tuple[str | None, str | None, int | None, ResolutionReason]: ref_resolved = provider.get_serialized_value(ref_name, targeting_key, attributes) if ref_resolved.value is None and (ref_variable := self._variable_registry.get(ref_name)) is not None: ref_default = ref_variable._get_serialized_default(targeting_key, attributes) @@ -329,7 +338,7 @@ def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str] ref_resolved.value, ref_resolved.label, ref_resolved.version, - ref_resolved._reason, # pyright: ignore[reportPrivateUsage] + ref_resolved.reason, ) try: @@ -344,7 +353,7 @@ def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str] name=self.name, value=default, exception=VariableCompositionError(composition_error), - _reason='other_error', + reason='other_error', label=serialized_result.label, version=serialized_result.version, composed_from=composed, @@ -355,7 +364,7 @@ def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str] name=self.name, value=default, exception=e, - _reason='other_error', + reason='other_error', label=serialized_result.label, version=serialized_result.version, composed_from=composed, @@ -371,7 +380,7 @@ def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str] name=self.name, value=default, exception=e, - _reason='other_error', + reason='other_error', label=serialized_result.label, version=serialized_result.version, composed_from=composed, @@ -389,7 +398,7 @@ def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str] name=self.name, value=default, exception=value_or_exc, - _reason=reason, + reason=reason, label=serialized_result.label, version=serialized_result.version, composed_from=composed, @@ -400,7 +409,7 @@ def resolve_ref(ref_name: str) -> tuple[str | None, str | None, int | None, str] value=value_or_exc, label=serialized_result.label, version=serialized_result.version, - _reason='resolved', + reason='resolved', composed_from=composed, ) @@ -424,7 +433,6 @@ def _get_serialized_default( def _resolve_serialized_default( self, - serialized_result: ResolvedVariable[str | None], provider: VariableProvider, targeting_key: str | None, attributes: Mapping[str, Any] | None, @@ -439,15 +447,17 @@ def _resolve_serialized_default( return None result = self._expand_and_deserialize( - ResolvedVariable(name=self.name, value=serialized_default, _reason='missing_config'), + ResolvedVariable(name=self.name, value=serialized_default, reason='missing_config'), provider, targeting_key, attributes, span, render_fn=render_fn, ) - if result._reason == 'resolved': # pyright: ignore[reportPrivateUsage] - result._reason = serialized_result._reason # pyright: ignore[reportPrivateUsage] + if result.reason == 'resolved': + # The expansion succeeded against the code default; flag the top-level + # reason as 'code_default' so callers can distinguish from a provider hit. + result.reason = 'code_default' return result def _get_merged_attributes(self, attributes: Mapping[str, Any] | None = None) -> Mapping[str, Any]: @@ -550,7 +560,7 @@ def _get_result_and_record_span( 'value': serialized_value, 'label': result.label, 'version': result.version, - 'reason': result._reason, # pyright: ignore[reportPrivateUsage] + 'reason': result.reason, } if result.composed_from: attrs['composed_from'] = json.dumps( @@ -677,19 +687,6 @@ def _render_fn(serialized_json: str) -> str: return self._get_result_and_record_span(targeting_key, attributes, label, render_fn=_render_fn) -def _with_value(details: ResolvedVariable[Any], new_value: T_co) -> ResolvedVariable[T_co]: - """Return a copy of the provided resolution details, just with a different value. - - Args: - details: Existing resolution details to modify. - new_value: The new value to use. - - Returns: - A new ResolvedVariable with the given value. - """ - return replace(details, value=new_value) - - def _first_composition_error(composed: list[ComposedReference]) -> str | None: """Return the first nested composition error, if any.""" for ref in composed: diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 20b69617c..13a2beb07 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -568,7 +568,7 @@ def raise_composition_error(*args: Any, **kwargs: Any) -> Any: assert result.value == 'fallback' assert result.exception is not None - assert result._reason == 'other_error' + assert result.reason == 'other_error' def test_nested_reference(self, config_kwargs: dict[str, Any]): """A→B→C chain resolves fully.""" @@ -600,7 +600,7 @@ def test_cycle_falls_back_gracefully(self, config_kwargs: dict[str, Any]): result = var.get() assert result.value == 'fallback' assert isinstance(result.exception, VariableCompositionError) - assert result._reason == 'other_error' + assert result.reason == 'other_error' assert len(result.composed_from) == 1 assert result.composed_from[0].composed_from[0].error == 'Circular reference detected: a -> b -> a' @@ -698,7 +698,7 @@ def test_no_composition_for_context_override(self, config_kwargs: dict[str, Any] result = var.get() assert result.value == 'override_value' assert result.composed_from == [] - assert result._reason == 'context_override' + assert result.reason == 'context_override' def test_composition_with_explicit_label(self, config_kwargs: dict[str, Any]): """Composition works when using explicit label parameter.""" @@ -781,6 +781,16 @@ def test_code_default_composition_when_provider_has_no_value( assert result.composed_from[0].name == 'greeting' assert result.composed_from[0].reason == 'code_default' + def test_top_level_reason_is_code_default_when_provider_has_no_value(self, config_kwargs: dict[str, Any]): + """When the provider has no value for the parent, the resolution reason is 'code_default'.""" + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='parent', default='hello', type=str) + result = var.get() + assert result.value == 'hello' + assert result.reason == 'code_default' + def test_reference_falls_back_to_registered_code_default(self, config_kwargs: dict[str, Any]): """A composed reference uses a registered variable's default when the provider has no selected value.""" variables_config = VariablesConfig( diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index 47c82f93c..e6c4811b8 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -200,7 +200,7 @@ class Inputs(BaseModel): assert resolved.value == 'fallback' assert resolved.exception is not None - assert resolved._reason == 'other_error' + assert resolved.reason == 'other_error' def test_composition_then_render(self, config_kwargs: dict[str, Any]): """@{refs}@ expanded first, then {{}} rendered with inputs.""" diff --git a/tests/test_variables.py b/tests/test_variables.py index ecd9b1296..a86bf2224 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -633,7 +633,7 @@ def test_returns_none(self): provider = NoOpVariableProvider() result = provider.get_serialized_value('any_variable') assert result.value is None - assert result._reason == 'no_provider' + assert result.reason == 'no_provider' def test_with_targeting_key_and_attributes(self): provider = NoOpVariableProvider() @@ -661,19 +661,19 @@ def test_shutdown_does_nothing(self): class TestResolvedVariable: def test_basic_details(self): - details = ResolvedVariable(name='test_var', value='test', _reason='resolved') + details = ResolvedVariable(name='test_var', value='test', reason='resolved') assert details.name == 'test_var' assert details.value == 'test' assert details.label is None assert details.exception is None def test_with_label(self): - details = ResolvedVariable(name='test_var', value='test', label='v1', _reason='resolved') + details = ResolvedVariable(name='test_var', value='test', label='v1', reason='resolved') assert details.label == 'v1' def test_with_exception(self): error = ValueError('test error') - details = ResolvedVariable(name='test_var', value='default', exception=error, _reason='validation_error') + details = ResolvedVariable(name='test_var', value='default', exception=error, reason='validation_error') assert details.exception is error def test_context_manager_sets_baggage(self, config_kwargs: dict[str, Any]): @@ -821,7 +821,7 @@ def test_get_serialized_value_basic(self, simple_config: VariablesConfig): result = provider.get_serialized_value('test_var') assert result.value == '"default_value"' assert result.label == 'default' - assert result._reason == 'resolved' + assert result.reason == 'resolved' def test_get_serialized_value_with_override(self, simple_config: VariablesConfig): provider = LocalVariableProvider(simple_config) @@ -836,7 +836,7 @@ def test_get_serialized_value_unrecognized(self, simple_config: VariablesConfig) provider = LocalVariableProvider(simple_config) result = provider.get_serialized_value('unknown_var') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' def test_rollout_returns_none(self): config = VariablesConfig( @@ -852,7 +852,7 @@ def test_rollout_returns_none(self): provider = LocalVariableProvider(config) result = provider.get_serialized_value('partial_var') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' # ============================================================================= @@ -923,7 +923,7 @@ def test_get_serialized_value_missing_config_no_block(self) -> None: # Without blocking, config might not be fetched yet result = provider.get_serialized_value('test_var') # Should return missing_config if not fetched - assert result._reason in ('missing_config', 'resolved', 'unrecognized_variable') + assert result.reason in ('missing_config', 'resolved', 'unrecognized_variable') finally: provider.shutdown() @@ -959,7 +959,7 @@ def test_unrecognized_variable(self) -> None: try: result = provider.get_serialized_value('nonexistent_var') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' finally: provider.shutdown() @@ -1027,7 +1027,7 @@ def test_refresh_with_force(self) -> None: try: provider.refresh(force=True) result = provider.get_serialized_value('test_var') - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' finally: provider.shutdown() @@ -1065,7 +1065,7 @@ def test_rollout_returns_none_label(self) -> None: try: result = provider.get_serialized_value('partial_var') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' finally: provider.shutdown() @@ -1144,7 +1144,7 @@ def test_get_serialized_value_for_label_no_block(self) -> None: # since no config has been fetched yet result = provider.get_serialized_value_for_label('test_var', 'production') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' finally: provider.shutdown() @@ -1286,7 +1286,7 @@ def test_handles_unexpected_response(self) -> None: try: # The mock returns an error, so config should not be set result = provider.get_serialized_value('test_var') - assert result._reason == 'missing_config' + assert result.reason == 'missing_config' finally: provider.shutdown() @@ -1309,7 +1309,7 @@ def test_handles_validation_error(self) -> None: try: # The mock returns invalid data, so validation error happens result = provider.get_serialized_value('test_var') - assert result._reason == 'missing_config' + assert result.reason == 'missing_config' finally: provider.shutdown() @@ -1636,7 +1636,7 @@ def test_get_details_with_validation_error(self, config_kwargs: dict[str, Any], # Falls back to default when validation fails assert details.value == 999 assert details.exception is not None - assert details._reason == 'validation_error' + assert details.reason == 'validation_error' assert details.label == 'default' assert details.version == 1 @@ -2190,7 +2190,7 @@ def failing_get(*args: Any, **kwargs: Any) -> ResolvedVariable[str | None]: var = lf.var(name='failing_var', default='fallback', type=str) details = var.get() assert details.value == 'fallback' - assert details._reason == 'other_error' + assert details.reason == 'other_error' assert isinstance(details.exception, IndexError) # Restore original @@ -2887,7 +2887,7 @@ def test_alias_resolution_success(self): # Access via alias result = config.resolve_serialized_value('old_name') assert result.value == '"value"' - assert result._reason == 'resolved' + assert result.reason == 'resolved' def test_multiple_aliases(self): """Test that multiple aliases resolve correctly.""" @@ -2906,7 +2906,7 @@ def test_multiple_aliases(self): for alias in ['alias1', 'alias2', 'alias3']: result = config.resolve_serialized_value(alias) assert result.value == '"value"' - assert result._reason == 'resolved' + assert result.reason == 'resolved' def test_nonexistent_variable_returns_unrecognized(self): """Test that nonexistent variable returns unrecognized.""" @@ -2922,7 +2922,7 @@ def test_nonexistent_variable_returns_unrecognized(self): ) result = config.resolve_serialized_value('nonexistent') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' def test_direct_name_takes_precedence(self): """Test that direct variable name takes precedence over alias lookup.""" @@ -2960,7 +2960,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() result = provider.get_all_variables_config() @@ -2973,7 +2973,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() config = VariableConfig( @@ -2993,7 +2993,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() config = VariableConfig( @@ -3013,7 +3013,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() with pytest.warns(UserWarning, match='does not persist variable writes'): @@ -3032,7 +3032,7 @@ def __init__(self): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_variable_config(self, name: str) -> VariableConfig | None: return self.configs.get(name) @@ -3487,7 +3487,7 @@ class FailingRefreshProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def refresh(self, force: bool = False): raise RuntimeError('Refresh failed!') @@ -3514,7 +3514,7 @@ class FailingConfigProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: raise RuntimeError('Config fetch failed!') @@ -3538,7 +3538,7 @@ class FailingApplyProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: return VariablesConfig(variables={}) @@ -3563,7 +3563,7 @@ class FailingRefreshProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def refresh(self, force: bool = False): raise RuntimeError('Refresh failed!') @@ -3585,7 +3585,7 @@ class FailingConfigProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: raise RuntimeError('Config fetch failed!') @@ -3891,7 +3891,7 @@ def test_unknown_variable_returns_unrecognized(self): provider = NoOpVariableProvider() result = provider.get_serialized_value_for_label('nonexistent', 'v1') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' class TestBaseVariableProviderTypesMethods: @@ -3904,7 +3904,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() with pytest.warns(UserWarning, match='does not support variable types'): @@ -3918,7 +3918,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() with pytest.warns(UserWarning, match='does not support variable types'): @@ -3933,7 +3933,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() config = VariableTypeConfig(name='test_type', json_schema={'type': 'string'}) @@ -4285,7 +4285,7 @@ def __init__(self) -> None: def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def list_variable_types(self) -> dict[str, VariableTypeConfig]: return dict(self._types) @@ -4389,7 +4389,7 @@ class FailingRefreshProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def refresh(self, force: bool = False): raise RuntimeError('Refresh failed!') @@ -4413,7 +4413,7 @@ class FailingListProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def list_variable_types(self) -> dict[str, Any]: raise RuntimeError('List failed!') @@ -4435,7 +4435,7 @@ class FailingApplyProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def list_variable_types(self) -> dict[str, Any]: return {} @@ -4521,7 +4521,7 @@ def __init__(self) -> None: def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def list_variable_types(self) -> dict[str, VariableTypeConfig]: return dict(self._types) @@ -4559,7 +4559,7 @@ def __init__(self) -> None: def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: return self._variables_config @@ -4655,7 +4655,7 @@ class FailingConfigProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: raise RuntimeError('Config fetch failed!') @@ -4977,7 +4977,7 @@ def test_code_default_variable_returns_none(self): ) result = config.resolve_serialized_value('test_var') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' class TestVariablesConfigValidationErrorsWithLatestVersion: @@ -5062,7 +5062,7 @@ def test_code_default_label_returns_none(self): provider = LocalVariableProvider(config) result = provider.get_serialized_value_for_label('test_var', 'v1') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' class TestGetSerializedValueForLabelNotFound: @@ -5082,7 +5082,7 @@ def test_missing_label_returns_none(self): provider = LocalVariableProvider(config) result = provider.get_serialized_value_for_label('test_var', 'nonexistent') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' class TestVariableGetWithExplicitLabel: @@ -5110,7 +5110,7 @@ def test_explicit_label_resolves_successfully(self, config_kwargs: dict[str, Any assert result.value == 'experiment_value' assert result.label == 'experiment' assert result.version == 2 - assert result._reason == 'resolved' + assert result.reason == 'resolved' def test_explicit_label_not_found_falls_through(self, config_kwargs: dict[str, Any]): variables_config = VariablesConfig( From 551b74fbe141060417145b6d013307a03eadb20e Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 08:11:14 -0600 Subject: [PATCH 23/40] Rename reference_warnings to reference_errors The validation report's `reference_warnings` field was a misnomer: cycles and missing references make `is_valid` False and block `variables_push(strict=True)`. Renamed to `reference_errors` (and the supporting helper / tests) so the name matches behavior. Also tweaked the formatted-report colors to use error red instead of warning yellow. --- logfire/_internal/main.py | 2 +- logfire/variables/abstract.py | 52 +++++++++++++++++------------------ tests/test_push_variables.py | 42 ++++++++++++++-------------- 3 files changed, 48 insertions(+), 48 deletions(-) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index a53cdcae6..a995e81ad 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2737,7 +2737,7 @@ def variables_push( dry_run: If True, only show what would change without applying. yes: If True, skip confirmation prompt. strict: If True, fail if any existing label values are incompatible with new schemas - or any reference warnings are found. + or any reference errors are found. Returns: True if changes were applied (or would be applied in dry_run mode), False otherwise. diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index 282075273..ee3d21220 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -257,7 +257,7 @@ class VariableDiff: changes: list[VariableChange] orphaned_server_variables: list[str] # Variables on server not in local code - reference_warnings: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + reference_errors: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] """Warnings about variable references (non-existent refs, cycles, etc.).""" @property @@ -309,8 +309,8 @@ class ValidationReport: """Names of variables that exist locally but not on the server.""" description_differences: list[DescriptionDifference] """List of variables where local and server descriptions differ.""" - reference_warnings: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] - """Warnings about variable references (non-existent refs, cycles, etc.).""" + reference_errors: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + """Errors found while checking ``@{variable}@`` references (missing refs, cycles, etc.).""" @property def has_errors(self) -> bool: @@ -319,8 +319,8 @@ def has_errors(self) -> bool: @property def is_valid(self) -> bool: - """Return False if there are validation errors, missing variables, or reference warnings.""" - return len(self.errors) == 0 and len(self.variables_not_on_server) == 0 and len(self.reference_warnings) == 0 + """Return False if there are validation errors, missing variables, or reference errors.""" + return len(self.errors) == 0 and len(self.variables_not_on_server) == 0 and len(self.reference_errors) == 0 def format(self, *, colors: bool = True) -> str: """Format the validation report for human-readable output. @@ -374,15 +374,15 @@ def format(self, *, colors: bool = True) -> str: lines.append(f' Local: {local_desc}') lines.append(f' Server: {server_desc}') - # Show reference warnings - if self.reference_warnings: - lines.append(f'\n{yellow}=== Reference warnings ==={reset}') - for warning in self.reference_warnings: - lines.append(f' {yellow}⚠ {warning}{reset}') + # Show reference errors + if self.reference_errors: + lines.append(f'\n{red}=== Reference errors ==={reset}') + for error in self.reference_errors: + lines.append(f' {red}✗ {error}{reset}') # Summary line if not self.is_valid: - issue_count = variables_with_errors + len(self.variables_not_on_server) + len(self.reference_warnings) + issue_count = variables_with_errors + len(self.variables_not_on_server) + len(self.reference_errors) lines.append(f'\n{red}Validation failed: {issue_count} issue(s) found.{reset}') else: lines.append(f'\n{green}Validation passed: All {self.variables_checked} variable(s) are valid.{reset}') @@ -512,11 +512,11 @@ def _check_type_label_compatibility( return incompatible -def _check_reference_warnings( +def _check_reference_errors( variables: Sequence[_BaseVariable[object]], server_config: VariablesConfig, ) -> list[str]: - """Check for reference warnings: non-existent refs and cycles. + """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. @@ -688,10 +688,10 @@ def _compute_diff( # Find orphaned server variables (on server but not in local code) orphaned = [name for name in server_config.variables.keys() if name not in local_names] - # Check for reference warnings (non-existent refs, cycles) - reference_warnings = _check_reference_warnings(variables, server_config) + # Check for reference errors (non-existent refs, cycles) + reference_errors = _check_reference_errors(variables, server_config) - return VariableDiff(changes=changes, orphaned_server_variables=orphaned, reference_warnings=reference_warnings) + return VariableDiff(changes=changes, orphaned_server_variables=orphaned, reference_errors=reference_errors) def _format_diff(diff: VariableDiff) -> str: @@ -754,10 +754,10 @@ def _format_diff(diff: VariableDiff) -> str: lines.append(f' Local: {local_desc}') lines.append(f' Server: {server_desc}') - # Show reference warnings - if diff.reference_warnings: - lines.append(f'\n{ANSI_YELLOW}=== Reference warnings ==={ANSI_RESET}') - for warning in diff.reference_warnings: + # Show reference errors + if diff.reference_errors: + lines.append(f'\n{ANSI_YELLOW}=== Reference errors ==={ANSI_RESET}') + for warning in diff.reference_errors: lines.append(f' {ANSI_YELLOW}⚠ {warning}{ANSI_RESET}') return '\n'.join(lines) @@ -1203,7 +1203,7 @@ def push_variables( dry_run: If True, only show what would change without applying. yes: If True, skip confirmation prompt. strict: If True, fail if any existing label values are incompatible with new schemas - or any reference warnings are found. + or any reference errors are found. Returns: True if changes were applied (or would be applied in dry_run mode), False otherwise. @@ -1231,9 +1231,9 @@ def push_variables( # Show diff print(_format_diff(diff)) - if diff.reference_warnings and strict: + if diff.reference_errors and strict: print( - f'\n{ANSI_RED}Error: Reference warnings found.\n' + f'\n{ANSI_RED}Error: Reference errors found.\n' f'Fix these references or set strict=False to proceed anyway.{ANSI_RESET}' ) return False @@ -1364,15 +1364,15 @@ def validate_variables( ) ) - # Check for reference warnings - reference_warnings = _check_reference_warnings(variables, server_config) + # Check for reference errors + reference_errors = _check_reference_errors(variables, server_config) return ValidationReport( errors=errors, variables_checked=len(variables), variables_not_on_server=variables_not_on_server, description_differences=description_differences, - reference_warnings=reference_warnings, + reference_errors=reference_errors, ) # --- Variable Types API --- diff --git a/tests/test_push_variables.py b/tests/test_push_variables.py index 972559528..30cb908be 100644 --- a/tests/test_push_variables.py +++ b/tests/test_push_variables.py @@ -302,8 +302,8 @@ def test_compute_diff_orphaned_variables(mock_logfire_instance: MockLogfire) -> assert 'my_feature' not in diff.orphaned_server_variables -def test_compute_diff_reference_warnings(mock_logfire_instance: MockLogfire) -> None: - """Reference warnings include missing references and cycles.""" +def test_compute_diff_reference_errors(mock_logfire_instance: MockLogfire) -> None: + """Reference errors include missing references and cycles.""" var_a = Variable[str]( name='var_a', default='@{missing}@ @{var_b}@', @@ -338,13 +338,13 @@ def test_compute_diff_reference_warnings(mock_logfire_instance: MockLogfire) -> diff = _compute_diff([var_a, var_b], server_config) - assert any("'var_a' references '@{missing}@'" in warning for warning in diff.reference_warnings) - assert any("'var_a' references '@{server_missing}@'" in warning for warning in diff.reference_warnings) - assert any("'var_a' references '@{server_latest_missing}@'" in warning for warning in diff.reference_warnings) - assert any('Reference cycle detected: var_a -> var_b -> var_a' in warning for warning in diff.reference_warnings) + assert any("'var_a' references '@{missing}@'" in warning for warning in diff.reference_errors) + assert any("'var_a' references '@{server_missing}@'" in warning for warning in diff.reference_errors) + assert any("'var_a' references '@{server_latest_missing}@'" in warning for warning in diff.reference_errors) + assert any('Reference cycle detected: var_a -> var_b -> var_a' in warning for warning in diff.reference_errors) -def test_compute_diff_reference_warning_scan_handles_unserializable_default( +def test_compute_diff_reference_error_scan_handles_unserializable_default( mock_logfire_instance: MockLogfire, ) -> None: """Reference scanning tolerates defaults that cannot be serialized.""" @@ -368,10 +368,10 @@ def test_compute_diff_reference_warning_scan_handles_unserializable_default( diff = _compute_diff([var], server_config) - assert diff.reference_warnings == [] + assert diff.reference_errors == [] -def test_compute_diff_reference_warning_scan_skips_already_visited_nodes( +def test_compute_diff_reference_error_scan_skips_already_visited_nodes( mock_logfire_instance: MockLogfire, ) -> None: """Cycle detection handles shared reference graph nodes without duplicate traversal.""" @@ -397,7 +397,7 @@ def test_compute_diff_reference_warning_scan_skips_already_visited_nodes( diff = _compute_diff([var_a, var_b, shared], server_config) - assert diff.reference_warnings == [] + assert diff.reference_errors == [] def test_format_diff_creates() -> None: @@ -436,17 +436,17 @@ def test_format_diff_updates() -> None: assert 'updated_feature' in output -def test_format_diff_reference_warnings() -> None: - """Reference warnings are shown in the formatted diff.""" +def test_format_diff_reference_errors() -> None: + """Reference errors are shown in the formatted diff.""" diff = VariableDiff( changes=[], orphaned_server_variables=[], - reference_warnings=["Variable 'a' references '@{missing}@' which does not exist."], + reference_errors=["Variable 'a' references '@{missing}@' which does not exist."], ) output = _format_diff(diff) - assert 'Reference warnings' in output + assert 'Reference errors' in output assert 'missing' in output @@ -459,7 +459,7 @@ def test_validation_report_format_reference_and_description_warnings() -> None: description_differences=[ DescriptionDifference(variable_name='prompt', local_description='local', server_description=None) ], - reference_warnings=["Variable 'prompt' references '@{missing}@' which does not exist."], + reference_errors=["Variable 'prompt' references '@{missing}@' which does not exist."], ) output = report.format(colors=False) @@ -468,25 +468,25 @@ def test_validation_report_format_reference_and_description_warnings() -> None: assert 'Description differences' in output assert 'Local: local' in output assert 'Server: (none)' in output - assert 'Reference warnings' in output + assert 'Reference errors' in output -def test_validation_report_reference_warnings_are_invalid() -> None: - """Reference warnings make validation invalid so strict push paths can fail on cycles.""" +def test_validation_report_reference_errors_are_invalid() -> None: + """Reference errors make validation invalid so strict push paths can fail on cycles.""" report = ValidationReport( errors=[], variables_checked=1, variables_not_on_server=[], description_differences=[], - reference_warnings=['Reference cycle detected: prompt -> prompt'], + reference_errors=['Reference cycle detected: prompt -> prompt'], ) assert report.is_valid is False assert report.has_errors is True -def test_push_variables_strict_fails_with_reference_warnings(mock_logfire_instance: MockLogfire) -> None: - """Strict push fails when reference warnings such as cycles are present.""" +def test_push_variables_strict_fails_with_reference_errors(mock_logfire_instance: MockLogfire) -> None: + """Strict push fails when reference errors such as cycles are present.""" provider = LocalVariableProvider(VariablesConfig(variables={})) var = Variable[str]( name='prompt', From db1f8e14fb9e56e4868bf798fddf1f1fa096385c Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 08:15:19 -0600 Subject: [PATCH 24/40] Honor variable overrides through composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composition's resolve_ref now checks _VARIABLE_OVERRIDES before consulting the provider, so `@{x}@` sees the same value that `x.get()` would. Previously the override was only honored on direct .get() calls — references in other variables silently used the provider value, making `context_override` unreachable on `ComposedReference.reason`. --- logfire/variables/variable.py | 31 +++++++++++++++++++++++- tests/test_variable_composition.py | 38 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index c21d9e4a3..9dd22d405 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -325,15 +325,44 @@ def _expand_and_deserialize( # Expand @{references}@ if any are present if has_references(serialized_value): + context_overrides = _VARIABLE_OVERRIDES.get() def resolve_ref( ref_name: str, ) -> tuple[str | None, str | None, int | None, ResolutionReason]: + # Lookup priority mirrors _BaseVariable._resolve so that composition + # respects overrides and registered code defaults rather than only + # consulting the provider. + ref_variable = self._variable_registry.get(ref_name) + + # 1. Context override (only for variables we know the type of) + if context_overrides is not None and ref_name in context_overrides and ref_variable is not None: + override_value = context_overrides[ref_name] + if is_resolve_function(override_value): + override_value = override_value(targeting_key, attributes) + try: + serialized = ref_variable.type_adapter.dump_json(override_value).decode('utf-8') + except (ValueError, TypeError, RuntimeError): + pass # fall through to provider + else: + return (serialized, None, None, 'context_override') + + # 2. Provider ref_resolved = provider.get_serialized_value(ref_name, targeting_key, attributes) - if ref_resolved.value is None and (ref_variable := self._variable_registry.get(ref_name)) is not None: + if ref_resolved.value is not None: + return ( + ref_resolved.value, + ref_resolved.label, + ref_resolved.version, + ref_resolved.reason, + ) + + # 3. Registered code default + if ref_variable is not None: ref_default = ref_variable._get_serialized_default(targeting_key, attributes) if ref_default is not None: return (ref_default, None, None, 'code_default') + return ( ref_resolved.value, ref_resolved.label, diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 13a2beb07..65f3c468d 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -822,6 +822,44 @@ def test_reference_falls_back_to_registered_code_default(self, config_kwargs: di assert result.composed_from[0].name == 'greeting' assert result.composed_from[0].reason == 'code_default' + def test_override_propagates_through_composition(self, config_kwargs: dict[str, Any]): + """``var.override(...)`` is honoured for ``@{var}@`` substitutions in a parent variable.""" + variables_config = VariablesConfig( + variables={ + 'greeting': VariableConfig( + name='greeting', + json_schema={'type': 'string'}, + labels={'production': LabeledValue(version=1, serialized_value='"PROVIDER_GREETING"')}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + 'parent': VariableConfig( + name='parent', + json_schema={'type': 'string'}, + labels={'production': LabeledValue(version=1, serialized_value='"hello @{greeting}@"')}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + }, + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + greeting = lf.var(name='greeting', default='code_default_greeting', type=str) + parent = lf.var(name='parent', default='fallback', type=str) + + # Without override: provider value used. + assert parent.get().value == 'hello PROVIDER_GREETING' + + # With override on the referenced variable, composition sees the overridden value. + with greeting.override('OVERRIDDEN_GREETING'): + assert greeting.get().value == 'OVERRIDDEN_GREETING' + result = parent.get() + assert result.value == 'hello OVERRIDDEN_GREETING' + assert len(result.composed_from) == 1 + assert result.composed_from[0].name == 'greeting' + assert result.composed_from[0].reason == 'context_override' + class TestCompositionExceptions: """Test the exception hierarchy.""" From fd0c69fd46df95bce892c4e685c7e0620501389f Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 08:27:25 -0600 Subject: [PATCH 25/40] Warn on composition failure; treat unresolved refs as errors Composition fallback now emits a RuntimeWarning when a variable's composition fails, so users notice the misconfiguration instead of getting a silent code-default substitution. References to unknown variables are also surfaced as composition errors (with the literal '@{x}@' marker set as the ComposedReference.error) and trigger the same warn-and-fallback path, matching the behavior of cycles and depth-exceeded errors. --- logfire/variables/composition.py | 1 + logfire/variables/variable.py | 65 +++++++++++++++++++----------- tests/test_variable_composition.py | 24 +++++++---- tests/test_variable_templates.py | 5 ++- 4 files changed, 62 insertions(+), 33 deletions(-) diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index 73d731b39..0967a552c 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -180,6 +180,7 @@ def expand_references( label=ref_label, version=ref_version, reason=ref_reason, + error=f"Referenced variable '{ref_name}' could not be resolved.", ) ) unresolved_names.add(ref_name) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 9dd22d405..b547e5569 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -2,6 +2,7 @@ import inspect import json +import warnings from collections.abc import Callable, Generator, Mapping, Sequence from contextlib import ExitStack, contextmanager from contextvars import ContextVar @@ -377,26 +378,20 @@ def resolve_ref( resolve_ref, ) if composition_error := _first_composition_error(composed): - default = self._get_default(targeting_key, attributes) - return ResolvedVariable( - name=self.name, - value=default, + return self._composition_failure( exception=VariableCompositionError(composition_error), - reason='other_error', - label=serialized_result.label, - version=serialized_result.version, - composed_from=composed, + targeting_key=targeting_key, + attributes=attributes, + serialized_result=serialized_result, + composed=composed, ) except VariableCompositionError as e: - default = self._get_default(targeting_key, attributes) - return ResolvedVariable( - name=self.name, - value=default, + return self._composition_failure( exception=e, - reason='other_error', - label=serialized_result.label, - version=serialized_result.version, - composed_from=composed, + targeting_key=targeting_key, + attributes=attributes, + serialized_result=serialized_result, + composed=composed, ) # Apply render_fn (template rendering) if provided @@ -404,15 +399,12 @@ def resolve_ref( try: serialized_value = render_fn(serialized_value) except (HandlebarsError, ValueError, TypeError) as e: - default = self._get_default(targeting_key, attributes) - return ResolvedVariable( - name=self.name, - value=default, + return self._composition_failure( exception=e, - reason='other_error', - label=serialized_result.label, - version=serialized_result.version, - composed_from=composed, + targeting_key=targeting_key, + attributes=attributes, + serialized_result=serialized_result, + composed=composed, ) # Deserialize the (possibly expanded/rendered) value @@ -442,6 +434,31 @@ def resolve_ref( composed_from=composed, ) + def _composition_failure( + self, + *, + exception: Exception, + targeting_key: str | None, + attributes: Mapping[str, Any] | None, + serialized_result: ResolvedVariable[str | None], + composed: list[ComposedReference], + ) -> ResolvedVariable[T_co]: + """Fall back to the code default and warn after a composition/render failure.""" + warnings.warn( + f"Variable '{self.name}' composition failed; falling back to code default: {exception}", + category=RuntimeWarning, + stacklevel=2, + ) + return ResolvedVariable( + name=self.name, + value=self._get_default(targeting_key, attributes), + exception=exception, + reason='other_error', + label=serialized_result.label, + version=serialized_result.version, + composed_from=composed, + ) + def _get_default( self, targeting_key: str | None = None, merged_attributes: Mapping[str, Any] | None = None ) -> T_co: diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 65f3c468d..221de994f 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -564,7 +564,8 @@ def raise_composition_error(*args: Any, **kwargs: Any) -> Any: monkeypatch.setattr('logfire.variables.variable.expand_references', raise_composition_error) var = lf.var(name='main', default='fallback', type=str) - result = var.get() + with pytest.warns(RuntimeWarning, match='composition failed'): + result = var.get() assert result.value == 'fallback' assert result.exception is not None @@ -588,7 +589,7 @@ def test_nested_reference(self, config_kwargs: dict[str, Any]): assert result.composed_from[0].composed_from[0].name == 'c' def test_cycle_falls_back_gracefully(self, config_kwargs: dict[str, Any]): - """Cycles in references are surfaced on the top-level result.""" + """Cycles in references are surfaced on the top-level result and a warning is emitted.""" variables_config = _make_variables_config( a='"@{b}@"', b='"@{a}@"', @@ -597,15 +598,16 @@ def test_cycle_falls_back_gracefully(self, config_kwargs: dict[str, Any]): lf = logfire.configure(**config_kwargs) var = lf.var(name='a', default='fallback', type=str) - result = var.get() + with pytest.warns(RuntimeWarning, match='composition failed'): + result = var.get() assert result.value == 'fallback' assert isinstance(result.exception, VariableCompositionError) assert result.reason == 'other_error' assert len(result.composed_from) == 1 assert result.composed_from[0].composed_from[0].error == 'Circular reference detected: a -> b -> a' - def test_nonexistent_reference_left_unexpanded(self, config_kwargs: dict[str, Any]): - """References to non-existent variables are left as-is.""" + def test_nonexistent_reference_falls_back_with_warning(self, config_kwargs: dict[str, Any]): + """References to non-existent variables surface as composition errors and fall back.""" variables_config = _make_variables_config( main='"Hello @{nonexistent}@"', ) @@ -613,8 +615,16 @@ def test_nonexistent_reference_left_unexpanded(self, config_kwargs: dict[str, An lf = logfire.configure(**config_kwargs) var = lf.var(name='main', default='fallback', type=str) - result = var.get() - assert result.value == 'Hello @{nonexistent}@' + with pytest.warns(RuntimeWarning, match='composition failed'): + result = var.get() + assert result.value == 'fallback' + assert result.reason == 'other_error' + assert isinstance(result.exception, VariableCompositionError) + # The unresolved reference is recorded with an error message. + assert len(result.composed_from) == 1 + assert result.composed_from[0].name == 'nonexistent' + assert result.composed_from[0].error is not None + assert 'nonexistent' in result.composed_from[0].error def test_non_string_reference_expanded(self, config_kwargs: dict[str, Any]): """Non-string variables are now expanded via Handlebars.""" diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index e6c4811b8..50f841822 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -188,7 +188,7 @@ class Inputs(BaseModel): lf.template_var('not-valid', type=str, default='x', inputs_type=Inputs) def test_remote_render_error_records_exception(self, config_kwargs: dict[str, Any]): - """Invalid remote templates fall back and record the render exception.""" + """Invalid remote templates fall back, warn, and record the render exception.""" class Inputs(BaseModel): name: str @@ -196,7 +196,8 @@ class Inputs(BaseModel): lf = _make_lf(_simple_config('prompt', json.dumps('Hello {{#if name}}')), config_kwargs) var = lf.template_var('prompt', type=str, default='fallback', inputs_type=Inputs) - resolved = var.get(Inputs(name='Alice')) + with pytest.warns(RuntimeWarning, match='composition failed'): + resolved = var.get(Inputs(name='Alice')) assert resolved.value == 'fallback' assert resolved.exception is not None From aeb1ac7b6a621880521f52267b1ad9c4450ea3fd Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 10:52:22 -0600 Subject: [PATCH 26/40] Move template_inputs state to TemplateVariable Take template_inputs_type and the inputs TypeAdapter off _BaseVariable so the field only exists where it's actually used. Variable instances no longer carry a None-valued attribute; to_config() and get_template_inputs_schema() route through a single virtual method that TemplateVariable overrides. --- logfire/_internal/main.py | 8 ++++++-- logfire/variables/variable.py | 33 +++++++++++++-------------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index a995e81ad..46cf8b035 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2567,8 +2567,12 @@ def var( ... ``` - For variables with Handlebars ``{{placeholder}}`` templates that need runtime inputs, - use [`template_var()`][logfire.Logfire.template_var] instead. + For variables whose values contain Handlebars `{{placeholder}}` templates that + need runtime inputs, we recommend [`template_var()`][logfire.Logfire.template_var]: + it renders the templates as part of resolution and gives you a typed + `get(inputs)` API. Under the hood it uses `pydantic_handlebars.render`, which + you can also call yourself on the resolved value if you need to drive rendering + manually. Args: name: Unique identifier for the variable. Must match the name configured in the diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index b547e5569..2a49c1a4e 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -154,8 +154,6 @@ class _BaseVariable(Generic[T_co]): """Default value or function to compute the default.""" description: str | None """Description of the variable.""" - template_inputs_type: type[Any] | None - """The Pydantic model type for template inputs, if template rendering is enabled.""" logfire_instance: logfire.Logfire """The Logfire instance this variable is associated with.""" @@ -167,7 +165,6 @@ def __init__( type: type[T_co], default: T_co | ResolveFunction[T_co], description: str | None = None, - template_inputs: type[Any] | None = None, logfire_instance: logfire.Logfire, ): """Create a new managed variable. @@ -178,29 +175,23 @@ def __init__( default: Default value to use when no configuration is found, or a function that computes the default based on targeting_key and attributes. description: Optional human-readable description of what this variable controls. - template_inputs: Internal hook used by ``TemplateVariable`` to declare the expected - template inputs type. Not exposed via the public ``logfire.var()`` API. logfire_instance: The Logfire instance this variable is associated with. Used to determine config, etc. """ self.name = name self.value_type = type self.default = default self.description = description - self.template_inputs_type = template_inputs self._variable_registry = logfire_instance._variables # pyright: ignore[reportPrivateUsage] self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='variables') self.type_adapter = TypeAdapter[T_co](type) - if template_inputs is not None: - self._template_inputs_adapter: TypeAdapter[Any] | None = TypeAdapter(template_inputs) - else: - self._template_inputs_adapter = None - def get_template_inputs_schema(self) -> dict[str, Any] | None: - """Return the JSON schema for template inputs, or None if not configured.""" - if self._template_inputs_adapter is not None: - return self._template_inputs_adapter.json_schema() + """Return the JSON schema for template inputs. + + Returns None on plain `Variable` instances. `TemplateVariable` overrides this + to return the schema derived from its `inputs_type`. + """ return None def _deserialize(self, serialized_value: str) -> T_co | ValidationError | ValueError: @@ -545,9 +536,7 @@ def to_config(self) -> VariableConfig: if not is_resolve_function(self.default): example = self.type_adapter.dump_json(self.default).decode('utf-8') - template_inputs_schema: dict[str, Any] | None = None - if self._template_inputs_adapter is not None: - template_inputs_schema = self._template_inputs_adapter.json_schema() + template_inputs_schema = self.get_template_inputs_schema() return VariableConfig( name=self.name, @@ -683,8 +672,8 @@ def __init__( type: The expected type of this variable's values, used for validation. default: Default value to use when no configuration is found, or a function that computes the default based on targeting_key and attributes. - inputs_type: The type (typically a Pydantic ``BaseModel``) describing the expected - template inputs. Used for type-safe ``get(inputs)`` calls and JSON schema generation. + 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 this variable controls. logfire_instance: The Logfire instance this variable is associated with. """ @@ -693,10 +682,14 @@ def __init__( type=type, default=default, description=description, - template_inputs=inputs_type, logfire_instance=logfire_instance, ) self.inputs_type = inputs_type + self._inputs_type_adapter: TypeAdapter[InputsT] = TypeAdapter(inputs_type) + + def get_template_inputs_schema(self) -> dict[str, Any]: + """Return the JSON schema derived from `inputs_type`.""" + return self._inputs_type_adapter.json_schema() def get( self, From 3a1238592798a55729ca213c0c6bb59148e32c77 Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 10:55:04 -0600 Subject: [PATCH 27/40] Replace double-backticks with single backticks in docstrings The codebase had ~111 occurrences of Sphinx-style ``code`` in docstrings and module comments across 12 files. mkdocstrings renders these as literal double-backticks; single backticks are the convention used elsewhere and match how Markdown is rendered. Triple-backtick code fences are untouched. --- .../_internal/integrations/pytest.pyi | 36 +++++++-------- .../integrations/claude_agent_sdk.py | 4 +- logfire/_internal/integrations/pytest.py | 38 +++++++-------- logfire/_internal/main.py | 10 ++-- logfire/variables/abstract.py | 16 +++---- logfire/variables/composition.py | 46 +++++++++---------- logfire/variables/reference_syntax.py | 16 +++---- logfire/variables/template_validation.py | 36 +++++++-------- logfire/variables/variable.py | 14 +++--- .../django_test_site/wsgi.py | 2 +- tests/otel_integrations/test_pytest_plugin.py | 4 +- tests/test_variable_composition.py | 2 +- 12 files changed, 112 insertions(+), 112 deletions(-) diff --git a/logfire-api/logfire_api/_internal/integrations/pytest.pyi b/logfire-api/logfire_api/_internal/integrations/pytest.pyi index 13c71128c..f9431313e 100644 --- a/logfire-api/logfire_api/_internal/integrations/pytest.pyi +++ b/logfire-api/logfire_api/_internal/integrations/pytest.pyi @@ -18,22 +18,22 @@ def pytest_configure(config: pytest.Config) -> None: def pytest_xdist_setupnodes(config: Any, specs: Any) -> None: """Inject TRACEPARENT into env before xdist spawns workers. - Called in the controller before any ``makegateway()`` call, so all workers - inherit the session-level trace context via ``os.environ``. + Called in the controller before any `makegateway()` call, so all workers + inherit the session-level trace context via `os.environ`. - NOTE: This relies on ``pytest_sessionstart`` (which creates the session span) - running at default priority, *before* xdist's ``DSession.pytest_sessionstart`` - which uses ``trylast=True`` and calls ``setup_nodes()`` → - ``pytest_xdist_setupnodes``. Do not add ``trylast=True`` to our - ``pytest_sessionstart`` or this ordering guarantee breaks. + NOTE: This relies on `pytest_sessionstart` (which creates the session span) + running at default priority, *before* xdist's `DSession.pytest_sessionstart` + which uses `trylast=True` and calls `setup_nodes()` → + `pytest_xdist_setupnodes`. Do not add `trylast=True` to our + `pytest_sessionstart` or this ordering guarantee breaks. """ def pytest_sessionstart(session: pytest.Session) -> None: """Create a session span when the test session starts. - IMPORTANT: This hook must run at default priority (no ``trylast=True``). - ``pytest_xdist_setupnodes`` depends on the session span being active when - it injects TRACEPARENT into ``os.environ`` for worker processes. xdist's - ``DSession.pytest_sessionstart`` uses ``trylast=True``, so our default-priority + IMPORTANT: This hook must run at default priority (no `trylast=True`). + `pytest_xdist_setupnodes` depends on the session span being active when + it injects TRACEPARENT into `os.environ` for worker processes. xdist's + `DSession.pytest_sessionstart` uses `trylast=True`, so our default-priority hook is guaranteed to run first. """ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None) -> Generator[None]: @@ -62,16 +62,16 @@ def logfire_pytest(request: pytest.FixtureRequest) -> Logfire: def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Generator[None]: """Re-attach the per-test span context for async test functions. - The ``pytest_runtest_protocol`` hook creates a span per test and attaches it - to the OTel context via ``context_api.attach()`` in the **synchronous** hook + The `pytest_runtest_protocol` hook creates a span per test and attaches it + to the OTel context via `context_api.attach()` in the **synchronous** hook thread. However, when tests are async (e.g. with anyio/pytest-asyncio), they - may run inside an event-loop task whose ``contextvars`` snapshot was taken - before the per-test span was attached (e.g. when ``asyncio.Runner`` reuses a - saved context on Python 3.11+). As a result, ``logfire.get_context()`` inside + may run inside an event-loop task whose `contextvars` snapshot was taken + before the per-test span was attached (e.g. when `asyncio.Runner` reuses a + saved context on Python 3.11+). As a result, `logfire.get_context()` inside an async test can return a stale traceparent from a previous test (or no context at all). - This hook wraps async test functions so that ``context_api.attach()`` is called + This hook wraps async test functions so that `context_api.attach()` is called *inside* the coroutine body, making the span visible to the test and any - callbacks (e.g. httpx event hooks) that call ``logfire.get_context()``. + callbacks (e.g. httpx event hooks) that call `logfire.get_context()`. """ diff --git a/logfire/_internal/integrations/claude_agent_sdk.py b/logfire/_internal/integrations/claude_agent_sdk.py index 98b4c05e6..010ea97f3 100644 --- a/logfire/_internal/integrations/claude_agent_sdk.py +++ b/logfire/_internal/integrations/claude_agent_sdk.py @@ -117,8 +117,8 @@ def _extract_usage(usage: Any, *, partial: bool = False) -> dict[str, int]: Args: usage: A usage object or dict from the SDK. - partial: If True, prefix attribute names with ``gen_ai.usage.partial.`` - instead of ``gen_ai.usage.``. Used for chat spans where per-message + partial: If True, prefix attribute names with `gen_ai.usage.partial.` + instead of `gen_ai.usage.`. Used for chat spans where per-message usage from the SDK is unreliable. """ if not usage: diff --git a/logfire/_internal/integrations/pytest.py b/logfire/_internal/integrations/pytest.py index fe7c306ee..6bea08519 100644 --- a/logfire/_internal/integrations/pytest.py +++ b/logfire/_internal/integrations/pytest.py @@ -264,14 +264,14 @@ def _inject_traceparent_env() -> None: def pytest_xdist_setupnodes(config: Any, specs: Any) -> None: # pragma: no cover """Inject TRACEPARENT into env before xdist spawns workers. - Called in the controller before any ``makegateway()`` call, so all workers - inherit the session-level trace context via ``os.environ``. - - NOTE: This relies on ``pytest_sessionstart`` (which creates the session span) - running at default priority, *before* xdist's ``DSession.pytest_sessionstart`` - which uses ``trylast=True`` and calls ``setup_nodes()`` → - ``pytest_xdist_setupnodes``. Do not add ``trylast=True`` to our - ``pytest_sessionstart`` or this ordering guarantee breaks. + Called in the controller before any `makegateway()` call, so all workers + inherit the session-level trace context via `os.environ`. + + NOTE: This relies on `pytest_sessionstart` (which creates the session span) + running at default priority, *before* xdist's `DSession.pytest_sessionstart` + which uses `trylast=True` and calls `setup_nodes()` → + `pytest_xdist_setupnodes`. Do not add `trylast=True` to our + `pytest_sessionstart` or this ordering guarantee breaks. """ del specs # unused if not _is_enabled(config): @@ -287,10 +287,10 @@ def _get_xdist_worker_id() -> str | None: def pytest_sessionstart(session: pytest.Session) -> None: """Create a session span when the test session starts. - IMPORTANT: This hook must run at default priority (no ``trylast=True``). - ``pytest_xdist_setupnodes`` depends on the session span being active when - it injects TRACEPARENT into ``os.environ`` for worker processes. xdist's - ``DSession.pytest_sessionstart`` uses ``trylast=True``, so our default-priority + IMPORTANT: This hook must run at default priority (no `trylast=True`). + `pytest_xdist_setupnodes` depends on the session span being active when + it injects TRACEPARENT into `os.environ` for worker processes. xdist's + `DSession.pytest_sessionstart` uses `trylast=True`, so our default-priority hook is guaranteed to run first. """ if not _is_enabled(session.config): @@ -611,18 +611,18 @@ def logfire_pytest(request: pytest.FixtureRequest) -> Logfire: def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Generator[None]: """Re-attach the per-test span context for async test functions. - The ``pytest_runtest_protocol`` hook creates a span per test and attaches it - to the OTel context via ``context_api.attach()`` in the **synchronous** hook + The `pytest_runtest_protocol` hook creates a span per test and attaches it + to the OTel context via `context_api.attach()` in the **synchronous** hook thread. However, when tests are async (e.g. with anyio/pytest-asyncio), they - may run inside an event-loop task whose ``contextvars`` snapshot was taken - before the per-test span was attached (e.g. when ``asyncio.Runner`` reuses a - saved context on Python 3.11+). As a result, ``logfire.get_context()`` inside + may run inside an event-loop task whose `contextvars` snapshot was taken + before the per-test span was attached (e.g. when `asyncio.Runner` reuses a + saved context on Python 3.11+). As a result, `logfire.get_context()` inside an async test can return a stale traceparent from a previous test (or no context at all). - This hook wraps async test functions so that ``context_api.attach()`` is called + This hook wraps async test functions so that `context_api.attach()` is called *inside* the coroutine body, making the span visible to the test and any - callbacks (e.g. httpx event hooks) that call ``logfire.get_context()``. + callbacks (e.g. httpx event hooks) that call `logfire.get_context()`. """ import inspect diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 46cf8b035..cc50098db 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2634,9 +2634,9 @@ def template_var( ) -> TemplateVariable[T, InputsT]: """Define a managed template variable with integrated rendering. - Like ``var()``, but ``get(inputs)`` automatically renders Handlebars ``{{placeholder}}`` + Like `var()`, but `get(inputs)` automatically renders Handlebars `{{placeholder}}` templates in the resolved value before returning. The pipeline is: - resolve → compose ``@{refs}@`` → render ``{{}}`` → deserialize. + resolve → compose `@{refs}@` → render `{{}}` → deserialize. ```py skip-run="true" skip-reason="requires-pydantic-handlebars" from pydantic import BaseModel @@ -2666,9 +2666,9 @@ class PromptInputs(BaseModel): name: Unique identifier for the variable. type: Expected type for validation and JSON schema generation. default: Default value used when no remote configuration is found. - Can also be a callable with ``targeting_key`` and ``attributes`` parameters. - inputs_type: The type (typically a Pydantic ``BaseModel``) describing the expected - template inputs. Used for type-safe ``get(inputs)`` calls and JSON schema generation. + Can also be a callable with `targeting_key` and `attributes` parameters. + 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 diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index ee3d21220..b09f53d9b 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -98,8 +98,8 @@ class ResolvedVariable(Generic[T_co]): value: T_co """The resolved value of the variable.""" reason: ResolutionReason - """How the variable was resolved (see ``ResolutionReason`` for possible values).""" - # Note: ``reason`` is declared before fields with defaults because we don't use kw_only=True + """How the variable was resolved (see `ResolutionReason` for possible values).""" + # Note: `reason` is declared before fields with defaults because we don't use kw_only=True # on Python<3.10; move it to the end when 3.9 support is dropped. label: str | None = None """The name of the selected label, if any.""" @@ -136,8 +136,8 @@ def _inputs_to_context(inputs: Any) -> dict[str, Any]: """Convert inputs (Pydantic model, dict, or Mapping) to a template context dict. Args: - inputs: Template context values. Can be a Pydantic ``BaseModel`` (uses ``model_dump()``), - a ``dict``, or any ``Mapping``. If ``None``, returns an empty dict. + inputs: Template context values. Can be a Pydantic `BaseModel` (uses `model_dump()`), + a `dict`, or any `Mapping`. If `None`, returns an empty dict. Returns: A dict suitable for use as a Handlebars template context. @@ -158,13 +158,13 @@ def _inputs_to_context(inputs: Any) -> dict[str, Any]: def render_serialized_string(serialized_json: str, inputs: Any) -> str: """Render Handlebars templates in a serialized JSON string. - Decodes the JSON, renders all string values containing ``{{placeholders}}`` + Decodes the JSON, renders all string values containing `{{placeholders}}` using the provided inputs, then re-encodes to JSON. Args: serialized_json: A JSON-encoded string potentially containing Handlebars templates. - inputs: Template context values. Can be a Pydantic ``BaseModel``, ``dict``, - ``Mapping``, or ``None``. + inputs: Template context values. Can be a Pydantic `BaseModel`, `dict`, + `Mapping`, or `None`. Returns: The rendered JSON string. @@ -310,7 +310,7 @@ class ValidationReport: description_differences: list[DescriptionDifference] """List of variables where local and server descriptions differ.""" reference_errors: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] - """Errors found while checking ``@{variable}@`` references (missing refs, cycles, etc.).""" + """Errors found while checking `@{variable}@` references (missing refs, cycles, etc.).""" @property def has_errors(self) -> bool: diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index 0967a552c..95c70ec2e 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -1,11 +1,11 @@ -"""Variable composition: expand ``@{variable_name}@`` references in serialized values. +"""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 +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. -Meanwhile, any ``{{runtime}}`` placeholders are preserved untouched for later +Meanwhile, any `{{runtime}}` placeholders are preserved untouched for later template rendering. The composition logic is shared between the SDK (client-side expansion) and the @@ -42,14 +42,14 @@ ] """Why a variable (or a composed reference) resolved to its final value. -- ``resolved``: provider returned a value that was used as-is. -- ``context_override``: a value set via ``Variable.override(...)`` was used. -- ``missing_config``: the variable exists on the provider but the targeting/rollout produced no value. -- ``unrecognized_variable``: the provider has no entry for the variable. -- ``validation_error``: the serialized value failed deserialization. -- ``other_error``: composition, rendering or other error during resolution. -- ``no_provider``: no provider is configured. -- ``code_default``: the variable's code-default was used because the provider had no value. +- `resolved`: provider returned a value that was used as-is. +- `context_override`: a value set via `Variable.override(...)` was used. +- `missing_config`: the variable exists on the provider but the targeting/rollout produced no value. +- `unrecognized_variable`: the provider has no entry for the variable. +- `validation_error`: the serialized value failed deserialization. +- `other_error`: composition, rendering or other error during resolution. +- `no_provider`: no provider is configured. +- `code_default`: the variable's code-default was used because the provider had no value. """ # Matches unescaped @{ (not preceded by \). @@ -79,7 +79,7 @@ class VariableCompositionCycleError(VariableCompositionError): @dataclass class ComposedReference: - """Metadata about a single ``@{reference}@`` that was encountered during expansion. + """Metadata about a single `@{reference}@` that was encountered during expansion. This is a lightweight dataclass used to track composition results without depending on ResolvedVariable, making it reusable from both the SDK and backend. @@ -106,7 +106,7 @@ class ComposedReference: def has_references(serialized_value: str) -> bool: - """Quick check for any unescaped ``@{`` in a serialized value.""" + """Quick check for any unescaped `@{` in a serialized value.""" return _HAS_REFERENCE.search(serialized_value) is not None @@ -118,11 +118,11 @@ def expand_references( _visited: tuple[str, ...] = (), _depth: int = 0, ) -> tuple[str, list[ComposedReference]]: - """Expand ``@{var}@`` references in a serialized variable value. + """Expand `@{var}@` references in a serialized variable value. - Uses the Handlebars engine so that ``@{}@`` supports simple references, + 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 + top-level referenced variable while preserving `{{runtime}}` placeholders untouched. Args: @@ -262,10 +262,10 @@ def expand_references( def find_references(serialized_value: str) -> list[str]: - """Find all ``@{variable_name}@`` references in a serialized value. + """Find all `@{variable_name}@` references in a serialized value. - Detects both simple ``@{var}@`` and block ``@{#helper var}@`` patterns. - For dotted references like ``@{var.field}@``, only the base variable name + Detects both simple `@{var}@` and block `@{#helper var}@` patterns. + For dotted references like `@{var.field}@`, only the base variable name (first segment) is returned. This ensures correct cycle detection and reference graph building. @@ -334,7 +334,7 @@ def _render_value(value: Any, context: dict[str, Any], unresolved_names: set[str """Recursively walk a decoded JSON value, rendering strings through Handlebars. Unresolved variable names should already be present in the context as their - literal ``@{name}@`` text so that Handlebars preserves them. + literal `@{name}@` text so that Handlebars preserves them. """ if isinstance(value, str): if not has_references(value): @@ -381,9 +381,9 @@ def _restore_unresolved_refs(value: str, protected_refs: dict[str, str]) -> str: def _unescape_serialized(serialized: str) -> str: - r"""Unescape ``\@{`` to ``@{`` in a JSON-serialized string. + 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. + In JSON encoding, a literal backslash is `\\`, so `\@{` in user content + appears as `\\@{` in the serialized JSON. """ return serialized.replace('\\\\@{', '@{') diff --git a/logfire/variables/reference_syntax.py b/logfire/variables/reference_syntax.py index 6194610f7..be736b740 100644 --- a/logfire/variables/reference_syntax.py +++ b/logfire/variables/reference_syntax.py @@ -1,13 +1,13 @@ -"""Reference-syntax Handlebars: low-level primitives for ``@{}@`` rendering. +"""Reference-syntax Handlebars: low-level primitives for `@{}@` rendering. -This module provides ``render_once`` which performs a single-pass render using -``@{}@`` as the delimiter instead of ``{{}}``. It is the engine behind variable -composition — it gives ``@{}@`` syntax a Handlebars-compatible subset while -preserving any ``{{}}`` runtime placeholders untouched. +This module provides `render_once` which performs a single-pass render using +`@{}@` as the delimiter instead of `{{}}`. It is the engine behind variable +composition — it gives `@{}@` syntax a Handlebars-compatible subset while +preserving any `{{}}` runtime placeholders untouched. Algorithm: - a. Protect ``{{...}}`` runtime placeholders in the template - b. Convert ``@{...}@`` reference tags to standard Handlebars ``{{...}}`` tags + a. Protect `{{...}}` runtime placeholders in the template + b. Convert `@{...}@` reference tags to standard Handlebars `{{...}}` tags c. Run standard Handlebars d. Restore the protected runtime placeholders e. Unescape entities introduced to protect context values @@ -46,7 +46,7 @@ def _protect_value(value: Any, safe_string_cls: type[str]) -> Any: def render_once(template: str, context: dict[str, Any]) -> str: - """Single-pass render: convert ``@{}@`` tags, run Handlebars, restore ``{{}}``.""" + """Single-pass render: convert `@{}@` tags, run Handlebars, restore `{{}}`.""" safe_string_cls, hbs_render = get_handlebars_renderer() left_runtime_placeholder = _sentinel('left-runtime-placeholder', template) right_runtime_placeholder = _sentinel('right-runtime-placeholder', template) diff --git a/logfire/variables/template_validation.py b/logfire/variables/template_validation.py index 6df14980e..63e0ed96d 100644 --- a/logfire/variables/template_validation.py +++ b/logfire/variables/template_validation.py @@ -1,8 +1,8 @@ -"""Template validation: check ``{{field}}`` references against ``template_inputs_schema``. +"""Template validation: check `{{field}}` references against `template_inputs_schema`. -This module validates that Handlebars ``{{field}}`` references in template variable -values (including composed ``@{ref}@`` dependencies) match the declared -``template_inputs_schema``. It uses ``pydantic_handlebars.check_template_compatibility`` +This module validates that Handlebars `{{field}}` references in template variable +values (including composed `@{ref}@` dependencies) match the declared +`template_inputs_schema`. It uses `pydantic_handlebars.check_template_compatibility` for full AST-based schema checking (nested paths, block scopes, helpers). It also provides cycle detection for composition graphs. @@ -37,16 +37,16 @@ @dataclass class TemplateFieldIssue: - """A ``{{field}}`` reference that doesn't match the variable's ``template_inputs_schema``.""" + """A `{{field}}` reference that doesn't match the variable's `template_inputs_schema`.""" field_name: str - """The template field name (e.g., ``user_name`` from ``{{user_name}}``).""" + """The template field name (e.g., `user_name` from `{{user_name}}`).""" found_in_variable: str """Name of the variable whose value contains this field reference.""" found_in_label: str | None - """Label of the value where the field was found, or ``None`` for the latest version.""" + """Label of the value where the field was found, or `None` for the latest version.""" reference_path: list[str] - """Composition path from the root variable to ``found_in_variable``.""" + """Composition path from the root variable to `found_in_variable`.""" @dataclass @@ -57,7 +57,7 @@ class TemplateValidationResult: def find_template_fields(text: str) -> set[str]: - """Find all ``{{field}}`` or ``{{path.to.field}}`` references in a string. + """Find all `{{field}}` or `{{path.to.field}}` references in a string. Returns: Set of field names found in the text. @@ -66,7 +66,7 @@ def find_template_fields(text: str) -> set[str]: def _extract_template_strings(serialized_json: str) -> list[str]: - """Extract all string values from serialized JSON that contain ``{{...}}`` templates.""" + """Extract all string values from serialized JSON that contain `{{...}}` templates.""" try: decoded = json.loads(serialized_json) except (json.JSONDecodeError, TypeError): @@ -78,7 +78,7 @@ def _extract_template_strings(serialized_json: str) -> list[str]: def _collect_template_strings(value: Any) -> list[str]: - """Recursively collect strings containing ``{{...}}`` from a decoded JSON value.""" + """Recursively collect strings containing `{{...}}` from a decoded JSON value.""" if isinstance(value, str): return [value] if '{{' in value else [] if isinstance(value, dict): @@ -99,18 +99,18 @@ def validate_template_composition( template_inputs_schema: dict[str, Any], get_all_serialized_values: Callable[[str], dict[str | None, str]], ) -> TemplateValidationResult: - """Validate that ``{{field}}`` references in a template variable match its schema. + """Validate that `{{field}}` references in a template variable match its schema. Walks the composition graph starting from *variable_name*, collecting all - template strings from the variable's values and its ``@{ref}@`` dependencies, - then uses AST-based schema checking via ``check_template_compatibility`` to + template strings from the variable's values and its `@{ref}@` dependencies, + then uses AST-based schema checking via `check_template_compatibility` to find incompatible field references. Args: variable_name: Name of the template variable to validate. template_inputs_schema: JSON Schema describing the expected template inputs. - get_all_serialized_values: Function that returns ``{label_or_none: serialized_json}`` - for any variable name. ``None`` key represents the latest version. + get_all_serialized_values: Function that returns `{label_or_none: serialized_json}` + for any variable name. `None` key represents the latest version. Returns: A :class:`TemplateValidationResult` with any issues found. @@ -168,8 +168,8 @@ def detect_composition_cycles( any value of the given variable name. Returns: - The cycle path (e.g., ``['A', 'B', 'C', 'A']``) if a cycle is detected, - or ``None`` if no cycle exists. + The cycle path (e.g., `['A', 'B', 'C', 'A']`) if a cycle is detected, + or `None` if no cycle exists. """ for ref in sorted(new_references): # sort for deterministic results path = _find_cycle(variable_name, ref, get_all_references, frozenset()) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 2a49c1a4e..c70a50847 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -647,9 +647,9 @@ def get( class TemplateVariable(_BaseVariable[T_co], Generic[T_co, InputsT]): """A managed variable with integrated template rendering. - Like ``Variable``, but ``get()`` requires ``inputs`` and automatically renders - Handlebars ``{{placeholder}}`` templates in the resolved value before returning. - The pipeline is: resolve → compose ``@{refs}@`` → render ``{{}}`` → deserialize. + Like `Variable`, but `get()` requires `inputs` and automatically renders + Handlebars `{{placeholder}}` templates in the resolved value before returning. + The pipeline is: resolve → compose `@{refs}@` → render `{{}}` → deserialize. """ inputs_type: type[InputsT] @@ -703,13 +703,13 @@ def get( The resolution pipeline is: 1. Fetch serialized value from provider (or use default) - 2. Expand ``@{variable_name}@`` composition references - 3. Render ``{{placeholder}}`` Handlebars templates using ``inputs`` + 2. Expand `@{variable_name}@` composition references + 3. Render `{{placeholder}}` Handlebars templates using `inputs` 4. Deserialize to the variable's type Args: - inputs: Template context values. Typically a Pydantic ``BaseModel`` instance - matching ``inputs_type``. All ``{{placeholder}}`` expressions in the value + inputs: Template context values. Typically a Pydantic `BaseModel` instance + matching `inputs_type`. All `{{placeholder}}` expressions in the value are rendered using this context. targeting_key: Optional key for deterministic label selection (e.g., user ID). attributes: Optional attributes for condition-based targeting rules. diff --git a/tests/otel_integrations/django_test_project/django_test_site/wsgi.py b/tests/otel_integrations/django_test_project/django_test_site/wsgi.py index cd5d6f520..7a01716b6 100644 --- a/tests/otel_integrations/django_test_project/django_test_site/wsgi.py +++ b/tests/otel_integrations/django_test_project/django_test_site/wsgi.py @@ -1,7 +1,7 @@ """ WSGI config for django_test_site project. -It exposes the WSGI callable as a module-level variable named ``application``. +It exposes the WSGI callable as a module-level variable named `application`. For more information on this file, see https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ diff --git a/tests/otel_integrations/test_pytest_plugin.py b/tests/otel_integrations/test_pytest_plugin.py index 1a763a727..9ba4d9393 100644 --- a/tests/otel_integrations/test_pytest_plugin.py +++ b/tests/otel_integrations/test_pytest_plugin.py @@ -1301,7 +1301,7 @@ def test_multiple_async_tests_have_distinct_spans(logfire_pytester: pytest.Pytes the span context from the first test (stale traceparent), breaking per-test isolation. A module-scoped async fixture is used to force anyio to share its internal - runner task across tests. The runner task's ``contextvars`` snapshot is taken + runner task across tests. The runner task's `contextvars` snapshot is taken when it is first created (during the first test), so the second test inherits the first test's OTel context unless it is explicitly re-attached. """ @@ -1352,7 +1352,7 @@ def test_pytest_asyncio_span_context_propagation(logfire_pytester: pytest.Pytest Verifies that the pytest_pyfunc_call hook (which re-attaches per-test span context inside coroutine bodies) works correctly with pytest-asyncio's runner. - Note: pytest-asyncio internally calls ``contextvars.copy_context()`` per test, + Note: pytest-asyncio internally calls `contextvars.copy_context()` per test, so context propagation works even without the hook. These tests verify correctness and compatibility as defense-in-depth. diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index 221de994f..a6c979e63 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -833,7 +833,7 @@ def test_reference_falls_back_to_registered_code_default(self, config_kwargs: di assert result.composed_from[0].reason == 'code_default' def test_override_propagates_through_composition(self, config_kwargs: dict[str, Any]): - """``var.override(...)`` is honoured for ``@{var}@`` substitutions in a parent variable.""" + """`var.override(...)` is honoured for `@{var}@` substitutions in a parent variable.""" variables_config = VariablesConfig( variables={ 'greeting': VariableConfig( From 947fba27929b3a1900b03a8a073f455deb67e94b Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 11:08:50 -0600 Subject: [PATCH 28/40] Skip template_var doc examples without pydantic-handlebars On Python 3.9 the `[variables]` extra doesn't install pydantic-handlebars, so calling `logfire.template_var()` (even just for definition) raises HandlebarsDependencyError. Two doc examples in templates-and-composition.md call template_var() at module level and were failing test_runnable on Python 3.9. Detect the missing dependency at test-collection time and skip those examples, matching the existing fastapi/pydantic version-skip pattern. --- tests/test_docs.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_docs.py b/tests/test_docs.py index 4a85044a9..ef8f925f3 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -2,6 +2,7 @@ import gc import os +from importlib.util import find_spec import pydantic import pytest @@ -9,6 +10,8 @@ from logfire._internal.utils import get_version +HAS_PYDANTIC_HANDLEBARS = find_spec('pydantic_handlebars') is not None + # Prevent accidental live API calls during testing os.environ.setdefault('LOGFIRE_SEND_TO_LOGFIRE', 'false') @@ -88,6 +91,9 @@ def test_runnable(example: CodeExample, eval_example: EvalExample): if 'from fastapi' in example.source and get_version(pydantic.__version__) < get_version('2.7.0'): pytest.skip('FastAPI requires pydantic>=2.7') + if not HAS_PYDANTIC_HANDLEBARS and 'logfire.template_var' in example.source: + pytest.skip('logfire.template_var requires pydantic-handlebars (Python 3.10+)') + set_eval_config(eval_example) if eval_example.update_examples: # pragma: no cover From 0ef5fe437917cc0f16cd5b840df255544563c31a Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 12:01:54 -0600 Subject: [PATCH 29/40] Cover the uncovered composition and render branches Brings coverage on logfire/variables/{abstract,variable}.py back to 100% after the render() removal in 8a3a1a74 dropped the tests that exercised the input-shape branches of render_serialized_string and the various code-default serialization-failure paths. --- tests/test_variable_composition.py | 113 +++++++++++++++++++++++++++++ tests/test_variable_templates.py | 92 +++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index a6c979e63..d4ad9029a 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -870,6 +870,119 @@ def test_override_propagates_through_composition(self, config_kwargs: dict[str, assert result.composed_from[0].name == 'greeting' assert result.composed_from[0].reason == 'context_override' + def test_resolve_function_override_propagates_through_composition(self, config_kwargs: dict[str, Any]): + """A ResolveFunction passed to `override(...)` is called when used via composition.""" + variables_config = VariablesConfig( + variables={ + 'parent': VariableConfig( + name='parent', + json_schema={'type': 'string'}, + labels={'production': LabeledValue(version=1, serialized_value='"hello @{greeting}@"')}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + }, + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + greeting = lf.var(name='greeting', default='code_default_greeting', type=str) + parent = lf.var(name='parent', default='fallback', type=str) + + def compute_greeting(targeting_key: Any, attributes: Any) -> str: + return 'DYNAMIC_GREETING' + + with greeting.override(compute_greeting): + result = parent.get() + assert result.value == 'hello DYNAMIC_GREETING' + assert result.composed_from[0].reason == 'context_override' + + def test_unserializable_override_falls_through_to_provider(self, config_kwargs: dict[str, Any]): + """An override that fails to serialize via the child's type adapter falls through.""" + variables_config = VariablesConfig( + variables={ + 'parent': VariableConfig( + name='parent', + json_schema={'type': 'string'}, + labels={'production': LabeledValue(version=1, serialized_value='"hello @{opaque}@"')}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + }, + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + # `object` types can't be JSON-serialized via pydantic, so the override fails + # to serialize and the lookup falls through to the registered code default. + opaque = lf.var(name='opaque', default='code_default_opaque', type=object) + parent = lf.var(name='parent', default='fallback', type=str) + + with opaque.override(object()): + result = parent.get() + # Override failed to serialize; falls through to provider (which has nothing) + # then to opaque's registered code default. + assert result.value == 'hello code_default_opaque' + assert result.composed_from[0].reason == 'code_default' + + def test_unresolved_when_registered_code_default_also_fails(self, config_kwargs: dict[str, Any]): + """If both provider and the referenced variable's code default fail, ref is unresolved.""" + variables_config = VariablesConfig( + variables={ + 'parent': VariableConfig( + name='parent', + json_schema={'type': 'string'}, + labels={'production': LabeledValue(version=1, serialized_value='"hello @{opaque}@"')}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + }, + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + # A registered variable whose code default can't be JSON-serialized. + lf.var(name='opaque', default=object(), type=object) + parent = lf.var(name='parent', default='fallback', type=str) + + with pytest.warns(RuntimeWarning, match='composition failed'): + result = parent.get() + # The reference is treated as unresolved because no value source produced JSON. + assert result.value == 'fallback' + assert result.reason == 'other_error' + assert result.composed_from[0].name == 'opaque' + assert result.composed_from[0].error is not None + + +class TestCodeDefaultSerializationFailures: + """Cover paths where a variable's own code default can't be serialized.""" + + def test_unserializable_default_skips_default_composition(self, config_kwargs: dict[str, Any]): + """A non-serializable code default falls through to the plain `_get_default` value.""" + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + + sentinel = object() + var = lf.var(name='opaque', default=sentinel, type=object) + result = var.get() + # `_get_serialized_default` returned None; `_resolve_serialized_default` short-circuited + # and the plain Python default flows through. + assert result.value is sentinel + assert result.reason == 'code_default' + + def test_unserializable_default_with_references_falls_back(self, config_kwargs: dict[str, Any]): + """A code default that references missing variables still falls back to the plain default.""" + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + + # Reference an unknown variable in the code default — composition will fail, and the + # expand-and-deserialize result's reason will be 'other_error' rather than 'resolved'. + var = lf.var(name='main', default='hello @{nonexistent}@', type=str) + with pytest.warns(RuntimeWarning, match='composition failed'): + result = var.get() + assert result.value == 'hello @{nonexistent}@' # the unmodified code default + assert result.reason == 'other_error' + class TestCompositionExceptions: """Test the exception hierarchy.""" diff --git a/tests/test_variable_templates.py b/tests/test_variable_templates.py index 50f841822..46c809a04 100644 --- a/tests/test_variable_templates.py +++ b/tests/test_variable_templates.py @@ -388,3 +388,95 @@ class MyInputs(BaseModel): assert config.template_inputs_schema['type'] == 'object' assert 'user_name' in config.template_inputs_schema['properties'] assert 'count' in config.template_inputs_schema['properties'] + + +@requires_handlebars +class TestRenderSerializedString: + """Cover the input-shape branches of `render_serialized_string`.""" + + def test_inputs_none_returns_empty_context(self): + """`inputs=None` is treated as an empty context.""" + from logfire.variables.abstract import render_serialized_string + + # No placeholders to render, but the call exercises `_inputs_to_context(None)`. + assert render_serialized_string('"hello"', None) == '"hello"' + + def test_inputs_mapping(self): + """A plain dict input goes through the Mapping branch of `_inputs_to_context`.""" + from logfire.variables.abstract import render_serialized_string + + result = render_serialized_string('"Hello {{name}}!"', {'name': 'Alice'}) + assert json.loads(result) == 'Hello Alice!' + + def test_inputs_invalid_type_raises(self): + """A non-dict / non-Mapping / non-model input raises TypeError.""" + from logfire.variables.abstract import render_serialized_string + + with pytest.raises(TypeError, match='Expected a dict, Mapping, or Pydantic model'): + render_serialized_string('"x"', 42) + + def test_nested_dict_input_is_walked(self): + """Nested dict values in inputs are walked by `_wrap_safe_value`.""" + from logfire.variables.abstract import render_serialized_string + + result = render_serialized_string( + json.dumps('Hi {{user.name}} from {{user.city}}'), + {'user': {'name': 'Alice', 'city': 'London'}}, + ) + assert json.loads(result) == 'Hi Alice from London' + + def test_list_input_is_walked(self): + """List values in inputs are walked by `_wrap_safe_value`.""" + from logfire.variables.abstract import render_serialized_string + + result = render_serialized_string( + json.dumps('First: {{items.[0]}}, Second: {{items.[1]}}'), + {'items': ['apple', 'banana']}, + ) + assert json.loads(result) == 'First: apple, Second: banana' + + def test_list_value_is_walked(self): + """List values inside the rendered value are walked by `_render_json_value`.""" + from logfire.variables.abstract import render_serialized_string + + result = render_serialized_string( + json.dumps({'tags': ['Hello {{name}}', 'static']}), + {'name': 'Alice'}, + ) + assert json.loads(result) == {'tags': ['Hello Alice', 'static']} + + +@requires_handlebars +class TestTemplateVariableOverrideRender: + """Cover the `_render_default` path that fires when overriding a `TemplateVariable`.""" + + def test_override_render_failure_falls_back(self, config_kwargs: dict[str, Any]): + """A TemplateVariable override that renders to an invalid value records the exception.""" + from typing import Annotated + + from pydantic import StringConstraints, ValidationError + + class Config(BaseModel): + code: Annotated[str, StringConstraints(pattern=r'^[A-Z]+$')] + + class Inputs(BaseModel): + code: str + + lf = logfire.configure(**config_kwargs) + var = lf.template_var( + 'config', + type=Config, + default=Config(code='OK'), + inputs_type=Inputs, + ) + + # `model_construct` bypasses the constructor's validation so the override can hold + # the unrendered template; rendering then produces 'abc123', which fails the + # pattern constraint and exercises the `raise result` branch in `_render_default`. + bad_override = Config.model_construct(code='{{code}}') + with var.override(bad_override): + resolved = var.get(Inputs(code='abc123')) + + assert resolved.value == Config(code='OK') # falls back to the code default + assert resolved.reason == 'other_error' + assert isinstance(resolved.exception, ValidationError) From 51ae0334449e6d251a741674508a4add8ae203af Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 23:23:51 -0600 Subject: [PATCH 30/40] Skip handlebars-dependent coverage test on Python 3.9 test_unserializable_default_with_references_falls_back needs composition to run (and therefore pydantic_handlebars), but its enclosing class isn't @requires_handlebars. On Python 3.9 the [variables] extra doesn't install pydantic_handlebars and the test fails. Decorate the one test that needs it; the sibling test_unserializable_default_skips_default_composition stays unconditional because it exercises the no-composition short-circuit. --- tests/test_variable_composition.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_variable_composition.py b/tests/test_variable_composition.py index d4ad9029a..6b748f1ef 100644 --- a/tests/test_variable_composition.py +++ b/tests/test_variable_composition.py @@ -970,6 +970,7 @@ def test_unserializable_default_skips_default_composition(self, config_kwargs: d assert result.value is sentinel assert result.reason == 'code_default' + @requires_handlebars def test_unserializable_default_with_references_falls_back(self, config_kwargs: dict[str, Any]): """A code default that references missing variables still falls back to the plain default.""" config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) From b2a2f90c4109d201f47e90d3103da6a1c7554706 Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Wed, 20 May 2026 23:47:23 -0600 Subject: [PATCH 31/40] Export composition types from logfire.variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComposedReference, VariableCompositionError, VariableCompositionCycleError, and ResolutionReason were imported by logfire/variables/__init__.py but missing from __all__, so they weren't picked up by 'from logfire.variables import *' or by auto-generated API docs. They are part of the public surface — ComposedReference is the element type of ResolvedVariable.composed_from and ResolutionReason is the type of ResolvedVariable.reason. Add them to __all__ and import ResolutionReason explicitly. --- logfire/variables/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/logfire/variables/__init__.py b/logfire/variables/__init__.py index 509605d35..4d67278b9 100644 --- a/logfire/variables/__init__.py +++ b/logfire/variables/__init__.py @@ -15,6 +15,7 @@ ) from logfire.variables.composition import ( ComposedReference, + ResolutionReason, VariableCompositionCycleError, VariableCompositionError, ) @@ -78,10 +79,14 @@ # Context managers and utilities 'targeting_context', # Types + 'ComposedReference', + 'ResolutionReason', 'SyncMode', 'ValidationReport', # Exceptions 'VariableAlreadyExistsError', + 'VariableCompositionCycleError', + 'VariableCompositionError', 'VariableNotFoundError', 'VariableWriteError', ] From 362a227392bed8a333f65a45ac208d1faa019b63 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 11:59:46 +0200 Subject: [PATCH 32/40] revert pyi file changes for simpler PR diff --- .../_internal/integrations/pytest.pyi | 36 +++++++++---------- logfire-api/logfire_api/_internal/main.pyi | 4 +-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/logfire-api/logfire_api/_internal/integrations/pytest.pyi b/logfire-api/logfire_api/_internal/integrations/pytest.pyi index f9431313e..13c71128c 100644 --- a/logfire-api/logfire_api/_internal/integrations/pytest.pyi +++ b/logfire-api/logfire_api/_internal/integrations/pytest.pyi @@ -18,22 +18,22 @@ def pytest_configure(config: pytest.Config) -> None: def pytest_xdist_setupnodes(config: Any, specs: Any) -> None: """Inject TRACEPARENT into env before xdist spawns workers. - Called in the controller before any `makegateway()` call, so all workers - inherit the session-level trace context via `os.environ`. + Called in the controller before any ``makegateway()`` call, so all workers + inherit the session-level trace context via ``os.environ``. - NOTE: This relies on `pytest_sessionstart` (which creates the session span) - running at default priority, *before* xdist's `DSession.pytest_sessionstart` - which uses `trylast=True` and calls `setup_nodes()` → - `pytest_xdist_setupnodes`. Do not add `trylast=True` to our - `pytest_sessionstart` or this ordering guarantee breaks. + NOTE: This relies on ``pytest_sessionstart`` (which creates the session span) + running at default priority, *before* xdist's ``DSession.pytest_sessionstart`` + which uses ``trylast=True`` and calls ``setup_nodes()`` → + ``pytest_xdist_setupnodes``. Do not add ``trylast=True`` to our + ``pytest_sessionstart`` or this ordering guarantee breaks. """ def pytest_sessionstart(session: pytest.Session) -> None: """Create a session span when the test session starts. - IMPORTANT: This hook must run at default priority (no `trylast=True`). - `pytest_xdist_setupnodes` depends on the session span being active when - it injects TRACEPARENT into `os.environ` for worker processes. xdist's - `DSession.pytest_sessionstart` uses `trylast=True`, so our default-priority + IMPORTANT: This hook must run at default priority (no ``trylast=True``). + ``pytest_xdist_setupnodes`` depends on the session span being active when + it injects TRACEPARENT into ``os.environ`` for worker processes. xdist's + ``DSession.pytest_sessionstart`` uses ``trylast=True``, so our default-priority hook is guaranteed to run first. """ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None) -> Generator[None]: @@ -62,16 +62,16 @@ def logfire_pytest(request: pytest.FixtureRequest) -> Logfire: def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Generator[None]: """Re-attach the per-test span context for async test functions. - The `pytest_runtest_protocol` hook creates a span per test and attaches it - to the OTel context via `context_api.attach()` in the **synchronous** hook + The ``pytest_runtest_protocol`` hook creates a span per test and attaches it + to the OTel context via ``context_api.attach()`` in the **synchronous** hook thread. However, when tests are async (e.g. with anyio/pytest-asyncio), they - may run inside an event-loop task whose `contextvars` snapshot was taken - before the per-test span was attached (e.g. when `asyncio.Runner` reuses a - saved context on Python 3.11+). As a result, `logfire.get_context()` inside + may run inside an event-loop task whose ``contextvars`` snapshot was taken + before the per-test span was attached (e.g. when ``asyncio.Runner`` reuses a + saved context on Python 3.11+). As a result, ``logfire.get_context()`` inside an async test can return a stale traceparent from a previous test (or no context at all). - This hook wraps async test functions so that `context_api.attach()` is called + This hook wraps async test functions so that ``context_api.attach()`` is called *inside* the coroutine body, making the span visible to the test and any - callbacks (e.g. httpx event hooks) that call `logfire.get_context()`. + callbacks (e.g. httpx event hooks) that call ``logfire.get_context()``. """ diff --git a/logfire-api/logfire_api/_internal/main.pyi b/logfire-api/logfire_api/_internal/main.pyi index cda61a9c7..4bb0d8b67 100644 --- a/logfire-api/logfire_api/_internal/main.pyi +++ b/logfire-api/logfire_api/_internal/main.pyi @@ -1209,9 +1209,9 @@ class Logfire: `False` if the timeout was reached before the shutdown was completed, `True` otherwise. """ @overload - def var(self, name: str, *, default: T, description: str | None = None, template_inputs: type[Any] | None = None) -> Variable[T]: ... + def var(self, name: str, *, default: T, description: str | None = None) -> Variable[T]: ... @overload - def var(self, name: str, *, type: type[T], default: T | ResolveFunction[T], description: str | None = None, template_inputs: type[Any] | None = None) -> Variable[T]: ... + def var(self, name: str, *, type: type[T], default: T | ResolveFunction[T], description: str | None = None) -> Variable[T]: ... def variables_clear(self) -> None: """Clear all registered variables from this Logfire instance. From b8b2fa5f5e4a7d05abc8d0553d6648d5818f1f9e Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 12:20:02 +0200 Subject: [PATCH 33/40] Use logfire module shortcuts in type checking test --- tests/type_checking.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/type_checking.py b/tests/type_checking.py index 25233f01f..8ab377a2f 100644 --- a/tests/type_checking.py +++ b/tests/type_checking.py @@ -11,7 +11,7 @@ # detect whether you got a variable-provider-provided value. # Anyway, the _main_ reason it works this way is not because we prefer it, but because we can't see a way to make it a # type error, so the above argument is just a way of turning lemons into lemonade. -my_variable_2 = logfire.Logfire().var(name='my_variable_2', default=None, type=int) +my_variable_2 = logfire.var(name='my_variable_2', default=None, type=int) assert_type(my_variable_2, Variable[int | None]) @@ -19,7 +19,7 @@ class PromptInputs(BaseModel): name: str -my_template_variable = logfire.Logfire().template_var( +my_template_variable = logfire.template_var( name='my_template_variable', default='Hello {{name}}', type=str, From 17fd010b60d707070ce296b761a24dd048bd2a8c Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 13:28:17 +0200 Subject: [PATCH 34/40] Move ResolutionReason to abstract variables module --- logfire/variables/__init__.py | 2 +- logfire/variables/abstract.py | 26 ++++++++++++++++++++++++-- logfire/variables/composition.py | 27 +++------------------------ logfire/variables/variable.py | 3 +-- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/logfire/variables/__init__.py b/logfire/variables/__init__.py index 4d67278b9..2ce39433b 100644 --- a/logfire/variables/__init__.py +++ b/logfire/variables/__init__.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from logfire.variables.abstract import ( + ResolutionReason, ResolvedVariable, SyncMode, ValidationReport, @@ -15,7 +16,6 @@ ) from logfire.variables.composition import ( ComposedReference, - ResolutionReason, VariableCompositionCycleError, VariableCompositionError, ) diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index 0452a0d64..3b1a1b8d4 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -9,14 +9,13 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeVar -from logfire.variables.composition import ComposedReference, ResolutionReason - SyncMode = Literal['merge', 'replace'] if TYPE_CHECKING: from pydantic import TypeAdapter import logfire + from logfire.variables.composition import ComposedReference from logfire.variables.config import VariableConfig, VariablesConfig, VariableTypeConfig from logfire.variables.variable import _BaseVariable @@ -32,6 +31,7 @@ __all__ = ( 'ResolvedVariable', + 'ResolutionReason', 'SyncMode', 'ValidationReport', 'VariableProvider', @@ -45,6 +45,28 @@ T = TypeVar('T') T_co = TypeVar('T_co', covariant=True) +ResolutionReason = Literal[ + 'resolved', + 'context_override', + 'missing_config', + 'unrecognized_variable', + 'validation_error', + 'other_error', + 'no_provider', + 'code_default', +] +"""Why a variable (or a composed reference) resolved to its final value. + +- `resolved`: provider returned a value that was used as-is. +- `context_override`: a value set via `Variable.override(...)` was used. +- `missing_config`: the variable exists on the provider but the targeting/rollout produced no value. +- `unrecognized_variable`: the provider has no entry for the variable. +- `validation_error`: the serialized value failed deserialization. +- `other_error`: composition, rendering or other error during resolution. +- `no_provider`: no provider is configured. +- `code_default`: the variable's code-default was used because the provider had no value. +""" + if not TYPE_CHECKING: # pragma: no branch if sys.version_info < (3, 10): # pragma: no cover _dataclass = dataclass diff --git a/logfire/variables/composition.py b/logfire/variables/composition.py index 95c70ec2e..c26c4ac40 100644 --- a/logfire/variables/composition.py +++ b/logfire/variables/composition.py @@ -17,11 +17,12 @@ import json import re from dataclasses import dataclass, field -from typing import Any, Callable, Literal, Optional, Tuple # noqa: UP035 +from typing import Any, Callable, Optional, Tuple # noqa: UP035 + +from logfire.variables.abstract import ResolutionReason __all__ = ( 'MAX_COMPOSITION_DEPTH', - 'ResolutionReason', 'VariableCompositionError', 'VariableCompositionCycleError', 'ComposedReference', @@ -30,28 +31,6 @@ 'has_references', ) -ResolutionReason = Literal[ - 'resolved', - 'context_override', - 'missing_config', - 'unrecognized_variable', - 'validation_error', - 'other_error', - 'no_provider', - 'code_default', -] -"""Why a variable (or a composed reference) resolved to its final value. - -- `resolved`: provider returned a value that was used as-is. -- `context_override`: a value set via `Variable.override(...)` was used. -- `missing_config`: the variable exists on the provider but the targeting/rollout produced no value. -- `unrecognized_variable`: the provider has no entry for the variable. -- `validation_error`: the serialized value failed deserialization. -- `other_error`: composition, rendering or other error during resolution. -- `no_provider`: no provider is configured. -- `code_default`: the variable's code-default was used because the provider had no value. -""" - # Matches unescaped @{ (not preceded by \). # In JSON-serialized strings, a real backslash is \\, so \\@{ is an escaped ref. _HAS_REFERENCE = re.compile(r'(? Date: Thu, 21 May 2026 20:59:37 +0200 Subject: [PATCH 35/40] Remove _record_exception, we have a better solution from main now --- logfire/variables/variable.py | 11 +---------- tests/test_variables.py | 19 +------------------ 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 424ded34d..b1d69b2f6 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -51,15 +51,6 @@ _VARIABLE_OVERRIDES: ContextVar[dict[str, Any] | None] = ContextVar('_VARIABLE_OVERRIDES', default=None) -def _record_exception(exception: BaseException, span: logfire.LogfireSpan) -> None: - """Record an exception on a span, ignoring a CPython traceback extraction bug.""" - try: - span.record_exception(exception) - except RuntimeError as exc: - if 'generator raised StopIteration' not in str(exc): - raise - - @dataclass class _TargetingContextData: """Internal data structure for targeting context.""" @@ -612,7 +603,7 @@ def _get_result_and_record_span( ) span.set_attributes(attrs) if result.exception: - _record_exception(result.exception, span) + span.record_exception(result.exception) return result diff --git a/tests/test_variables.py b/tests/test_variables.py index 7ca6314df..af6b931d1 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -39,7 +39,7 @@ ) from logfire.variables.local import LocalVariableProvider from logfire.variables.remote import LogfireRemoteVariableProvider -from logfire.variables.variable import _record_exception, is_resolve_function +from logfire.variables.variable import is_resolve_function # ============================================================================= # Test Condition Classes @@ -2313,23 +2313,6 @@ def failing_get(*args: Any, **kwargs: Any) -> ResolvedVariable[str | None]: # Restore original lf.config._variable_provider.get_serialized_value = original - def test_record_exception_ignores_cpython_traceback_bug(self): - span = unittest.mock.Mock() - error = ValueError('Provider failed!') - span.record_exception.side_effect = RuntimeError('generator raised StopIteration') - - _record_exception(error, span) - - span.record_exception.assert_called_once_with(error) - - def test_record_exception_reraises_other_runtime_errors(self): - span = unittest.mock.Mock() - error = ValueError('Provider failed!') - span.record_exception.side_effect = RuntimeError('unexpected recording failure') - - with pytest.raises(RuntimeError, match='unexpected recording failure'): - _record_exception(error, span) - def test_variables_build_config(self, config_kwargs: dict[str, Any]): """Test that variables_build_config on a Logfire instance delegates to VariablesConfig.from_variables.""" lf = logfire.configure(**config_kwargs) From 8b04bfd9708fbc20982c96895240a7b2c730f7df Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 21:00:24 +0200 Subject: [PATCH 36/40] Fix variable helper review findings --- logfire/variables/variable.py | 10 ++-- tests/test_variables.py | 94 ++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index b1d69b2f6..1c766d29b 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -184,11 +184,11 @@ def get_template_inputs_schema(self) -> dict[str, Any] | None: """ return None - def _deserialize(self, serialized_value: str) -> T_co | ValidationError | ValueError: + def _deserialize(self, serialized_value: str) -> T_co | ValidationError | ValueError | TypeError: """Deserialize a JSON string to the variable's type, returning an Exception on failure.""" try: return self.type_adapter.validate_json(serialized_value) - except (ValidationError, ValueError) as e: + except (ValidationError, ValueError, TypeError) as e: return e @contextmanager @@ -254,6 +254,7 @@ def _resolve( attributes, span, render_fn=render_fn, + provider_exception=serialized_result.exception, ) if default_result is not None: return default_result @@ -283,7 +284,7 @@ def _render_default(self, default: Any, render_fn: Callable[[str], str]) -> T_co serialized = self.type_adapter.dump_json(default).decode('utf-8') rendered = render_fn(serialized) result = self._deserialize(rendered) - if isinstance(result, (ValidationError, ValueError)): + if isinstance(result, (ValidationError, ValueError, TypeError)): raise result return result @@ -466,6 +467,7 @@ def _resolve_serialized_default( attributes: Mapping[str, Any] | None, span: logfire.LogfireSpan | None, render_fn: Callable[[str], str] | None = None, + provider_exception: Exception | None = None, ) -> ResolvedVariable[T_co] | None: """Resolve the code default through composition/rendering when needed.""" serialized_default = self._get_serialized_default(targeting_key, attributes) @@ -486,6 +488,8 @@ def _resolve_serialized_default( # The expansion succeeded against the code default; flag the top-level # reason as 'code_default' so callers can distinguish from a provider hit. result.reason = 'code_default' + if result.exception is None: + result.exception = provider_exception return result def _get_merged_attributes(self, attributes: Mapping[str, Any] | None = None) -> Mapping[str, Any]: diff --git a/tests/test_variables.py b/tests/test_variables.py index af6b931d1..c0f95163a 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -14,7 +14,7 @@ import pytest import requests_mock as requests_mock_module from inline_snapshot import snapshot -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ValidationError, field_validator from requests import Session import logfire @@ -1665,6 +1665,55 @@ def test_get_uses_default_when_no_config(self, config_kwargs: dict[str, Any]): assert result.value == 'my_default' assert result.reason == 'code_default' + def test_get_calls_function_default_once_when_no_config(self, config_kwargs: dict[str, Any]): + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + calls = 0 + + def default(targeting_key: str | None, attributes: Mapping[str, Any] | None) -> str: + nonlocal calls + calls += 1 + return 'my_default' + + var = lf.var(name='unconfigured', default=default, type=str) + result = var.get() + assert result.value == 'my_default' + assert result.reason == 'code_default' + assert calls == 1 + + def test_get_preserves_metadata_with_deserialization_type_error(self, config_kwargs: dict[str, Any]): + class TypeErrorModel(BaseModel): + value: int + + @field_validator('value') + @classmethod + def fail_for_one(cls, value: int) -> int: + if value == 1: + raise TypeError('validator exploded') + return value + + config_kwargs['variables'] = LocalVariablesOptions( + config=VariablesConfig( + variables={ + 'type_error_var': VariableConfig( + name='type_error_var', + labels={'default': LabeledValue(version=1, serialized_value='{"value": 1}')}, + rollout=Rollout(labels={'default': 1.0}), + overrides=[], + ) + } + ) + ) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='type_error_var', default=TypeErrorModel(value=0), type=TypeErrorModel) + result = var.get() + assert result.value == TypeErrorModel(value=0) + assert isinstance(result.exception, TypeError) + assert result.reason == 'other_error' + assert result.label == 'default' + assert result.version == 1 + def test_plain_variable_has_no_template_inputs_schema(self, config_kwargs: dict[str, Any]): lf = logfire.configure(**config_kwargs) @@ -1707,6 +1756,27 @@ def test_render_fn_applies_to_context_override( assert invalid.reason == 'other_error' assert isinstance(invalid.exception, ValidationError) + class TypeErrorModel(BaseModel): + value: int + + @field_validator('value') + @classmethod + def fail_for_one(cls, value: int) -> int: + if value == 1: + raise TypeError('validator exploded') + return value + + type_error_var = lf.var(name='type_error_var', default=TypeErrorModel(value=0), type=TypeErrorModel) + + with type_error_var.override(TypeErrorModel(value=0)): + type_error_result = type_error_var._get_result_and_record_span( + None, None, None, render_fn=lambda _: '{"value": 1}' + ) + + assert type_error_result.value == TypeErrorModel(value=0) + assert type_error_result.reason == 'other_error' + assert isinstance(type_error_result.exception, TypeError) + def test_render_fn_applies_to_code_default(self, config_kwargs: dict[str, Any]): config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) lf = logfire.configure(**config_kwargs) @@ -1724,6 +1794,28 @@ def test_render_fn_applies_to_code_default(self, config_kwargs: dict[str, Any]): assert invalid.reason == 'validation_error' assert isinstance(invalid.exception, ValidationError) + def test_render_fn_preserves_provider_exception_when_using_code_default( + self, config_kwargs: dict[str, Any], monkeypatch: pytest.MonkeyPatch + ): + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + provider_error = RuntimeError('missing') + + def missing_get( + variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None + ) -> ResolvedVariable[str | None]: + return ResolvedVariable( + name=variable_name, value=None, exception=provider_error, reason='unrecognized_variable' + ) + + monkeypatch.setattr(lf.config._variable_provider, 'get_serialized_value', missing_get) + + var = lf.var(name='unconfigured', default='my_default', type=str) + result = var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"rendered_default"') + assert result.value == 'rendered_default' + assert result.reason == 'code_default' + assert result.exception is provider_error + def test_render_fn_skips_code_default_when_default_cannot_be_serialized(self, config_kwargs: dict[str, Any]): config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) lf = logfire.configure(**config_kwargs) From e7ab01d7cee73c23c3d915085da6b603543bfebd Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Thu, 21 May 2026 14:31:24 -0600 Subject: [PATCH 37/40] Address managed variable doc review threads - templates-and-composition.md: merge the template_var definition and usage into one runnable snippet that covers the conditional case, drop the one-row parameters table in favor of prose, and remove the unnecessary skip="true" on the composition example. - index.md: add the missing logfire.configure() call so the snippet does not raise LogfireNotConfiguredWarning and can run unskipped. --- .../advanced/managed-variables/index.md | 7 ++- .../templates-and-composition.md | 46 +++++-------------- 2 files changed, 16 insertions(+), 37 deletions(-) diff --git a/docs/reference/advanced/managed-variables/index.md b/docs/reference/advanced/managed-variables/index.md index 460100aac..461651416 100644 --- a/docs/reference/advanced/managed-variables/index.md +++ b/docs/reference/advanced/managed-variables/index.md @@ -124,11 +124,13 @@ For AI applications, variables often contain prompt templates with placeholders pip install 'logfire[variables]' ``` -```python skip="true" +```python from pydantic import BaseModel import logfire +logfire.configure() + class PromptInputs(BaseModel): user_name: str @@ -143,7 +145,8 @@ prompt = logfire.template_var( ) with prompt.get(PromptInputs(user_name='Alice', is_premium=True)) as resolved: - print(resolved.value) # "Hello Alice! Welcome back, valued member." + print(resolved.value) + #> Hello Alice! Welcome back, valued member. ``` Variables can also reference other variables using `@{variable_name}@` syntax, allowing you to compose values from reusable fragments that can be independently updated in the UI. diff --git a/docs/reference/advanced/managed-variables/templates-and-composition.md b/docs/reference/advanced/managed-variables/templates-and-composition.md index 1ff93f1c3..6d44214fe 100644 --- a/docs/reference/advanced/managed-variables/templates-and-composition.md +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -15,7 +15,7 @@ This is especially useful for AI applications where prompts are built from reusa ## Template Variables -A **template variable** is a variable whose value contains `{{placeholder}}` expressions that are rendered with typed inputs at resolution time. Define one with `logfire.template_var()`: +A **template variable** is a variable whose value contains `{{placeholder}}` expressions that are rendered with typed inputs at resolution time. Define one with `logfire.template_var()` and call `.get(inputs)` to resolve and render in one step: ```python from pydantic import BaseModel @@ -33,16 +33,17 @@ class PromptInputs(BaseModel): prompt = logfire.template_var( 'system_prompt', type=str, - default='Hello {{user_name}}! Welcome to our service.', + default='Hello {{user_name}}!{{#if is_premium}} Thank you for being a premium member.{{/if}}', inputs_type=PromptInputs, ) -``` -When you call `.get()`, you pass an instance of the inputs type. The SDK renders all `{{placeholder}}` expressions in the resolved value before returning: +with prompt.get(PromptInputs(user_name='Alice', is_premium=True)) as resolved: + print(resolved.value) + #> Hello Alice! Thank you for being a premium member. -```python skip="true" -with prompt.get(PromptInputs(user_name='Alice')) as resolved: - print(resolved.value) # "Hello Alice! Welcome to our service." +with prompt.get(PromptInputs(user_name='Bob')) as resolved: + print(resolved.value) + #> Hello Bob! ``` The full resolution pipeline is: @@ -52,13 +53,7 @@ The full resolution pipeline is: 3. **Render** — render `{{placeholder}}` Handlebars templates using the provided inputs 4. **Deserialize** — validate and deserialize to the variable's type -### Template Variables Parameters - -`logfire.template_var()` accepts the same parameters as `logfire.var()` plus: - -| Parameter | Description | -|-----------|-------------| -| `inputs_type` | A Pydantic `BaseModel` (or any type supported by `TypeAdapter`) describing the expected template inputs. This is used for type-safe `.get(inputs)` calls and generates a `template_inputs_schema` for validation. | +`logfire.template_var()` accepts the same parameters as `logfire.var()` plus an `inputs_type` parameter — a Pydantic `BaseModel` (or any type supported by `TypeAdapter`) describing the expected template inputs. It is used for type-safe `.get(inputs)` calls and generates a `template_inputs_schema` for validation. ### Handlebars Syntax @@ -74,25 +69,6 @@ Template variables use [Handlebars](https://handlebarsjs.com/) syntax, powered b | `{{#with obj}}...{{/with}}` | Change context | | `{{! comment }}` | Comment (not rendered) | -**Example with conditionals:** - -```python skip="true" -prompt = logfire.template_var( - 'greeting', - type=str, - default='Hello {{user_name}}!{{#if is_premium}} Thank you for being a premium member.{{/if}}', - inputs_type=PromptInputs, -) - -with prompt.get(PromptInputs(user_name='Alice', is_premium=True)) as resolved: - print(resolved.value) - # "Hello Alice! Thank you for being a premium member." - -with prompt.get(PromptInputs(user_name='Bob', is_premium=False)) as resolved: - print(resolved.value) - # "Hello Bob!" -``` - ### Structured Template Variables Template variables work with structured types too. Only string fields containing `{{placeholders}}` are rendered — other fields pass through unchanged: @@ -146,7 +122,7 @@ For example, if your `inputs_type` declares `user_name: str` and `is_premium: bo This is useful for building values from reusable fragments: -```python skip="true" +```python import logfire logfire.configure() @@ -167,7 +143,7 @@ agent_prompt = logfire.var( with agent_prompt.get() as resolved: print(resolved.value) - # "You are a helpful assistant. Never share personal data. Always be respectful." + #> You are a helpful assistant. Never share personal data. Always be respectful. ``` When `safety_rules` is updated in the Logfire UI, all variables that reference `@{safety_rules}@` automatically pick up the new value — no code changes or redeployment required. From b5e54c2b70e3b093636b24256af6bb041cf870e6 Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Thu, 21 May 2026 14:31:37 -0600 Subject: [PATCH 38/40] Move template-inputs schema off the shared variable base _BaseVariable no longer carries any template-related surface area: the public get_template_inputs_schema method is removed and to_config no longer emits a template_inputs_schema field. TemplateVariable now overrides to_config to attach its schema, and external diff/sync code uses a small module-level helper (get_template_inputs_schema) gated on isinstance(variable, TemplateVariable). Also tightens _resolve_code_default (formerly _resolve_serialized_default) so the user's default-resolution function is invoked at most once per get(), and hardens the outer error handler against the default also raising while building the error result. --- logfire/variables/abstract.py | 5 +- logfire/variables/config.py | 5 +- logfire/variables/variable.py | 112 +++++++++++++++++++--------------- tests/test_variables.py | 22 +++---- 4 files changed, 78 insertions(+), 66 deletions(-) diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index 3b1a1b8d4..c2031826f 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -648,8 +648,9 @@ def _compute_diff( local_description = variable.description server_var = server_config.variables.get(variable.name) - # Get template_inputs_schema if available - template_inputs_schema = variable.get_template_inputs_schema() + from logfire.variables.variable import get_template_inputs_schema + + template_inputs_schema = get_template_inputs_schema(variable) if server_var is None: # New variable - needs to be created diff --git a/logfire/variables/config.py b/logfire/variables/config.py index f3fcdcce0..3a51679f3 100644 --- a/logfire/variables/config.py +++ b/logfire/variables/config.py @@ -583,7 +583,7 @@ def from_variables(variables: Sequence[_BaseVariable[Any]]) -> VariablesConfig: Returns: A VariablesConfig with minimal configs for each variable. """ - from logfire.variables.variable import is_resolve_function + from logfire.variables.variable import get_template_inputs_schema, is_resolve_function variable_configs: dict[VariableName, VariableConfig] = {} for variable in variables: @@ -595,8 +595,7 @@ def from_variables(variables: Sequence[_BaseVariable[Any]]) -> VariablesConfig: if not is_resolve_function(variable.default): example = variable.type_adapter.dump_json(variable.default).decode('utf-8') - # Get template inputs schema if available - template_inputs_schema = variable.get_template_inputs_schema() + template_inputs_schema = get_template_inputs_schema(variable) config = VariableConfig( name=variable.name, diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 1c766d29b..17b19107d 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -8,7 +8,7 @@ from contextvars import ContextVar from dataclasses import dataclass, field from importlib.util import find_spec -from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar +from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast from opentelemetry.trace import get_current_span from pydantic import TypeAdapter, ValidationError @@ -176,14 +176,6 @@ def __init__( self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='variables') self.type_adapter = TypeAdapter[T_co](type) - def get_template_inputs_schema(self) -> dict[str, Any] | None: - """Return the JSON schema for template inputs. - - Returns None on plain `Variable` instances. `TemplateVariable` overrides this - to return the schema derived from its `inputs_type`. - """ - return None - def _deserialize(self, serialized_value: str) -> T_co | ValidationError | ValueError | TypeError: """Deserialize a JSON string to the variable's type, returning an Exception on failure.""" try: @@ -248,24 +240,13 @@ def _resolve( serialized_result = provider.get_serialized_value(self.name, targeting_key, attributes) if serialized_result.value is None: - default_result = self._resolve_serialized_default( + return self._resolve_code_default( provider, targeting_key, attributes, span, render_fn=render_fn, - provider_exception=serialized_result.exception, - ) - if default_result is not None: - return default_result - # Provider had no value; surface that the code default was used. - return ResolvedVariable( - name=self.name, - value=self._get_default(targeting_key, attributes), - exception=serialized_result.exception, - label=serialized_result.label, - version=serialized_result.version, - reason='code_default', + serialized_result=serialized_result, ) return self._expand_and_deserialize( @@ -276,7 +257,10 @@ def _resolve( if span and serialized_result is not None: # pragma: no cover span.set_attribute('invalid_serialized_label', serialized_result.label) span.set_attribute('invalid_serialized_value', serialized_result.value) - default = self._get_default(targeting_key, attributes) + try: + default = self._get_default(targeting_key, attributes) + except Exception: + default = cast('T_co', None) return ResolvedVariable(name=self.name, value=default, exception=e, reason='other_error') def _render_default(self, default: Any, render_fn: Callable[[str], str]) -> T_co: @@ -460,37 +444,55 @@ def _get_serialized_default( except (ValueError, TypeError, RuntimeError): return None - def _resolve_serialized_default( + def _resolve_code_default( self, provider: VariableProvider, targeting_key: str | None, attributes: Mapping[str, Any] | None, span: logfire.LogfireSpan | None, - render_fn: Callable[[str], str] | None = None, - provider_exception: Exception | None = None, - ) -> ResolvedVariable[T_co] | None: - """Resolve the code default through composition/rendering when needed.""" - serialized_default = self._get_serialized_default(targeting_key, attributes) - if serialized_default is None: - return None - if render_fn is None and not has_references(serialized_default): - return None + render_fn: Callable[[str], str] | None, + serialized_result: ResolvedVariable[str | None], + ) -> ResolvedVariable[T_co]: + """Build a ResolvedVariable for the registered code default. + + Routes through composition/rendering only when needed, and otherwise returns + the deserialized default directly. The user's default-resolution function + (when `default` is callable) is invoked at most once. + """ + default_value = self._get_default(targeting_key, attributes) + try: + serialized_default = self.type_adapter.dump_json(default_value).decode('utf-8') + except (ValueError, TypeError, RuntimeError): + serialized_default = None - result = self._expand_and_deserialize( - ResolvedVariable(name=self.name, value=serialized_default, reason='missing_config'), - provider, - targeting_key, - attributes, - span, - render_fn=render_fn, + needs_processing = serialized_default is not None and ( + render_fn is not None or has_references(serialized_default) + ) + if needs_processing: + result = self._expand_and_deserialize( + ResolvedVariable(name=self.name, value=serialized_default, reason='missing_config'), + provider, + targeting_key, + attributes, + span, + render_fn=render_fn, + ) + if result.reason == 'resolved': + # Flag the top-level reason as 'code_default' so callers can + # distinguish from a provider hit. + result.reason = 'code_default' + if result.exception is None: + result.exception = serialized_result.exception + return result + + return ResolvedVariable( + name=self.name, + value=default_value, + exception=serialized_result.exception, + label=serialized_result.label, + version=serialized_result.version, + reason='code_default', ) - if result.reason == 'resolved': - # The expansion succeeded against the code default; flag the top-level - # reason as 'code_default' so callers can distinguish from a provider hit. - result.reason = 'code_default' - if result.exception is None: - result.exception = provider_exception - return result def _get_merged_attributes(self, attributes: Mapping[str, Any] | None = None) -> Mapping[str, Any]: from logfire._internal.config import LocalVariablesOptions, VariablesOptions @@ -531,8 +533,6 @@ def to_config(self) -> VariableConfig: if not is_resolve_function(self.default): example = self.type_adapter.dump_json(self.default).decode('utf-8') - template_inputs_schema = self.get_template_inputs_schema() - return VariableConfig( name=self.name, description=self.description, @@ -541,7 +541,6 @@ def to_config(self) -> VariableConfig: overrides=[], json_schema=json_schema, example=example, - template_inputs_schema=template_inputs_schema, ) def _get_result_and_record_span( @@ -686,6 +685,12 @@ def get_template_inputs_schema(self) -> dict[str, Any]: """Return the JSON schema derived from `inputs_type`.""" return self._inputs_type_adapter.json_schema() + def to_config(self) -> VariableConfig: + """Create a VariableConfig, including `template_inputs_schema`.""" + config = super().to_config() + config.template_inputs_schema = self.get_template_inputs_schema() + return config + def get( self, inputs: InputsT, @@ -721,6 +726,13 @@ def _render_fn(serialized_json: str) -> str: return self._get_result_and_record_span(targeting_key, attributes, label, render_fn=_render_fn) +def get_template_inputs_schema(variable: _BaseVariable[Any]) -> dict[str, Any] | None: + """Return the template inputs JSON schema, or None for non-template variables.""" + if isinstance(variable, TemplateVariable): + return variable.get_template_inputs_schema() + return None + + def _first_composition_error(composed: list[ComposedReference]) -> str | None: """Return the first nested composition error, if any.""" for ref in composed: diff --git a/tests/test_variables.py b/tests/test_variables.py index c0f95163a..119b44266 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -1715,11 +1715,14 @@ def fail_for_one(cls, value: int) -> int: assert result.version == 1 def test_plain_variable_has_no_template_inputs_schema(self, config_kwargs: dict[str, Any]): + from logfire.variables.variable import get_template_inputs_schema + lf = logfire.configure(**config_kwargs) var = lf.var(name='plain_var', default='default', type=str) - assert var.get_template_inputs_schema() is None + assert not hasattr(var, 'get_template_inputs_schema') + assert get_template_inputs_schema(var) is None def test_render_fn_applies_to_provider_value( self, config_kwargs: dict[str, Any], variables_config: VariablesConfig @@ -1816,24 +1819,21 @@ def missing_get( assert result.reason == 'code_default' assert result.exception is provider_error - def test_render_fn_skips_code_default_when_default_cannot_be_serialized(self, config_kwargs: dict[str, Any]): + def test_render_fn_reports_default_function_failure(self, config_kwargs: dict[str, Any]): config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) lf = logfire.configure(**config_kwargs) + default_error = RuntimeError('default failed') + def bad_default(targeting_key: str | None, attributes: Mapping[str, Any] | None) -> str: - raise RuntimeError('default failed') + raise default_error var = lf.var(name='unconfigured', default=bad_default, type=str) - result = var._resolve_serialized_default( - lf.config.get_variable_provider(), - None, - None, - None, - render_fn=lambda value: value, - ) + result = var._get_result_and_record_span(None, None, None, render_fn=lambda value: value) - assert result is None + assert result.reason == 'other_error' + assert result.exception is default_error def test_get_preserves_provider_exception_when_using_code_default( self, config_kwargs: dict[str, Any], monkeypatch: pytest.MonkeyPatch From 85dcbadf4b34f7aab28f8590002e786418d8ab68 Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Thu, 21 May 2026 16:30:06 -0600 Subject: [PATCH 39/40] Share the lookup priority chain between resolution and composition Extracts _BaseVariable._lookup_serialized to encode the override -> provider -> registered code default priority once, and routes both _resolve (for self.name) and the composition expander's resolve_ref (for child @{ref}@ lookups) through it. The two paths can no longer drift. Behavioural notes: - context_override now returns the serialized form from _lookup_serialized; _resolve detects the reason and skips composition (preserving the literal-override semantics), then optionally renders for TemplateVariable. - When _lookup_serialized falls back to a registered code default, the caller in _resolve promotes the success reason to 'code_default' and preserves the provider's exception (matching the previous behaviour carved out in _resolve_code_default). --- logfire/variables/variable.py | 153 +++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 59 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 17b19107d..c1b032d4d 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -216,28 +216,25 @@ def _resolve( ) -> ResolvedVariable[T_co]: serialized_result: ResolvedVariable[str | None] | None = None try: - if (context_overrides := _VARIABLE_OVERRIDES.get()) is not None and self.name in context_overrides: - context_value = context_overrides[self.name] - if is_resolve_function(context_value): - context_value = context_value(targeting_key, attributes) - # For TemplateVariable (render_fn set), the override is a template - # that still gets rendered with inputs. - if render_fn is not None: - context_value = self._render_default(context_value, render_fn) - return ResolvedVariable(name=self.name, value=context_value, reason='context_override') - provider = self.logfire_instance.config.get_variable_provider() - # If explicit label is requested, try to get that specific label - if label is not None: - serialized_result = provider.get_serialized_value_for_label(self.name, label) - if serialized_result.value is not None: - return self._expand_and_deserialize( - serialized_result, provider, targeting_key, attributes, span, render_fn=render_fn - ) - # Label not found - fall through to default resolution + serialized_result = self._lookup_serialized( + self.name, + provider=provider, + targeting_key=targeting_key, + attributes=attributes, + label=label, + ) - serialized_result = provider.get_serialized_value(self.name, targeting_key, attributes) + # Context overrides skip composition expansion: the override is the + # user's literal choice, so we only optionally render (for + # TemplateVariable) and then deserialize. + if serialized_result.reason == 'context_override' and serialized_result.value is not None: + serialized = serialized_result.value + if render_fn is not None: + serialized = render_fn(serialized) + value = self.type_adapter.validate_json(serialized) + return ResolvedVariable(name=self.name, value=value, reason='context_override') if serialized_result.value is None: return self._resolve_code_default( @@ -249,9 +246,18 @@ def _resolve( serialized_result=serialized_result, ) - return self._expand_and_deserialize( + result = self._expand_and_deserialize( serialized_result, provider, targeting_key, attributes, span, render_fn=render_fn ) + # Preserve the lookup-tier signal: if the value came from the code + # default (via `_lookup_serialized`) rather than the provider, we + # promote the success reason from 'resolved' to 'code_default' and + # carry the provider's exception through so callers can surface it. + if serialized_result.reason == 'code_default' and result.reason == 'resolved': + result.reason = 'code_default' + if result.exception is None and serialized_result.exception is not None: + result.exception = serialized_result.exception + return result except Exception as e: if span and serialized_result is not None: # pragma: no cover @@ -263,6 +269,65 @@ def _resolve( default = cast('T_co', None) return ResolvedVariable(name=self.name, value=default, exception=e, reason='other_error') + def _lookup_serialized( + self, + name: str, + *, + provider: VariableProvider, + targeting_key: str | None, + attributes: Mapping[str, Any] | None, + label: str | None = None, + ) -> ResolvedVariable[str | None]: + """Resolve a variable name to its serialized value using the standard priority chain. + + Priority: context override -> provider (label-specific then default) -> registered code default. + + Used by both `_resolve` (for `self.name`) and the composition expander + (for child `@{ref}@` lookups) so the two paths can't drift. + """ + variable = self._variable_registry.get(name) + context_overrides = _VARIABLE_OVERRIDES.get() + + # 1. Context override (only for variables whose type we know) + if context_overrides is not None and name in context_overrides and variable is not None: + override_value = context_overrides[name] + if is_resolve_function(override_value): + override_value = override_value(targeting_key, attributes) + try: + serialized = variable.type_adapter.dump_json(override_value).decode('utf-8') + except (ValueError, TypeError, RuntimeError): + pass # Fall through to provider/code default + else: + return ResolvedVariable(name=name, value=serialized, reason='context_override') + + # 2. Provider (label-specific first, falling back to default targeting) + if label is not None: + provider_result = provider.get_serialized_value_for_label(name, label) + if provider_result.value is None: + provider_result = provider.get_serialized_value(name, targeting_key, attributes) + else: + provider_result = provider.get_serialized_value(name, targeting_key, attributes) + + if provider_result.value is not None: + return provider_result + + # 3. Registered code default + if variable is not None: + serialized_default = variable._get_serialized_default(targeting_key, attributes) + if serialized_default is not None: + return ResolvedVariable( + name=name, + value=serialized_default, + label=provider_result.label, + version=provider_result.version, + reason='code_default', + exception=provider_result.exception, + ) + + # No value at any tier; propagate the provider's metadata so callers + # can surface the original exception/reason. + return provider_result + def _render_default(self, default: Any, render_fn: Callable[[str], str]) -> T_co: """Serialize the default value, apply render_fn, then deserialize back.""" serialized = self.type_adapter.dump_json(default).decode('utf-8') @@ -293,50 +358,20 @@ def _expand_and_deserialize( # Expand @{references}@ if any are present if has_references(serialized_value): - context_overrides = _VARIABLE_OVERRIDES.get() def resolve_ref( ref_name: str, ) -> tuple[str | None, str | None, int | None, ResolutionReason]: - # Lookup priority mirrors _BaseVariable._resolve so that composition - # respects overrides and registered code defaults rather than only - # consulting the provider. - ref_variable = self._variable_registry.get(ref_name) - - # 1. Context override (only for variables we know the type of) - if context_overrides is not None and ref_name in context_overrides and ref_variable is not None: - override_value = context_overrides[ref_name] - if is_resolve_function(override_value): - override_value = override_value(targeting_key, attributes) - try: - serialized = ref_variable.type_adapter.dump_json(override_value).decode('utf-8') - except (ValueError, TypeError, RuntimeError): - pass # fall through to provider - else: - return (serialized, None, None, 'context_override') - - # 2. Provider - ref_resolved = provider.get_serialized_value(ref_name, targeting_key, attributes) - if ref_resolved.value is not None: - return ( - ref_resolved.value, - ref_resolved.label, - ref_resolved.version, - ref_resolved.reason, - ) - - # 3. Registered code default - if ref_variable is not None: - ref_default = ref_variable._get_serialized_default(targeting_key, attributes) - if ref_default is not None: - return (ref_default, None, None, 'code_default') - - return ( - ref_resolved.value, - ref_resolved.label, - ref_resolved.version, - ref_resolved.reason, + # Shares the lookup priority with `_resolve` so that composition + # respects overrides and registered code defaults rather than + # only consulting the provider. + ref_result = self._lookup_serialized( + ref_name, + provider=provider, + targeting_key=targeting_key, + attributes=attributes, ) + return (ref_result.value, ref_result.label, ref_result.version, ref_result.reason) try: serialized_value, composed = expand_references( From 2c76bc3054e40a6557f26d23f8f674a7eea2d7f1 Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Thu, 21 May 2026 17:10:22 -0600 Subject: [PATCH 40/40] Skip composition doc examples on Python 3.9 Composition expands `@{...}@` references through `pydantic-handlebars` via `logfire/variables/reference_syntax.py:render_once`, so any doc example using `@{...}@` requires the `[variables]` extra (Python 3.10+). Extend the test_docs skip predicate to cover that, matching the existing `logfire.template_var` skip. --- tests/test_docs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_docs.py b/tests/test_docs.py index ef8f925f3..30f187f15 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -91,8 +91,8 @@ def test_runnable(example: CodeExample, eval_example: EvalExample): if 'from fastapi' in example.source and get_version(pydantic.__version__) < get_version('2.7.0'): pytest.skip('FastAPI requires pydantic>=2.7') - if not HAS_PYDANTIC_HANDLEBARS and 'logfire.template_var' in example.source: - pytest.skip('logfire.template_var requires pydantic-handlebars (Python 3.10+)') + if not HAS_PYDANTIC_HANDLEBARS and ('logfire.template_var' in example.source or '@{' in example.source): + pytest.skip('Variable templates and composition require pydantic-handlebars (Python 3.10+)') set_eval_config(eval_example)