diff --git a/docs/reference/advanced/managed-variables/configuration-reference.md b/docs/reference/advanced/managed-variables/configuration-reference.md index 19098963b..9a95b8262 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 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/index.md b/docs/reference/advanced/managed-variables/index.md index b5c8174db..461651416 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 `@{other_variable}@` references (see [Templates and Composition](templates-and-composition.md)) ### Versions and Labels @@ -112,6 +113,46 @@ 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: + +!!! 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 +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}}!{{#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 `@{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). + ## How It Works Here's the typical workflow using the `AgentConfig` example from above: @@ -231,8 +272,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..6d44214fe --- /dev/null +++ b/docs/reference/advanced/managed-variables/templates-and-composition.md @@ -0,0 +1,220 @@ +# Template Variables and Composition + +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. + +!!! 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()` raises an error immediately so your application does not silently use an unrendered template. + +## 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()` and call `.get(inputs)` to resolve and render in one step: + +```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}}!{{#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')) as resolved: + print(resolved.value) + #> Hello Bob! +``` + +The full resolution pipeline is: + +1. **Resolve** — fetch the serialized value from the provider (or use the code default) +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 + +`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 + +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) | + +### 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, +) + +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 +``` + +### 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 `@{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: + +```python +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. @{safety_rules}@', +) + +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 `@{safety_rules}@` automatically pick up the new value — no code changes or redeployment required. + +### Composition Control Flow + +The `@{}@` syntax supports a small Handlebars-compatible subset for composing variables. It supports simple references, dotted field reads, and block helpers that branch or iterate over a top-level referenced variable: + +| Syntax | Description | +|--------|-------------| +| `@{variable_name}@` | Insert a variable's value | +| `@{variable.field}@` | Access a nested field | +| `@{#if variable}@...@{else}@...@{/if}@` | Conditional on whether a variable is set | +| `@{#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: + +```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 `@{ref}@` 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}}. @{tone_instructions}@', + inputs_type=ChatInputs, +) + +# 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." +``` + +### Cycle Detection + +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 + +`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..9706df2ad --- /dev/null +++ b/examples/python/variable_composition_demo.py @@ -0,0 +1,603 @@ +"""Demo: Variable Composition & Template Rendering with Logfire Managed Variables. + +This script demonstrates the full power of Logfire's variable composition +(@{variable_name}@ references) and Handlebars template rendering ({{field}}) +using a purely local configuration — no remote server needed. + +Key features shown: + 1. Basic variable composition: @{var}@ references expand inline + 2. Nested composition: variable A references B, which references C + 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}} + 7. Handlebars iteration: {{#each items}}...{{/each}} + 8. TemplateVariable: single-step get(inputs) with automatic rendering + 9. Rollout overrides with attribute-based conditions + 10. Composition-time conditionals: @{#if flag}@...@{else}@...@{/if}@ +""" + +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.tagline}@ + '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_email}@ --- + 'support_footer': VariableConfig( + name='support_footer', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps('Need help? Contact us at @{support_email}@.'), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + ), + # --- Composed + templated variable --- + # References @{app_name}@, @{safety_disclaimer}@, @{support_footer}@ + # 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 @{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' + '- @{safety_disclaimer}@\n\n' + '@{support_footer}@' + ), + ), + 'concise': LabeledValue( + version=1, + serialized_value=json.dumps( + '@{app_name}@ assistant. User: {{user.name}} ({{user.tier}}). ' + 'Topic: {{topic}}. Be brief. @{safety_disclaimer}@' + ), + ), + }, + 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 @{app_name}@.\n' + '{{#if details}}Details: {{details}}\n{{/if}}' + '\nA confirmation has been sent to {{user.email}}.\n\n' + '@{support_footer}@' + ), + ), + }, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + template_inputs_schema=NotificationInputs.model_json_schema(), + ), + # --- Onboarding template: demonstrates #if/#else and #each --- + # Also uses @{brand.tagline}@ subfield composition + 'onboarding_message': VariableConfig( + name='onboarding_message', + labels={ + 'production': LabeledValue( + version=1, + serialized_value=json.dumps( + '{{#if is_new_user}}' + 'Welcome to @{app_name}@, {{user.name}}! ' + '@{brand.tagline}@.\n' + '{{else}}' + 'Welcome back to @{app_name}@, {{user.name}}!\n' + '{{/if}}' + '\n' + '{{#if features}}' + 'Here are your enabled features:\n' + '{{#each features}}' + ' - {{this}}\n' + '{{/each}}' + '{{else}}' + 'No features enabled yet. Visit @{brand.support_url}@ to get started.\n' + '{{/if}}' + '\nQuestions? Reach out to @{support_email}@.' + ), + ), + }, + 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 @{app_name}@, {{user.name}}!', + 'subtitle': 'Your {{user.tier}} account is ready. @{brand.tagline}@.', + '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! @{brand.tagline}@.' + '@{else}@' + 'Welcome to @{app_name}@.' + '@{/if}@' + ), + ), + }, + 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 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}}?', + inputs_type=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: @{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})') + +# --------------------------------------------------------------------------- +# 6. Demo: Nested composition (A -> B -> C) +# --------------------------------------------------------------------------- + +section('2. Nested Composition: system_prompt -> support_footer -> support_email') + +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() + +print('Resolved value (composition + template rendering applied):') +for line in nested_result.value.split('\n'): + print(f' {line}') + +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: + print(f' -> @{{{nested.name}}}@ -> "{nested.value}"') + +# --------------------------------------------------------------------------- +# 7. Demo: Subfield references to structured variables +# --------------------------------------------------------------------------- + +section('3. Subfield Variable References: @{brand.tagline}@, @{brand.support_url}@') + +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 @{brand.field}@.') +print('For example, the onboarding_message template contains:') +print(' @{brand.tagline}@ -> expands to the tagline string') +print(' @{brand.support_url}@ -> 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') + +rendered_result = system_prompt_var.get(inputs) + +print('Rendered system prompt:') +for line in rendered_result.value.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 + @{brand.tagline}@ 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 @{brand.tagline}@ 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(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}"') + +# --------------------------------------------------------------------------- +# 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(inputs) 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(' - @{variable}@ composition: inline expansion of variable references') +print(' - Nested composition: A -> B -> C chains expand recursively') +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}@...@{else}@...@{/if}@ conditionals (composition-time)') +print(' - Structured variables: templates render inside dict string values') +print(' - TemplateVariable: single-step get(inputs) with auto-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/__init__.py b/logfire/__init__.py index fb03376ea..a8058bb0b 100644 --- a/logfire/__init__.py +++ b/logfire/__init__.py @@ -99,6 +99,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 @@ -199,6 +200,7 @@ def loguru_handler() -> Any: 'LocalVariablesOptions', 'variables', 'var', + 'template_var', 'variables_clear', 'variables_get', 'variables_push', diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 7057fdf8f..b4ed4236a 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -120,6 +120,7 @@ from ..integrations.wsgi import RequestHook as WSGIRequestHook, ResponseHook as WSGIResponseHook from ..variables import ( ResolveFunction, + TemplateVariable, ValidationReport, Variable, VariablesConfig, @@ -142,6 +143,7 @@ ExcInfo = Union[SysExcInfo, BaseException, bool, None] T = TypeVar('T') +InputsT = TypeVar('InputsT') class Logfire: @@ -161,7 +163,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: @@ -2565,6 +2567,13 @@ def var( ... ``` + 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 Logfire UI when using remote variables. @@ -2603,7 +2612,93 @@ 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, + ) + self._variables[name] = variable + + return variable + + def template_var( + self, + name: str, + *, + type: type[T], + default: T | ResolveFunction[T], + inputs_type: type[InputsT], + description: str | None = None, + ) -> TemplateVariable[T, InputsT]: + """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 `@{refs}@` → render `{{}}` → deserialize. + + ```py skip-run="true" skip-reason="requires-pydantic-handlebars" + 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: + assert 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." + ) + + from logfire.variables._handlebars import ensure_handlebars_available + + ensure_handlebars_available() + + variable = TemplateVariable[T, InputsT]( + name, + type=type, + default=default, + inputs_type=inputs_type, + description=description, + logfire_instance=self, + ) self._variables[name] = variable return variable @@ -2611,19 +2706,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, @@ -2644,7 +2740,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 errors are found. Returns: True if changes were applied (or would be applied in dry_run mode), False otherwise. @@ -2739,7 +2836,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. @@ -2841,7 +2938,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 1c2135a33..2ce39433b 100644 --- a/logfire/variables/__init__.py +++ b/logfire/variables/__init__.py @@ -14,6 +14,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 @@ -40,6 +45,7 @@ ) from logfire.variables.variable import ( ResolveFunction, + TemplateVariable, Variable, targeting_context, ) @@ -47,6 +53,7 @@ __all__ = [ # Variable classes 'Variable', + 'TemplateVariable', 'ResolvedVariable', 'ResolveFunction', # Configuration classes @@ -72,11 +79,14 @@ # Context managers and utilities 'targeting_context', # Types + 'ComposedReference', 'ResolutionReason', 'SyncMode', 'ValidationReport', # Exceptions 'VariableAlreadyExistsError', + 'VariableCompositionCycleError', + 'VariableCompositionError', 'VariableNotFoundError', 'VariableWriteError', ] @@ -114,6 +124,7 @@ def __getattr__(name: str): ) from logfire.variables.variable import ( ResolveFunction, + TemplateVariable, Variable, targeting_context, ) diff --git a/logfire/variables/_handlebars.py b/logfire/variables/_handlebars.py new file mode 100644 index 000000000..c49394e63 --- /dev/null +++ b/logfire/variables/_handlebars.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from collections.abc import Callable +from functools import cache +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 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.""" + try: + from pydantic_handlebars import SafeString, render + except ImportError as exc: # pragma: no cover + raise _dependency_error() from exc + return SafeString, render + + +@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 + + +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/abstract.py b/logfire/variables/abstract.py index b0bf06009..c2031826f 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 # ANSI color codes for terminal output ANSI_RESET = '\033[0m' @@ -38,6 +39,7 @@ 'VariableWriteError', 'VariableNotFoundError', 'VariableAlreadyExistsError', + 'render_serialized_string', ) T = TypeVar('T') @@ -129,6 +131,12 @@ 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 @{reference}@ expansion. + + Each entry is a ComposedReference for a referenced variable, including + its label, version, reason, and any nested composed_from entries. + """ def __post_init__(self): self._exit_stack = ExitStack() @@ -155,6 +163,95 @@ def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException self._exit_stack.__exit__(exc_type, exc_val, exc_tb) +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 logfire.variables._handlebars import get_handlebars_renderer + + safe_string_cls, render_fn = get_handlebars_renderer() + + 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): + 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] + 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 --- @@ -182,6 +279,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 @@ -190,6 +288,8 @@ class VariableDiff: changes: list[VariableChange] orphaned_server_variables: list[str] # Variables on server not in local code + reference_errors: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + """Warnings about variable references (non-existent refs, cycles, etc.).""" @property def has_changes(self) -> bool: @@ -240,6 +340,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_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: @@ -248,8 +350,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 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. @@ -303,10 +405,16 @@ def format(self, *, colors: bool = True) -> str: lines.append(f' Local: {local_desc}') lines.append(f' Server: {server_desc}') + # 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: - 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_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}') @@ -316,12 +424,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). @@ -335,7 +443,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: @@ -359,7 +467,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. @@ -435,8 +543,92 @@ def _check_type_label_compatibility( return incompatible +def _check_reference_errors( + variables: Sequence[_BaseVariable[object]], + server_config: VariablesConfig, +) -> list[str]: + """Check for reference errors: non-existent refs and cycles. + + Scans local variable defaults and server label values for @{references}@ + and validates that referenced variables exist and there are no cycles. + """ + 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. @@ -456,6 +648,10 @@ def _compute_diff( local_description = variable.description server_var = server_config.variables.get(variable.name) + 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 default_serialized = _get_default_serialized(variable) @@ -466,6 +662,7 @@ def _compute_diff( local_schema=local_schema, initial_value=default_serialized, local_description=local_description, + template_inputs_schema=template_inputs_schema, ) ) else: @@ -478,6 +675,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 @@ -485,7 +685,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) @@ -499,6 +699,7 @@ def _compute_diff( local_description=local_description, server_description=server_description, description_differs=description_differs, + template_inputs_schema=template_inputs_schema, ) ) else: @@ -519,7 +720,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 errors (non-existent refs, cycles) + reference_errors = _check_reference_errors(variables, server_config) + + return VariableDiff(changes=changes, orphaned_server_variables=orphaned, reference_errors=reference_errors) def _format_diff(diff: VariableDiff) -> str: @@ -582,6 +786,12 @@ def _format_diff(diff: VariableDiff) -> str: lines.append(f' Local: {local_desc}') lines.append(f' Server: {server_desc}') + # 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) @@ -615,6 +825,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) @@ -644,6 +855,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) @@ -1005,7 +1217,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, @@ -1022,7 +1234,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 errors are found. Returns: True if changes were applied (or would be applied in dry_run mode), False otherwise. @@ -1050,6 +1263,13 @@ def push_variables( # Show diff print(_format_diff(diff)) + if diff.reference_errors and strict: + print( + f'\n{ANSI_RED}Error: Reference errors 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: @@ -1101,7 +1321,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. @@ -1176,11 +1396,15 @@ def validate_variables( ) ) + # 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_errors=reference_errors, ) # --- Variable Types API --- @@ -1452,7 +1676,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, @@ -1468,7 +1692,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/composition.py b/logfire/variables/composition.py new file mode 100644 index 000000000..c26c4ac40 --- /dev/null +++ b/logfire/variables/composition.py @@ -0,0 +1,368 @@ +"""Variable composition: expand `@{variable_name}@` references in serialized values. + +This module provides pure functions for expanding variable references in serialized +JSON strings. References use the `@{variable_name}@` syntax and are expanded using +a Handlebars-compatible subset: simple references, dotted field reads, and block +helpers whose condition/iterable is a top-level referenced variable. + +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.abstract import ResolutionReason + +__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_REFERENCE = re.compile(r'(? (serialized_value, label, version, reason) +ResolveFn = Callable[[str], Tuple[Optional[str], Optional[str], Optional[int], ResolutionReason]] # noqa: UP006 + + +def has_references(serialized_value: str) -> bool: + """Quick check for any unescaped `@{` in a serialized value.""" + return _HAS_REFERENCE.search(serialized_value) is not None + + +def expand_references( + serialized_value: str, + variable_name: str, + resolve_fn: ResolveFn, + *, + _visited: tuple[str, ...] = (), + _depth: int = 0, +) -> tuple[str, list[ComposedReference]]: + """Expand `@{var}@` references in a serialized variable value. + + Uses the Handlebars engine so that `@{}@` supports simple references, + dotted field reads, and block helpers whose condition/iterable is a + top-level referenced variable while preserving `{{runtime}}` placeholders + 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 - ordered 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, + error=f"Referenced variable '{ref_name}' could not be resolved.", + ) + ) + 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. + nested_composed: list[ComposedReference] = [] + if has_references(ref_serialized): + try: + expanded_serialized, nested_composed = expand_references( + ref_serialized, + 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 @{name}@ back as literal "@{name}@". + for name in unresolved_names: + context[name] = f'@{{{name}}}@' + + # Walk the decoded value and render each string through the reference-syntax Handlebars engine. + rendered = _render_value(decoded, context, unresolved_names) + + result_serialized = json.dumps(rendered) + return result_serialized, composed + + +def find_references(serialized_value: str) -> list[str]: + """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 + (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: @{var}@ or @{var.field}@ + 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], 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 + 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('\\@{', '@{') + from logfire.variables.reference_syntax import render_once + + protected_value, protected_refs = _protect_unresolved_dotted_refs(value, unresolved_names) + rendered = render_once(protected_value, context) if has_references(protected_value) else protected_value + return _restore_unresolved_refs(rendered, protected_refs) + if isinstance(value, dict): + 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, 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 + + +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 58e223841..3a51679f3 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 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 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. @@ -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. @@ -577,7 +583,7 @@ def from_variables(variables: list[Variable[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: @@ -589,6 +595,8 @@ 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') + template_inputs_schema = get_template_inputs_schema(variable) + config = VariableConfig( name=variable.name, description=variable.description, @@ -597,6 +605,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/reference_syntax.py b/logfire/variables/reference_syntax.py new file mode 100644 index 000000000..be736b740 --- /dev/null +++ b/logfire/variables/reference_syntax.py @@ -0,0 +1,66 @@ +"""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. + +Algorithm: + 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 +""" + +from __future__ import annotations + +import re +from typing import Any + +from logfire.variables._handlebars import get_handlebars_renderer + +_REFERENCE_TAG = re.compile(r'(? 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 mark string values safe, preserving structure for dicts/lists.""" + if isinstance(value, str): + 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): + return [_protect_value(v, safe_string_cls) for v in value] # pyright: ignore[reportUnknownVariableType] + return value # bools, ints, None, etc. — pass through + + +# --------------------------------------------------------------------------- +# Core single-pass render: protect runtime placeholders → convert refs → render +# --------------------------------------------------------------------------- + + +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() + 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) + return ( + result.replace(left_runtime_placeholder, '{{') + .replace(right_runtime_placeholder, '}}') + .replace(escaped_reference_start, '@{') + ) diff --git a/logfire/variables/remote.py b/logfire/variables/remote.py index 4966efb2e..80b1fbcd5 100644 --- a/logfire/variables/remote.py +++ b/logfire/variables/remote.py @@ -554,6 +554,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/logfire/variables/template_validation.py b/logfire/variables/template_validation.py new file mode 100644 index 000000000..63e0ed96d --- /dev/null +++ b/logfire/variables/template_validation.py @@ -0,0 +1,205 @@ +"""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` +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 logfire.variables._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}} and {{path.to.identifier}} Handlebars variable references. +# Excludes block helpers ({{#if}}), closing tags ({{/if}}), partials ({{> name}}), +# and comments ({{! text}}). +TEMPLATE_FIELD_PATTERN = re.compile(r'\{\{\s*([a-zA-Z_]\w*(?:\.[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}}` or `{{path.to.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 `@{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. + + 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 6dcc31003..c1b032d4d 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -1,17 +1,27 @@ from __future__ import annotations as _annotations import inspect +import json +import warnings from collections.abc import Callable, Generator, Mapping, Sequence from contextlib import ExitStack, contextmanager 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 from typing_extensions import TypeIs +from logfire.variables._handlebars import HandlebarsError +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 @@ -23,17 +33,19 @@ from asyncio import to_thread # pragma: no cover import logfire -from logfire.variables.abstract import ResolvedVariable +from logfire.variables.abstract import ResolutionReason, ResolvedVariable __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) @@ -160,22 +172,15 @@ def __init__( self.default = default self.description = description + 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) - 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: + 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 @@ -211,66 +216,124 @@ 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: - default_result = self._resolve_serialized_default( + return self._resolve_code_default( provider, targeting_key, attributes, span, render_fn=render_fn, - ) - 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( + 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 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 _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') rendered = render_fn(serialized) result = self._deserialize(rendered) - if isinstance(result, (ValidationError, ValueError)): + if isinstance(result, (ValidationError, ValueError, TypeError)): raise result return result @@ -283,12 +346,70 @@ def _expand_and_deserialize( span: logfire.LogfireSpan | None, render_fn: Callable[[str], str] | None = None, ) -> ResolvedVariable[T_co]: + """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. + """ assert serialized_result.value is not None serialized_value = serialized_result.value + composed: list[ComposedReference] = [] + + # Expand @{references}@ if any are present + if has_references(serialized_value): + + def resolve_ref( + ref_name: str, + ) -> tuple[str | None, str | None, int | None, ResolutionReason]: + # 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( + serialized_value, + self.name, + resolve_ref, + ) + if composition_error := _first_composition_error(composed): + return self._composition_failure( + exception=VariableCompositionError(composition_error), + targeting_key=targeting_key, + attributes=attributes, + serialized_result=serialized_result, + composed=composed, + ) + except VariableCompositionError as e: + return self._composition_failure( + exception=e, + targeting_key=targeting_key, + attributes=attributes, + serialized_result=serialized_result, + composed=composed, + ) + + # Apply render_fn (template rendering) if provided if render_fn is not None: - serialized_value = render_fn(serialized_value) + try: + serialized_value = render_fn(serialized_value) + except (HandlebarsError, ValueError, TypeError) as e: + return self._composition_failure( + exception=e, + targeting_key=targeting_key, + attributes=attributes, + serialized_result=serialized_result, + composed=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 @@ -303,6 +424,7 @@ def _expand_and_deserialize( reason=reason, label=serialized_result.label, version=serialized_result.version, + composed_from=composed, ) return ResolvedVariable( @@ -311,6 +433,32 @@ def _expand_and_deserialize( label=serialized_result.label, version=serialized_result.version, reason='resolved', + 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( @@ -331,34 +479,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, - ) -> 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: - return None + render_fn: Callable[[str], str] | None, + serialized_result: ResolvedVariable[str | None], + ) -> ResolvedVariable[T_co]: + """Build a ResolvedVariable for the registered code default. - result = self._expand_and_deserialize( - ResolvedVariable(name=self.name, value=serialized_default, reason='missing_config'), - provider, - targeting_key, - attributes, - span, - render_fn=render_fn, + 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 + + 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' - return result def _get_merged_attributes(self, attributes: Mapping[str, Any] | None = None) -> Mapping[str, Any]: from logfire._internal.config import LocalVariablesOptions, VariablesOptions @@ -457,11 +626,22 @@ def _get_result_and_record_span( 'version': result.version, 'reason': result.reason, } + 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, - ) + span.record_exception(result.exception) return result @@ -493,10 +673,115 @@ def get( 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 `@{refs}@` → 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, + 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 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, + 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 `@{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 + 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 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: + 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, - variables: Sequence[Variable[Any]] | None = None, + variables: Sequence[Variable[Any] | TemplateVariable[Any, Any]] | None = None, ) -> Generator[None]: """Set the targeting key for variable resolution within this context. diff --git a/mkdocs.yml b/mkdocs.yml index 8623bdcbf..880cf63e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -232,6 +232,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 7a859578b..a2b31585d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,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://pydantic.dev/logfire" @@ -203,6 +203,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'", "claude-agent-sdk>=0 ; python_full_version >= '3.10'", ] docs = [ diff --git a/tests/test_docs.py b/tests/test_docs.py index 4a85044a9..30f187f15 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 or '@{' in example.source): + pytest.skip('Variable templates and composition require pydantic-handlebars (Python 3.10+)') + set_eval_config(eval_example) if eval_example.update_examples: # pragma: no cover diff --git a/tests/test_logfire_api.py b/tests/test_logfire_api.py index 75b8e5fef..89f6b15c5 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_push_variables.py b/tests/test_push_variables.py index a04d9f011..30cb908be 100644 --- a/tests/test_push_variables.py +++ b/tests/test_push_variables.py @@ -3,18 +3,21 @@ # pyright: reportPrivateUsage=false from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any import pytest +from pydantic import BaseModel import logfire from logfire.variables.abstract import ( + DescriptionDifference, LabelCompatibility, LabelValidationError, ValidationReport, VariableChange, VariableDiff, + _apply_changes, _check_label_compatibility, _check_type_label_compatibility, _compute_diff, @@ -23,7 +26,8 @@ _get_json_schema, ) from logfire.variables.config import LabeledValue, LabelRef, LatestVersion, Rollout, VariableConfig, VariablesConfig -from logfire.variables.variable import Variable +from logfire.variables.local import LocalVariableProvider +from logfire.variables.variable import TemplateVariable, Variable @dataclass @@ -31,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.""" @@ -220,6 +225,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 = TemplateVariable[str, NewInputs]( + name='prompt', + default='Hello {{user_name}}', + type=str, + inputs_type=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]( @@ -253,6 +302,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_errors(mock_logfire_instance: MockLogfire) -> None: + """Reference errors 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_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_error_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_errors == [] + + +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.""" + 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_errors == [] + + def test_format_diff_creates() -> None: """Test diff formatting for creates.""" diff = VariableDiff( @@ -289,6 +436,69 @@ def test_format_diff_updates() -> None: assert 'updated_feature' in output +def test_format_diff_reference_errors() -> None: + """Reference errors are shown in the formatted diff.""" + diff = VariableDiff( + changes=[], + orphaned_server_variables=[], + reference_errors=["Variable 'a' references '@{missing}@' which does not exist."], + ) + + output = _format_diff(diff) + + assert 'Reference errors' 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_errors=["Variable 'prompt' references '@{missing}@' which does not exist."], + ) + + output = report.format(colors=False) + + assert 'Validation failed' in output + assert 'Description differences' in output + assert 'Local: local' in output + assert 'Server: (none)' in output + assert 'Reference errors' in output + + +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_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_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', + 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: """Test has_changes when there are changes.""" diff = VariableDiff( diff --git a/tests/test_template_validation.py b/tests/test_template_validation.py new file mode 100644 index 000000000..eab3235e3 --- /dev/null +++ b/tests/test_template_validation.py @@ -0,0 +1,623 @@ +"""Tests for template_validation: {{field}} validation and cycle detection.""" + +# pyright: reportPrivateUsage=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from __future__ import annotations + +from importlib.util import find_spec +from types import SimpleNamespace + +import pytest + +from logfire.variables.template_validation import ( + TemplateFieldIssue, + TemplateValidationResult, + _extract_template_strings, + detect_composition_cycles, + find_template_fields, + 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 +# ============================================================================= + + +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_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'} + + 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 + + +@requires_handlebars +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_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'}}} + 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}@, 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}@"'}, + '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}@"'}, + 'b': {None: '"@{a}@"'}, + } + ) + # 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_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 {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.""" + 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}@"'}, + 'b': {None: '"@{c}@"'}, + '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: '"@{b}@"', 'prod': '"@{b}@"'}, + '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}@ @{c}@"'}, + '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_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( + { + '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..6b748f1ef --- /dev/null +++ b/tests/test_variable_composition.py @@ -0,0 +1,1013 @@ +"""Tests for variable composition (@{variable_name}@ reference expansion).""" + +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import json +from importlib.util import find_spec +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, +) + +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) +# ============================================================================= + + +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 + + +@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({}) + expanded, composed = expand_references('"hello world"', 'my_var', resolve_fn) + assert expanded == '"hello world"' + assert composed == [] + + def test_simple_string_reference(self): + """Simple @{ref}@ expands to the referenced string value.""" + resolve_fn = _make_resolve_fn({'greeting': '"Hello"'}) + expanded, composed = expand_references('"@{greeting}@ 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 @{refs}@ in one value are all expanded.""" + resolve_fn = _make_resolve_fn( + { + 'greeting': '"Hello"', + 'name': '"World"', + } + ) + 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 @{ref}@ used multiple times expands each occurrence.""" + resolve_fn = _make_resolve_fn({'word': '"echo"'}) + 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 + 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}@"', + 'b': '"World"', + } + ) + expanded, composed = expand_references('"@{a}@!"', '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_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( + { + '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('"@{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' + # 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 @{a}@ failed + assert len(b_ref.composed_from) == 1 + assert b_ref.composed_from[0].name == 'a' + 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.""" + resolve_fn = _make_resolve_fn({'a': '"@{a}@"'}) + # my_var references a, a references itself + _, composed = expand_references('"@{a}@"', 'my_var', resolve_fn) + assert len(composed) == 1 + assert composed[0].name == 'a' + # 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 == 'Circular reference detected: my_var -> a -> a' + + 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'"@{{var_{i + 1}}}@"' + else: + variables[f'var_{i}'] = '"end"' + resolve_fn = _make_resolve_fn(variables) + _, composed = expand_references('"@{var_0}@"', '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 @{nonexistent}@"', 'my_var', resolve_fn) + assert expanded == '"Hello @{nonexistent}@"' + 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(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}) + 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: @{number}@"', '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: @{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: @{obj}@"', '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': '@{safety}@ 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_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"'}) + 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('"@{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('"@{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: @{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 \@{ref}@ but @{ref}@ + # In JSON encoding, backslash must be \\, so the raw JSON is: + # "not \\@{ref}@ but @{ref}@" + raw_python_str = 'not \\@{ref}@ but @{ref}@' + serialized = json.dumps(raw_python_str) + # serialized is: "not \\@{ref}@ but @{ref}@" + expanded, composed = expand_references(serialized, 'my_var', resolve_fn) + result = json.loads(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' + + def test_escape_only(self): + r"""Only escaped references, no real references.""" + resolve_fn = _make_resolve_fn({}) + 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 @{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('"@{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 + + +class TestFindReferences: + def test_no_references(self): + assert find_references('"hello world"') == [] + + def test_single_reference(self): + assert find_references('"@{greeting}@"') == ['greeting'] + + def test_multiple_unique_references(self): + assert find_references('"@{a}@ @{b}@ @{c}@"') == ['a', 'b', 'c'] + + def test_duplicate_references(self): + """Duplicates are deduplicated, order preserved.""" + assert find_references('"@{a}@ @{b}@ @{a}@"') == ['a', 'b'] + + def test_escaped_not_matched(self): + assert find_references(r'"\\@{escaped}@"') == [] + + def test_mixed_escaped_and_real(self): + result = find_references(r'"\\@{escaped}@ @{real}@"') + assert result == ['real'] + + def test_in_structured_json(self): + 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@{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('@{greeting}@ @{#if flag}@yes@{/if}@') + result = find_references(serialized) + 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 +# ============================================================================= + + +@requires_handlebars +class TestBlockHelpers: + def test_block_if_true(self): + """@{#if flag}@yes@{else}@no@{/if}@ with truthy flag.""" + resolve_fn = _make_resolve_fn({'flag': 'true'}) + 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@{else}@no@{/if}@ with falsy flag.""" + resolve_fn = _make_resolve_fn({'flag': 'false'}) + 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}@- @{this}@@{/each}@ iterates over a list.""" + resolve_fn = _make_resolve_fn({'items': '["a", "b", "c"]'}) + 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@{/unless}@ with falsy flag.""" + resolve_fn = _make_resolve_fn({'flag': 'false'}) + 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@{/unless}@ with truthy flag shows nothing.""" + resolve_fn = _make_resolve_fn({'flag': 'true'}) + expanded, _ = expand_references('"@{#unless flag}@shown@{/unless}@"', 'my_var', resolve_fn) + assert json.loads(expanded) == '' + + def test_block_with(self): + """@{#with config}@@{name}@@{/with}@ accesses nested fields.""" + resolve_fn = _make_resolve_fn({'config': '{"name": "acme"}'}) + 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}@@{brand.tagline}@@{/if}@ — conditional with dotted access.""" + resolve_fn = _make_resolve_fn({'brand': '{"tagline": "Build faster"}'}) + expanded, _ = expand_references('"@{#if brand}@@{brand.tagline}@@{/if}@"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'Build faster' + + 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('"@{greeting}@ {{user.name}}"', 'my_var', resolve_fn) + assert json.loads(expanded) == 'Hello {{user.name}}' + + def test_escape_reference_syntax(self): + r"""Escaped \@{ref}@ becomes literal @{ref}@ in output.""" + resolve_fn = _make_resolve_fn({'ref': '"expanded"'}) + raw_python_str = '\\@{ref}@' + serialized = json.dumps(raw_python_str) + expanded, _ = expand_references(serialized, 'my_var', resolve_fn) + result = json.loads(expanded) + assert result == '@{ref}@' + + def test_escape_mixed(self): + r"""Escaped \@{escaped}@ stays literal, real @{real}@ expands.""" + resolve_fn = _make_resolve_fn({'escaped': '"X"', 'real': '"expanded"'}) + 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 == '@{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 +# ============================================================================= + + +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) + + +@requires_handlebars +class TestCompositionIntegration: + def test_simple_reference(self, config_kwargs: dict[str, Any]): + """End-to-end: variable with @{ref}@ is resolved with composition.""" + variables_config = _make_variables_config( + greeting='"Hello"', + main='"@{greeting}@ 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_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) + with pytest.warns(RuntimeWarning, match='composition failed'): + 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( + c='"end"', + b='"@{c}@_b"', + a='"@{b}@_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 are surfaced on the top-level result and a warning is emitted.""" + variables_config = _make_variables_config( + a='"@{b}@"', + b='"@{a}@"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='a', default='fallback', type=str) + 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_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}@"', + ) + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='main', default='fallback', type=str) + 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.""" + # 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: @{number}@"')}, + rollout=Rollout(labels={'production': 1.0}), + overrides=[], + latest_version=LatestVersion(version=1, serialized_value='"Value: @{number}@"'), + ), + } + 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': '@{safety}@ 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='"@{greeting}@ 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='"@{greeting}@ 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='"@{greeting}@ 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 + + @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 = _make_variables_config(main=None) if register_main else VariablesConfig(variables={}) + 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() + 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_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( + 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' + + 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' + + 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' + + @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={})) + 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.""" + + 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=('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..46c809a04 --- /dev/null +++ b/tests/test_variable_templates.py @@ -0,0 +1,482 @@ +"""Tests for variable template rendering (Handlebars {{placeholder}} support).""" + +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import builtins +import json +import os +import subprocess +import sys +import textwrap +from importlib.util import find_spec +from pathlib import Path +from typing import Any + +import pytest +from pydantic import BaseModel + +import logfire +from logfire._internal.config import LocalVariablesOptions +from logfire.variables import _handlebars +from logfire.variables.config import ( + LabeledValue, + Rollout, + VariableConfig, + 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.""" + 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=[], + ), + }, + ) + + +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 + assert logfire.var('plain_var', type=str, default='Hello') + + 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'}) + 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 + + +@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) + + +# ============================================================================= +# 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 + + +# ============================================================================= +# TemplateVariable tests +# ============================================================================= + + +@requires_handlebars +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_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, warn, 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) + + with pytest.warns(RuntimeWarning, match='composition failed'): + resolved = var.get(Inputs(name='Alice')) + + assert resolved.value == 'fallback' + assert resolved.exception is not None + assert resolved.reason == 'other_error' + + def test_composition_then_render(self, config_kwargs: dict[str, Any]): + """@{refs}@ 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}}. @{snippet}@'), + ), + }, + 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'] + + +@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) diff --git a/tests/test_variables.py b/tests/test_variables.py index 2bd2ea91c..119b44266 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,12 +1665,64 @@ 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]): + 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 @@ -1707,6 +1759,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,24 +1797,43 @@ 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_skips_code_default_when_default_cannot_be_serialized(self, config_kwargs: dict[str, Any]): + 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_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 @@ -2586,6 +2678,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 diff --git a/tests/type_checking.py b/tests/type_checking.py index 62054d07e..8ab377a2f 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 @@ -9,5 +11,19 @@ # 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]) + + +class PromptInputs(BaseModel): + name: str + + +my_template_variable = 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) diff --git a/uv.lock b/uv.lock index 4059c6aef..3951e77a1 100644 --- a/uv.lock +++ b/uv.lock @@ -3751,6 +3751,7 @@ system-metrics = [ ] variables = [ { name = "pydantic" }, + { name = "pydantic-handlebars", marker = "python_full_version >= '3.10'" }, ] wsgi = [ { name = "opentelemetry-instrumentation-wsgi" }, @@ -3846,6 +3847,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.97.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" }, @@ -3937,6 +3939,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 = "starlette", marker = "extra == 'gateway'", specifier = ">=0.37.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1" }, @@ -4015,6 +4018,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" }, @@ -7708,6 +7712,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/0b/317ffa52272ed3157733aaefa60e7a6337332dff08d9c5e3077042b2ca5b/pydantic_graph-1.97.0-py3-none-any.whl", hash = "sha256:db0c95e1686e0fd9843b558ff608fa90ed2cdc56d8b8a7249180216ad56ad764", size = 80091, upload-time = "2026-05-15T22:28:35.678Z" }, ] +[[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.14.1"