logfire-sdk 5.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- _logfire_sdk/logfire/.agents/skills/logfire-evals/SKILL.md +170 -0
- _logfire_sdk/logfire/.agents/skills/logfire-gateway/SKILL.md +156 -0
- _logfire_sdk/logfire/.agents/skills/logfire-infrastructure/SKILL.md +61 -0
- _logfire_sdk/logfire/.agents/skills/logfire-infrastructure/references/collector/host-and-infra-metrics.md +229 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/SKILL.md +292 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/auth.md +100 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/ai-sdk.md +145 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/cloudflare-and-deno.md +99 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/frameworks.md +22 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/installation-and-env.md +75 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/nextjs.md +78 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/node-runtime.md +132 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/patterns.md +184 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/project-detection.md +43 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/react-browser.md +18 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/verification-troubleshooting.md +68 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/python/integrations.md +104 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/python/logging-patterns.md +120 -0
- _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/rust/patterns.md +113 -0
- _logfire_sdk/logfire/.agents/skills/logfire-setup/SKILL.md +58 -0
- _logfire_sdk/logfire/.agents/skills/logfire-setup-offline.md +715 -0
- _logfire_sdk/logfire/__init__.py +226 -0
- _logfire_sdk/logfire/__main__.py +6 -0
- _logfire_sdk/logfire/_internal/__init__.py +14 -0
- _logfire_sdk/logfire/_internal/ast_utils.py +264 -0
- _logfire_sdk/logfire/_internal/async_.py +125 -0
- _logfire_sdk/logfire/_internal/auth.py +298 -0
- _logfire_sdk/logfire/_internal/auto_trace/__init__.py +74 -0
- _logfire_sdk/logfire/_internal/auto_trace/import_hook.py +137 -0
- _logfire_sdk/logfire/_internal/auto_trace/rewrite_ast.py +185 -0
- _logfire_sdk/logfire/_internal/auto_trace/types.py +44 -0
- _logfire_sdk/logfire/_internal/baggage.py +109 -0
- _logfire_sdk/logfire/_internal/cli/__init__.py +1081 -0
- _logfire_sdk/logfire/_internal/cli/ai_tools.py +231 -0
- _logfire_sdk/logfire/_internal/cli/auth.py +151 -0
- _logfire_sdk/logfire/_internal/cli/gateway.py +627 -0
- _logfire_sdk/logfire/_internal/cli/gateway_auth.py +313 -0
- _logfire_sdk/logfire/_internal/cli/prompt.py +50 -0
- _logfire_sdk/logfire/_internal/cli/run.py +590 -0
- _logfire_sdk/logfire/_internal/client.py +178 -0
- _logfire_sdk/logfire/_internal/collect_system_info.py +29 -0
- _logfire_sdk/logfire/_internal/config.py +2471 -0
- _logfire_sdk/logfire/_internal/config_params.py +343 -0
- _logfire_sdk/logfire/_internal/constants.py +185 -0
- _logfire_sdk/logfire/_internal/db_statement_summary.py +140 -0
- _logfire_sdk/logfire/_internal/exporters/__init__.py +1 -0
- _logfire_sdk/logfire/_internal/exporters/console.py +517 -0
- _logfire_sdk/logfire/_internal/exporters/dynamic_batch.py +49 -0
- _logfire_sdk/logfire/_internal/exporters/logs.py +30 -0
- _logfire_sdk/logfire/_internal/exporters/otlp.py +517 -0
- _logfire_sdk/logfire/_internal/exporters/processor_wrapper.py +652 -0
- _logfire_sdk/logfire/_internal/exporters/quiet_metrics.py +18 -0
- _logfire_sdk/logfire/_internal/exporters/remove_pending.py +45 -0
- _logfire_sdk/logfire/_internal/exporters/test.py +257 -0
- _logfire_sdk/logfire/_internal/exporters/wrapper.py +106 -0
- _logfire_sdk/logfire/_internal/formatter.py +379 -0
- _logfire_sdk/logfire/_internal/forwarding.py +585 -0
- _logfire_sdk/logfire/_internal/http_transport.py +193 -0
- _logfire_sdk/logfire/_internal/instrument.py +254 -0
- _logfire_sdk/logfire/_internal/integrations/__init__.py +1 -0
- _logfire_sdk/logfire/_internal/integrations/aiohttp_client.py +324 -0
- _logfire_sdk/logfire/_internal/integrations/aiohttp_server.py +22 -0
- _logfire_sdk/logfire/_internal/integrations/asgi.py +117 -0
- _logfire_sdk/logfire/_internal/integrations/asyncpg.py +21 -0
- _logfire_sdk/logfire/_internal/integrations/aws_lambda.py +36 -0
- _logfire_sdk/logfire/_internal/integrations/celery.py +20 -0
- _logfire_sdk/logfire/_internal/integrations/claude_agent_sdk.py +628 -0
- _logfire_sdk/logfire/_internal/integrations/django.py +41 -0
- _logfire_sdk/logfire/_internal/integrations/dspy.py +20 -0
- _logfire_sdk/logfire/_internal/integrations/executors.py +99 -0
- _logfire_sdk/logfire/_internal/integrations/fastapi.py +421 -0
- _logfire_sdk/logfire/_internal/integrations/flask.py +53 -0
- _logfire_sdk/logfire/_internal/integrations/google_genai.py +66 -0
- _logfire_sdk/logfire/_internal/integrations/httpx.py +577 -0
- _logfire_sdk/logfire/_internal/integrations/litellm.py +20 -0
- _logfire_sdk/logfire/_internal/integrations/litestar.py +77 -0
- _logfire_sdk/logfire/_internal/integrations/llm_providers/anthropic.py +406 -0
- _logfire_sdk/logfire/_internal/integrations/llm_providers/llm_provider.py +251 -0
- _logfire_sdk/logfire/_internal/integrations/llm_providers/openai.py +892 -0
- _logfire_sdk/logfire/_internal/integrations/llm_providers/semconv.py +194 -0
- _logfire_sdk/logfire/_internal/integrations/llm_providers/types.py +36 -0
- _logfire_sdk/logfire/_internal/integrations/llm_providers/usage.py +89 -0
- _logfire_sdk/logfire/_internal/integrations/mcp.py +191 -0
- _logfire_sdk/logfire/_internal/integrations/monty.py +155 -0
- _logfire_sdk/logfire/_internal/integrations/mysql.py +34 -0
- _logfire_sdk/logfire/_internal/integrations/openai_agents.py +460 -0
- _logfire_sdk/logfire/_internal/integrations/print.py +176 -0
- _logfire_sdk/logfire/_internal/integrations/psycopg.py +126 -0
- _logfire_sdk/logfire/_internal/integrations/pydantic_ai.py +65 -0
- _logfire_sdk/logfire/_internal/integrations/pymongo.py +40 -0
- _logfire_sdk/logfire/_internal/integrations/pytest.py +648 -0
- _logfire_sdk/logfire/_internal/integrations/redis.py +57 -0
- _logfire_sdk/logfire/_internal/integrations/requests.py +34 -0
- _logfire_sdk/logfire/_internal/integrations/snowflake.py +162 -0
- _logfire_sdk/logfire/_internal/integrations/sqlalchemy.py +70 -0
- _logfire_sdk/logfire/_internal/integrations/sqlite3.py +29 -0
- _logfire_sdk/logfire/_internal/integrations/starlette.py +50 -0
- _logfire_sdk/logfire/_internal/integrations/surrealdb.py +137 -0
- _logfire_sdk/logfire/_internal/integrations/system_metrics.py +322 -0
- _logfire_sdk/logfire/_internal/integrations/wsgi.py +34 -0
- _logfire_sdk/logfire/_internal/interactive.py +115 -0
- _logfire_sdk/logfire/_internal/json_encoder.py +344 -0
- _logfire_sdk/logfire/_internal/json_formatter.py +350 -0
- _logfire_sdk/logfire/_internal/json_schema.py +455 -0
- _logfire_sdk/logfire/_internal/json_types.py +147 -0
- _logfire_sdk/logfire/_internal/logs.py +142 -0
- _logfire_sdk/logfire/_internal/main.py +3498 -0
- _logfire_sdk/logfire/_internal/metrics.py +283 -0
- _logfire_sdk/logfire/_internal/scrubbing.py +404 -0
- _logfire_sdk/logfire/_internal/server_response.py +44 -0
- _logfire_sdk/logfire/_internal/stack_info.py +147 -0
- _logfire_sdk/logfire/_internal/tracer.py +538 -0
- _logfire_sdk/logfire/_internal/ulid.py +40 -0
- _logfire_sdk/logfire/_internal/utils.py +550 -0
- _logfire_sdk/logfire/cli.py +3 -0
- _logfire_sdk/logfire/db_api.py +393 -0
- _logfire_sdk/logfire/exceptions.py +9 -0
- _logfire_sdk/logfire/experimental/__init__.py +0 -0
- _logfire_sdk/logfire/experimental/annotations.py +80 -0
- _logfire_sdk/logfire/experimental/api_client.py +1421 -0
- _logfire_sdk/logfire/integrations/__init__.py +4 -0
- _logfire_sdk/logfire/integrations/aiohttp_client.py +12 -0
- _logfire_sdk/logfire/integrations/flask.py +26 -0
- _logfire_sdk/logfire/integrations/httpx.py +49 -0
- _logfire_sdk/logfire/integrations/logging.py +153 -0
- _logfire_sdk/logfire/integrations/loguru.py +98 -0
- _logfire_sdk/logfire/integrations/psycopg.py +23 -0
- _logfire_sdk/logfire/integrations/pydantic.py +541 -0
- _logfire_sdk/logfire/integrations/redis.py +31 -0
- _logfire_sdk/logfire/integrations/sqlalchemy.py +12 -0
- _logfire_sdk/logfire/integrations/structlog.py +55 -0
- _logfire_sdk/logfire/integrations/wsgi.py +15 -0
- _logfire_sdk/logfire/propagate.py +161 -0
- _logfire_sdk/logfire/py.typed +0 -0
- _logfire_sdk/logfire/query_client.py +701 -0
- _logfire_sdk/logfire/sampling/__init__.py +9 -0
- _logfire_sdk/logfire/sampling/_tail_sampling.py +292 -0
- _logfire_sdk/logfire/testing.py +127 -0
- _logfire_sdk/logfire/types.py +303 -0
- _logfire_sdk/logfire/variables/__init__.py +154 -0
- _logfire_sdk/logfire/variables/_handlebars.py +77 -0
- _logfire_sdk/logfire/variables/abstract.py +2027 -0
- _logfire_sdk/logfire/variables/composition.py +430 -0
- _logfire_sdk/logfire/variables/config.py +694 -0
- _logfire_sdk/logfire/variables/local.py +134 -0
- _logfire_sdk/logfire/variables/remote.py +877 -0
- _logfire_sdk/logfire/variables/template_validation.py +208 -0
- _logfire_sdk/logfire/variables/variable.py +1299 -0
- _logfire_sdk/logfire/version.py +5 -0
- logfire/.agents/skills/logfire-evals/SKILL.md +170 -0
- logfire/.agents/skills/logfire-gateway/SKILL.md +156 -0
- logfire/.agents/skills/logfire-infrastructure/SKILL.md +61 -0
- logfire/.agents/skills/logfire-infrastructure/references/collector/host-and-infra-metrics.md +229 -0
- logfire/.agents/skills/logfire-instrumentation/SKILL.md +292 -0
- logfire/.agents/skills/logfire-instrumentation/references/auth.md +100 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/ai-sdk.md +145 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/cloudflare-and-deno.md +99 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/frameworks.md +22 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/installation-and-env.md +75 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/nextjs.md +78 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/node-runtime.md +132 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/patterns.md +184 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/project-detection.md +43 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/react-browser.md +18 -0
- logfire/.agents/skills/logfire-instrumentation/references/javascript/verification-troubleshooting.md +68 -0
- logfire/.agents/skills/logfire-instrumentation/references/python/integrations.md +104 -0
- logfire/.agents/skills/logfire-instrumentation/references/python/logging-patterns.md +120 -0
- logfire/.agents/skills/logfire-instrumentation/references/rust/patterns.md +113 -0
- logfire/.agents/skills/logfire-setup/SKILL.md +58 -0
- logfire/.agents/skills/logfire-setup-offline.md +715 -0
- logfire/__init__.py +226 -0
- logfire/__main__.py +6 -0
- logfire/_internal/__init__.py +14 -0
- logfire/_internal/ast_utils.py +264 -0
- logfire/_internal/async_.py +125 -0
- logfire/_internal/auth.py +298 -0
- logfire/_internal/auto_trace/__init__.py +74 -0
- logfire/_internal/auto_trace/import_hook.py +137 -0
- logfire/_internal/auto_trace/rewrite_ast.py +185 -0
- logfire/_internal/auto_trace/types.py +44 -0
- logfire/_internal/baggage.py +109 -0
- logfire/_internal/cli/__init__.py +1081 -0
- logfire/_internal/cli/ai_tools.py +231 -0
- logfire/_internal/cli/auth.py +151 -0
- logfire/_internal/cli/gateway.py +627 -0
- logfire/_internal/cli/gateway_auth.py +313 -0
- logfire/_internal/cli/prompt.py +50 -0
- logfire/_internal/cli/run.py +590 -0
- logfire/_internal/client.py +178 -0
- logfire/_internal/collect_system_info.py +29 -0
- logfire/_internal/config.py +2471 -0
- logfire/_internal/config_params.py +343 -0
- logfire/_internal/constants.py +185 -0
- logfire/_internal/db_statement_summary.py +140 -0
- logfire/_internal/exporters/__init__.py +1 -0
- logfire/_internal/exporters/console.py +517 -0
- logfire/_internal/exporters/dynamic_batch.py +49 -0
- logfire/_internal/exporters/logs.py +30 -0
- logfire/_internal/exporters/otlp.py +517 -0
- logfire/_internal/exporters/processor_wrapper.py +652 -0
- logfire/_internal/exporters/quiet_metrics.py +18 -0
- logfire/_internal/exporters/remove_pending.py +45 -0
- logfire/_internal/exporters/test.py +257 -0
- logfire/_internal/exporters/wrapper.py +106 -0
- logfire/_internal/formatter.py +379 -0
- logfire/_internal/forwarding.py +585 -0
- logfire/_internal/http_transport.py +193 -0
- logfire/_internal/instrument.py +254 -0
- logfire/_internal/integrations/__init__.py +1 -0
- logfire/_internal/integrations/aiohttp_client.py +324 -0
- logfire/_internal/integrations/aiohttp_server.py +22 -0
- logfire/_internal/integrations/asgi.py +117 -0
- logfire/_internal/integrations/asyncpg.py +21 -0
- logfire/_internal/integrations/aws_lambda.py +36 -0
- logfire/_internal/integrations/celery.py +20 -0
- logfire/_internal/integrations/claude_agent_sdk.py +628 -0
- logfire/_internal/integrations/django.py +41 -0
- logfire/_internal/integrations/dspy.py +20 -0
- logfire/_internal/integrations/executors.py +99 -0
- logfire/_internal/integrations/fastapi.py +421 -0
- logfire/_internal/integrations/flask.py +53 -0
- logfire/_internal/integrations/google_genai.py +66 -0
- logfire/_internal/integrations/httpx.py +577 -0
- logfire/_internal/integrations/litellm.py +20 -0
- logfire/_internal/integrations/litestar.py +77 -0
- logfire/_internal/integrations/llm_providers/anthropic.py +406 -0
- logfire/_internal/integrations/llm_providers/llm_provider.py +251 -0
- logfire/_internal/integrations/llm_providers/openai.py +892 -0
- logfire/_internal/integrations/llm_providers/semconv.py +194 -0
- logfire/_internal/integrations/llm_providers/types.py +36 -0
- logfire/_internal/integrations/llm_providers/usage.py +89 -0
- logfire/_internal/integrations/mcp.py +191 -0
- logfire/_internal/integrations/monty.py +155 -0
- logfire/_internal/integrations/mysql.py +34 -0
- logfire/_internal/integrations/openai_agents.py +460 -0
- logfire/_internal/integrations/print.py +176 -0
- logfire/_internal/integrations/psycopg.py +126 -0
- logfire/_internal/integrations/pydantic_ai.py +65 -0
- logfire/_internal/integrations/pymongo.py +40 -0
- logfire/_internal/integrations/pytest.py +648 -0
- logfire/_internal/integrations/redis.py +57 -0
- logfire/_internal/integrations/requests.py +34 -0
- logfire/_internal/integrations/snowflake.py +162 -0
- logfire/_internal/integrations/sqlalchemy.py +70 -0
- logfire/_internal/integrations/sqlite3.py +29 -0
- logfire/_internal/integrations/starlette.py +50 -0
- logfire/_internal/integrations/surrealdb.py +137 -0
- logfire/_internal/integrations/system_metrics.py +322 -0
- logfire/_internal/integrations/wsgi.py +34 -0
- logfire/_internal/interactive.py +115 -0
- logfire/_internal/json_encoder.py +344 -0
- logfire/_internal/json_formatter.py +350 -0
- logfire/_internal/json_schema.py +455 -0
- logfire/_internal/json_types.py +147 -0
- logfire/_internal/logs.py +142 -0
- logfire/_internal/main.py +3498 -0
- logfire/_internal/metrics.py +283 -0
- logfire/_internal/scrubbing.py +404 -0
- logfire/_internal/server_response.py +44 -0
- logfire/_internal/stack_info.py +147 -0
- logfire/_internal/tracer.py +538 -0
- logfire/_internal/ulid.py +40 -0
- logfire/_internal/utils.py +550 -0
- logfire/cli.py +3 -0
- logfire/db_api.py +393 -0
- logfire/exceptions.py +9 -0
- logfire/experimental/__init__.py +0 -0
- logfire/experimental/annotations.py +80 -0
- logfire/experimental/api_client.py +1421 -0
- logfire/integrations/__init__.py +4 -0
- logfire/integrations/aiohttp_client.py +12 -0
- logfire/integrations/flask.py +26 -0
- logfire/integrations/httpx.py +49 -0
- logfire/integrations/logging.py +153 -0
- logfire/integrations/loguru.py +98 -0
- logfire/integrations/psycopg.py +23 -0
- logfire/integrations/pydantic.py +541 -0
- logfire/integrations/redis.py +31 -0
- logfire/integrations/sqlalchemy.py +12 -0
- logfire/integrations/structlog.py +55 -0
- logfire/integrations/wsgi.py +15 -0
- logfire/propagate.py +161 -0
- logfire/py.typed +0 -0
- logfire/query_client.py +701 -0
- logfire/sampling/__init__.py +9 -0
- logfire/sampling/_tail_sampling.py +292 -0
- logfire/testing.py +127 -0
- logfire/types.py +303 -0
- logfire/variables/__init__.py +154 -0
- logfire/variables/_handlebars.py +77 -0
- logfire/variables/abstract.py +2027 -0
- logfire/variables/composition.py +430 -0
- logfire/variables/config.py +694 -0
- logfire/variables/local.py +134 -0
- logfire/variables/remote.py +877 -0
- logfire/variables/template_validation.py +208 -0
- logfire/variables/variable.py +1299 -0
- logfire/version.py +5 -0
- logfire_sdk-5.3.0.dist-info/METADATA +140 -0
- logfire_sdk-5.3.0.dist-info/RECORD +304 -0
- logfire_sdk-5.3.0.dist-info/WHEEL +4 -0
- logfire_sdk-5.3.0.dist-info/entry_points.txt +6 -0
- logfire_sdk-5.3.0.dist-info/licenses/LICENSE +21 -0
- logfire_sdk.pth +2 -0
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
from __future__ import annotations as _annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import warnings
|
|
5
|
+
from collections.abc import Generator, Mapping, Sequence
|
|
6
|
+
from contextlib import ExitStack, contextmanager
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from importlib.util import find_spec
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
from opentelemetry.trace import get_current_span
|
|
13
|
+
from pydantic import TypeAdapter, ValidationError
|
|
14
|
+
from typing_extensions import TypeIs
|
|
15
|
+
|
|
16
|
+
from logfire.variables.composition import (
|
|
17
|
+
ComposedReference,
|
|
18
|
+
VariableCompositionError,
|
|
19
|
+
expand_references,
|
|
20
|
+
find_references,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from logfire._internal.config import TemplateMismatchPolicy
|
|
25
|
+
from logfire.variables.abstract import VariableProvider
|
|
26
|
+
from logfire.variables.config import VariableConfig
|
|
27
|
+
|
|
28
|
+
if find_spec('anyio') is not None: # pragma: no branch
|
|
29
|
+
# Use anyio for running sync functions on separate threads in an event loop if it is available
|
|
30
|
+
from anyio.to_thread import run_sync as to_thread
|
|
31
|
+
else:
|
|
32
|
+
from asyncio import to_thread # pragma: no cover
|
|
33
|
+
|
|
34
|
+
import logfire
|
|
35
|
+
from logfire.variables.abstract import ResolutionReason, ResolvedVariable
|
|
36
|
+
|
|
37
|
+
__all__ = (
|
|
38
|
+
'ResolveFunction',
|
|
39
|
+
'is_resolve_function',
|
|
40
|
+
'Variable',
|
|
41
|
+
'TemplateVariable',
|
|
42
|
+
'TemplateInputsMismatchError',
|
|
43
|
+
'targeting_context',
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TemplateInputsMismatchError(Exception):
|
|
48
|
+
"""Render-time `{{field}}` mismatch raised under the strict policy.
|
|
49
|
+
|
|
50
|
+
Raised by `TemplateVariable.get(inputs)` when the resolved template
|
|
51
|
+
references a `{{field}}` not declared in the variable's `inputs_type`
|
|
52
|
+
and the active `template_mismatch_policy` is `'error'`.
|
|
53
|
+
|
|
54
|
+
Distinct from `HandlebarsError` so it bypasses the SDK's composition-
|
|
55
|
+
failure fallback and propagates to the caller — the `'error'` policy
|
|
56
|
+
is meant to fail loudly, not silently degrade to the code default.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
T_co = TypeVar('T_co', covariant=True)
|
|
61
|
+
InputsT = TypeVar('InputsT')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_VARIABLE_OVERRIDES: ContextVar[dict[str, Any] | None] = ContextVar('_VARIABLE_OVERRIDES', default=None)
|
|
65
|
+
|
|
66
|
+
# Per-`get()`-call cache for the code-default value. Keyed by `id(variable)`.
|
|
67
|
+
# Lets `_get_default_cached` short-circuit when the same callable default
|
|
68
|
+
# would otherwise be invoked twice in one resolution. Each entry is
|
|
69
|
+
# `(ok: bool, value_or_exception: Any)`: successful invocations cache the
|
|
70
|
+
# returned value; raising invocations cache the exception so re-entry
|
|
71
|
+
# re-raises without re-invoking the callable. Set up by `Variable._resolve`
|
|
72
|
+
# at the top of the call and reset when it returns.
|
|
73
|
+
_DEFAULT_CACHE: ContextVar[dict[int, tuple[bool, Any]] | None] = ContextVar('_DEFAULT_CACHE', default=None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class _TargetingContextData:
|
|
78
|
+
"""Internal data structure for targeting context."""
|
|
79
|
+
|
|
80
|
+
default: str | None = None
|
|
81
|
+
"""Default targeting key for all variables."""
|
|
82
|
+
by_variable: dict[str, str] = field(default_factory=dict[str, str])
|
|
83
|
+
"""Variable-specific targeting keys (variable name -> targeting key)."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_TARGETING_CONTEXT: ContextVar[_TargetingContextData | None] = ContextVar('_TARGETING_CONTEXT', default=None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ResolveFunction(Protocol[T_co]):
|
|
90
|
+
"""Protocol for functions that resolve variable values based on context."""
|
|
91
|
+
|
|
92
|
+
def __call__(self, targeting_key: str | None, attributes: Mapping[str, Any] | None) -> T_co:
|
|
93
|
+
"""Resolve the variable value given a targeting key and attributes."""
|
|
94
|
+
raise NotImplementedError # pragma: no cover
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _RenderFunction(Protocol):
|
|
98
|
+
def __call__(self, serialized_json: str, /) -> str:
|
|
99
|
+
"""Render a serialized JSON value before deserialization."""
|
|
100
|
+
raise NotImplementedError # pragma: no cover
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_resolve_function(f: Any) -> TypeIs[ResolveFunction[Any]]:
|
|
104
|
+
"""Check if a callable matches the ResolveFunction signature.
|
|
105
|
+
|
|
106
|
+
A resolve function is any callable that can be called with exactly two positional arguments
|
|
107
|
+
(targeting_key and attributes). This includes:
|
|
108
|
+
- Functions/lambdas with exactly 2 parameters
|
|
109
|
+
- Functions with 2+ parameters where all after the first 2 have defaults
|
|
110
|
+
- Functions with *args or **kwargs
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
f: The object to check.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if the callable can be invoked with two positional arguments.
|
|
117
|
+
"""
|
|
118
|
+
if not callable(f):
|
|
119
|
+
return False
|
|
120
|
+
try:
|
|
121
|
+
signature = inspect.signature(f)
|
|
122
|
+
except (ValueError, TypeError):
|
|
123
|
+
# Can't get signature (e.g., built-in), assume not a resolve function
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
params = list(signature.parameters.values())
|
|
127
|
+
if not params:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
# Count required positional parameters and check for *args
|
|
131
|
+
required_positional = 0
|
|
132
|
+
has_var_positional = False
|
|
133
|
+
optional_positional = 0
|
|
134
|
+
|
|
135
|
+
for param in params:
|
|
136
|
+
if param.kind == inspect.Parameter.VAR_POSITIONAL:
|
|
137
|
+
has_var_positional = True
|
|
138
|
+
elif param.kind == inspect.Parameter.VAR_KEYWORD:
|
|
139
|
+
# **kwargs doesn't affect positional argument count
|
|
140
|
+
pass
|
|
141
|
+
elif param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
|
|
142
|
+
if param.default is inspect.Parameter.empty:
|
|
143
|
+
required_positional += 1
|
|
144
|
+
else:
|
|
145
|
+
optional_positional += 1
|
|
146
|
+
elif param.kind == inspect.Parameter.KEYWORD_ONLY: # pragma: no branch
|
|
147
|
+
# Keyword-only params don't affect positional argument count
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
# Can be called with exactly 2 positional args if:
|
|
151
|
+
# - Has *args (can accept any number), OR
|
|
152
|
+
# - Required positional params <= 2 AND total positional params >= 2
|
|
153
|
+
total_positional = required_positional + optional_positional
|
|
154
|
+
if has_var_positional:
|
|
155
|
+
return required_positional <= 2
|
|
156
|
+
else:
|
|
157
|
+
return required_positional <= 2 and total_positional >= 2
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _emit_resolution_warning(message: str, *, stacklevel: int = 3) -> None:
|
|
161
|
+
"""Emit a non-fatal resolution warning without letting it abort or corrupt resolution.
|
|
162
|
+
|
|
163
|
+
Resolution is *filter-independent*: a ``-W error`` / ``filterwarnings=error`` config would
|
|
164
|
+
otherwise turn one of these informational ``RuntimeWarning``s into an exception, which the
|
|
165
|
+
broad fallback ``except`` in ``_resolve`` then catches -- replacing the correctly-computed
|
|
166
|
+
``ResolvedVariable`` (whose ``reason``/``exception`` already carry the real signal) with a
|
|
167
|
+
bogus ``reason='other_error'`` and the ``RuntimeWarning`` as its exception. Suppressing the
|
|
168
|
+
escalation here guarantees callers always get the structured result regardless of the active
|
|
169
|
+
warning filter; the warning is still shown whenever the filter permits.
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
warnings.warn(message, category=RuntimeWarning, stacklevel=stacklevel)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# Stage of the resolution pipeline that a `_ResolveAttempt` failed at. Drives both
|
|
178
|
+
# the fallback warning text and the resulting `ResolutionReason` ('validation' maps
|
|
179
|
+
# to 'validation_error', everything else to 'other_error').
|
|
180
|
+
_FailureStage = str # 'composition' | 'template rendering' | 'validation'
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class _ResolveAttempt:
|
|
185
|
+
"""Outcome of a single compose -> render -> deserialize attempt.
|
|
186
|
+
|
|
187
|
+
`ok=True` carries the successful `result`. `ok=False` carries the
|
|
188
|
+
`exception` and the pipeline `stage` that failed, which the caller uses to
|
|
189
|
+
decide how to fall back (e.g. a `'composition'` failure on the code default
|
|
190
|
+
triggers a non-strict retry).
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
ok: bool
|
|
194
|
+
result: ResolvedVariable[Any] | None = None
|
|
195
|
+
exception: Exception | None = None
|
|
196
|
+
stage: _FailureStage = ''
|
|
197
|
+
composed: list[ComposedReference] = field(default_factory=list['ComposedReference'])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Variable(Generic[T_co]):
|
|
201
|
+
"""A managed variable that can be resolved dynamically based on configuration."""
|
|
202
|
+
|
|
203
|
+
name: str
|
|
204
|
+
"""Unique name identifying this variable."""
|
|
205
|
+
value_type: type[T_co]
|
|
206
|
+
"""The expected type of this variable's values."""
|
|
207
|
+
default: T_co | ResolveFunction[T_co]
|
|
208
|
+
"""Default value or function to compute the default."""
|
|
209
|
+
description: str | None
|
|
210
|
+
"""Description of the variable."""
|
|
211
|
+
|
|
212
|
+
logfire_instance: logfire.Logfire
|
|
213
|
+
"""The Logfire instance this variable is associated with."""
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
name: str,
|
|
218
|
+
*,
|
|
219
|
+
type: type[T_co],
|
|
220
|
+
default: T_co | ResolveFunction[T_co],
|
|
221
|
+
description: str | None = None,
|
|
222
|
+
logfire_instance: logfire.Logfire,
|
|
223
|
+
):
|
|
224
|
+
"""Create a new managed variable.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
name: Unique name identifying this variable.
|
|
228
|
+
type: The expected type of this variable's values, used for validation.
|
|
229
|
+
default: Default value to use when no configuration is found, or a function
|
|
230
|
+
that computes the default based on targeting_key and attributes.
|
|
231
|
+
description: Optional human-readable description of what this variable controls.
|
|
232
|
+
logfire_instance: The Logfire instance this variable is associated with. Used to determine config, etc.
|
|
233
|
+
"""
|
|
234
|
+
self.name = name
|
|
235
|
+
self.value_type = type
|
|
236
|
+
self.default = default
|
|
237
|
+
self.description = description
|
|
238
|
+
|
|
239
|
+
self._variable_registry = logfire_instance._variables # pyright: ignore[reportPrivateUsage]
|
|
240
|
+
self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='variables')
|
|
241
|
+
self.type_adapter = TypeAdapter[T_co](type)
|
|
242
|
+
|
|
243
|
+
def _deserialize(self, serialized_value: str) -> T_co | ValidationError | ValueError | TypeError:
|
|
244
|
+
"""Deserialize a JSON string to the variable's type, returning an Exception on failure."""
|
|
245
|
+
try:
|
|
246
|
+
return self.type_adapter.validate_json(serialized_value)
|
|
247
|
+
except (ValidationError, ValueError, TypeError) as e:
|
|
248
|
+
return e
|
|
249
|
+
|
|
250
|
+
@contextmanager
|
|
251
|
+
def override(self, value: T_co | ResolveFunction[T_co]) -> Generator[None]:
|
|
252
|
+
"""Context manager to temporarily override this variable's value.
|
|
253
|
+
|
|
254
|
+
Inside the `with` block, every `get()` call on this variable returns
|
|
255
|
+
*value* (or the result of calling it as a `ResolveFunction`) instead
|
|
256
|
+
of consulting the provider or code default.
|
|
257
|
+
|
|
258
|
+
## Composition still applies
|
|
259
|
+
|
|
260
|
+
Overrides **do** participate in `@{ref}@` composition: *value* runs
|
|
261
|
+
through the same compose → render → deserialize pipeline as a stored
|
|
262
|
+
value, so it can stand in for a candidate stored value (e.g. during
|
|
263
|
+
iterative optimization) and resolve identically to how it would once
|
|
264
|
+
pushed. If you override with the string `'hi @{user}@'`, the
|
|
265
|
+
`@{user}@` *is* expanded against the live provider/config.
|
|
266
|
+
|
|
267
|
+
## Template rendering still applies to TemplateVariable
|
|
268
|
+
|
|
269
|
+
For `TemplateVariable.get(inputs)`, `{{...}}` rendering against
|
|
270
|
+
*inputs* runs on the override the same way it would on a
|
|
271
|
+
provider value — *as long as the override is JSON-serializable*.
|
|
272
|
+
For example, overriding with `'Hi {{name}}'` and calling
|
|
273
|
+
`get(Inputs(name='Alice'))` yields `'Hi Alice'`.
|
|
274
|
+
|
|
275
|
+
## Unserializable overrides come back verbatim
|
|
276
|
+
|
|
277
|
+
If *value* can't be serialized through the variable's type adapter
|
|
278
|
+
(e.g. an arbitrary Python object on a `Variable[object]`), the
|
|
279
|
+
override is returned exactly as you passed it in, with no
|
|
280
|
+
serialize/deserialize round-trip and no template render pass. This
|
|
281
|
+
matches the "literal user choice" intent — a non-JSON Python value
|
|
282
|
+
is by definition not a template, so there's nothing to render.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
value: The value to use within this context, or a function that computes
|
|
286
|
+
the value based on targeting_key and attributes.
|
|
287
|
+
"""
|
|
288
|
+
current = _VARIABLE_OVERRIDES.get() or {}
|
|
289
|
+
token = _VARIABLE_OVERRIDES.set({**current, self.name: value})
|
|
290
|
+
try:
|
|
291
|
+
yield
|
|
292
|
+
finally:
|
|
293
|
+
_VARIABLE_OVERRIDES.reset(token)
|
|
294
|
+
|
|
295
|
+
async def refresh(self, force: bool = False):
|
|
296
|
+
"""Asynchronously refresh the variable."""
|
|
297
|
+
await to_thread(self.refresh_sync, force)
|
|
298
|
+
|
|
299
|
+
def refresh_sync(self, force: bool = False):
|
|
300
|
+
"""Synchronously refresh the variable."""
|
|
301
|
+
self.logfire_instance.config.get_variable_provider().refresh(force=force)
|
|
302
|
+
|
|
303
|
+
def _resolve(
|
|
304
|
+
self,
|
|
305
|
+
targeting_key: str | None,
|
|
306
|
+
attributes: Mapping[str, Any] | None,
|
|
307
|
+
span: logfire.LogfireSpan | None,
|
|
308
|
+
label: str | None = None,
|
|
309
|
+
render_fn: _RenderFunction | None = None,
|
|
310
|
+
) -> ResolvedVariable[T_co]:
|
|
311
|
+
# `_DEFAULT_CACHE` memoises the code-default value across every
|
|
312
|
+
# `_get_default_cached` call inside this `get()` invocation, so a
|
|
313
|
+
# callable default isn't re-invoked when a downstream fallback path also
|
|
314
|
+
# needs it. Both values and exceptions are cached.
|
|
315
|
+
cache_token = _DEFAULT_CACHE.set({})
|
|
316
|
+
try:
|
|
317
|
+
return self._resolve_inner(targeting_key, attributes, span, label, render_fn)
|
|
318
|
+
finally:
|
|
319
|
+
_DEFAULT_CACHE.reset(cache_token)
|
|
320
|
+
|
|
321
|
+
def _resolve_inner(
|
|
322
|
+
self,
|
|
323
|
+
targeting_key: str | None,
|
|
324
|
+
attributes: Mapping[str, Any] | None,
|
|
325
|
+
span: logfire.LogfireSpan | None,
|
|
326
|
+
label: str | None,
|
|
327
|
+
render_fn: _RenderFunction | None,
|
|
328
|
+
) -> ResolvedVariable[T_co]:
|
|
329
|
+
serialized_result: ResolvedVariable[str | None] | None = None
|
|
330
|
+
try:
|
|
331
|
+
# Top-level context-override fast path: handled here, before
|
|
332
|
+
# `_lookup_serialized` even sees the name. The override runs
|
|
333
|
+
# through the same compose (`@{ref}@`) → render (`{{}}`) →
|
|
334
|
+
# deserialize pipeline as a stored value (see
|
|
335
|
+
# `_resolve_context_override`), and the round-trip through
|
|
336
|
+
# dump_json / validate_json that `_lookup_serialized` would
|
|
337
|
+
# otherwise perform silently drops any value that isn't
|
|
338
|
+
# JSON-serializable. Restore the pre-#1951 behaviour: if the
|
|
339
|
+
# override serializes, take the compose/render path; if it
|
|
340
|
+
# doesn't, return the typed Python value verbatim.
|
|
341
|
+
context_overrides = _VARIABLE_OVERRIDES.get()
|
|
342
|
+
if context_overrides is not None and self.name in context_overrides:
|
|
343
|
+
return self._resolve_context_override(
|
|
344
|
+
context_overrides[self.name], targeting_key, attributes, span, render_fn
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
provider = self.logfire_instance.config.get_variable_provider()
|
|
348
|
+
|
|
349
|
+
serialized_result = self._lookup_serialized(
|
|
350
|
+
self.name,
|
|
351
|
+
provider=provider,
|
|
352
|
+
targeting_key=targeting_key,
|
|
353
|
+
attributes=attributes,
|
|
354
|
+
label=label,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if serialized_result.value is None:
|
|
358
|
+
return self._resolve_code_default(
|
|
359
|
+
targeting_key,
|
|
360
|
+
attributes,
|
|
361
|
+
serialized_result=serialized_result,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if serialized_result.reason == 'code_default':
|
|
365
|
+
# The lookup chain already fell through to the (serializable) code
|
|
366
|
+
# default. Compose it the lenient way (strict, then non-strict, then
|
|
367
|
+
# raw) — there's nothing further to fall back to.
|
|
368
|
+
return self._resolve_code_default_value(
|
|
369
|
+
serialized_result.value,
|
|
370
|
+
provider,
|
|
371
|
+
targeting_key,
|
|
372
|
+
attributes,
|
|
373
|
+
span,
|
|
374
|
+
render_fn,
|
|
375
|
+
trigger_exc=None,
|
|
376
|
+
trigger_stage='',
|
|
377
|
+
label=None,
|
|
378
|
+
version=None,
|
|
379
|
+
provider_exception=serialized_result.exception,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# A provider (or label-specific) value: compose it strictly, so any
|
|
383
|
+
# unresolved `@{ref}@`/`@{ref.field}@` (or cycle/parse/depth) falls back
|
|
384
|
+
# to the code default rather than rendering empty in place.
|
|
385
|
+
attempt = self._try_resolve(
|
|
386
|
+
serialized_result, provider, targeting_key, attributes, span, render_fn, strict=True
|
|
387
|
+
)
|
|
388
|
+
if attempt.ok:
|
|
389
|
+
# A provider/label value carries no exception of its own (providers don't attach
|
|
390
|
+
# one to a value that's present); the provider-error cases all return value=None
|
|
391
|
+
# and flow through the code-default path below, which surfaces the exception.
|
|
392
|
+
return cast('ResolvedVariable[T_co]', attempt.result)
|
|
393
|
+
return self._resolve_code_default_value(
|
|
394
|
+
self._get_serialized_default(targeting_key, attributes),
|
|
395
|
+
provider,
|
|
396
|
+
targeting_key,
|
|
397
|
+
attributes,
|
|
398
|
+
span,
|
|
399
|
+
render_fn,
|
|
400
|
+
trigger_exc=attempt.exception,
|
|
401
|
+
trigger_stage=attempt.stage,
|
|
402
|
+
trigger_composed=attempt.composed,
|
|
403
|
+
label=serialized_result.label,
|
|
404
|
+
version=serialized_result.version,
|
|
405
|
+
provider_exception=serialized_result.exception,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
except TemplateInputsMismatchError:
|
|
409
|
+
# The `'error'` template_mismatch_policy explicitly opts into a
|
|
410
|
+
# loud failure mode — bypass the default-fallback path and let
|
|
411
|
+
# the exception reach the caller.
|
|
412
|
+
raise
|
|
413
|
+
except Exception as e:
|
|
414
|
+
if span and serialized_result is not None: # pragma: no cover
|
|
415
|
+
span.set_attribute('invalid_serialized_label', serialized_result.label)
|
|
416
|
+
span.set_attribute('invalid_serialized_value', serialized_result.value)
|
|
417
|
+
try:
|
|
418
|
+
default = self._get_default_cached(targeting_key, attributes)
|
|
419
|
+
except Exception as default_exc:
|
|
420
|
+
default = cast('T_co', None)
|
|
421
|
+
_emit_resolution_warning(
|
|
422
|
+
f"Variable '{self.name}' could not be resolved and its code default raised; "
|
|
423
|
+
f'returning None: {default_exc}'
|
|
424
|
+
)
|
|
425
|
+
return ResolvedVariable(name=self.name, value=default, exception=e, reason='other_error')
|
|
426
|
+
|
|
427
|
+
def _resolve_context_override(
|
|
428
|
+
self,
|
|
429
|
+
override_value: T_co | ResolveFunction[T_co],
|
|
430
|
+
targeting_key: str | None,
|
|
431
|
+
attributes: Mapping[str, Any] | None,
|
|
432
|
+
span: logfire.LogfireSpan | None,
|
|
433
|
+
render_fn: _RenderFunction | None,
|
|
434
|
+
) -> ResolvedVariable[T_co]:
|
|
435
|
+
"""Return a resolution for the top-level context override.
|
|
436
|
+
|
|
437
|
+
When the override value serializes cleanly, run it through the same
|
|
438
|
+
compose (`@{ref}@`) → render (`{{}}`) → deserialize pipeline a stored
|
|
439
|
+
value takes, so an override can stand in for a candidate stored value
|
|
440
|
+
(e.g. during iterative optimization) and resolve identically to how it
|
|
441
|
+
would once pushed. `@{ref}@` references are expanded against the live
|
|
442
|
+
provider/config, so the override participates in composition.
|
|
443
|
+
|
|
444
|
+
When the value doesn't serialize — common for custom Python types,
|
|
445
|
+
arbitrary objects, etc. — return the user's value verbatim under
|
|
446
|
+
`reason='context_override'` (no compose/render pass possible without a
|
|
447
|
+
string form). Returning verbatim is the legacy behaviour Devin / Alex
|
|
448
|
+
flagged on #1951; the previous implementation silently dropped these
|
|
449
|
+
values back to the provider / code default.
|
|
450
|
+
"""
|
|
451
|
+
if is_resolve_function(override_value):
|
|
452
|
+
resolved_value = cast('T_co', override_value(targeting_key, attributes))
|
|
453
|
+
else:
|
|
454
|
+
resolved_value = cast('T_co', override_value)
|
|
455
|
+
try:
|
|
456
|
+
serialized = self.type_adapter.dump_json(resolved_value).decode('utf-8')
|
|
457
|
+
except (ValueError, TypeError, RuntimeError):
|
|
458
|
+
return ResolvedVariable(name=self.name, value=resolved_value, reason='context_override')
|
|
459
|
+
|
|
460
|
+
provider = self.logfire_instance.config.get_variable_provider()
|
|
461
|
+
serialized_result = ResolvedVariable[str | None](name=self.name, value=serialized, reason='context_override')
|
|
462
|
+
# Compose the override strictly (it stands in for a candidate provider value), so a
|
|
463
|
+
# missing reference falls back to the code default just as a stored value would.
|
|
464
|
+
attempt = self._try_resolve(
|
|
465
|
+
serialized_result, provider, targeting_key, attributes, span, render_fn, strict=True
|
|
466
|
+
)
|
|
467
|
+
if attempt.ok:
|
|
468
|
+
result = cast('ResolvedVariable[T_co]', attempt.result)
|
|
469
|
+
result.reason = 'context_override'
|
|
470
|
+
return result
|
|
471
|
+
return self._resolve_code_default_value(
|
|
472
|
+
self._get_serialized_default(targeting_key, attributes),
|
|
473
|
+
provider,
|
|
474
|
+
targeting_key,
|
|
475
|
+
attributes,
|
|
476
|
+
span,
|
|
477
|
+
render_fn,
|
|
478
|
+
trigger_exc=attempt.exception,
|
|
479
|
+
trigger_stage=attempt.stage,
|
|
480
|
+
trigger_composed=attempt.composed,
|
|
481
|
+
label=None,
|
|
482
|
+
version=None,
|
|
483
|
+
provider_exception=None,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
def _lookup_serialized(
|
|
487
|
+
self,
|
|
488
|
+
name: str,
|
|
489
|
+
*,
|
|
490
|
+
provider: VariableProvider,
|
|
491
|
+
targeting_key: str | None,
|
|
492
|
+
attributes: Mapping[str, Any] | None,
|
|
493
|
+
label: str | None = None,
|
|
494
|
+
) -> ResolvedVariable[str | None]:
|
|
495
|
+
"""Resolve a variable name to its serialized value using the standard priority chain.
|
|
496
|
+
|
|
497
|
+
Priority: context override -> provider (label-specific then default) -> registered code default.
|
|
498
|
+
|
|
499
|
+
Used by both `_resolve` (for `self.name`) and the composition expander
|
|
500
|
+
(for child `@{ref}@` lookups) so the two paths can't drift.
|
|
501
|
+
"""
|
|
502
|
+
variable = self._variable_registry.get(name)
|
|
503
|
+
context_overrides = _VARIABLE_OVERRIDES.get()
|
|
504
|
+
|
|
505
|
+
# 1. Context override (only for variables whose type we know)
|
|
506
|
+
if context_overrides is not None and name in context_overrides and variable is not None:
|
|
507
|
+
override_value = context_overrides[name]
|
|
508
|
+
if is_resolve_function(override_value):
|
|
509
|
+
override_value = override_value(targeting_key, attributes)
|
|
510
|
+
try:
|
|
511
|
+
serialized = variable.type_adapter.dump_json(override_value).decode('utf-8')
|
|
512
|
+
except (ValueError, TypeError, RuntimeError) as e:
|
|
513
|
+
_emit_resolution_warning(
|
|
514
|
+
f"Context override for variable '{name}' could not be serialized while resolving "
|
|
515
|
+
f"'{self.name}' composition; falling through to provider/code default: {e}"
|
|
516
|
+
)
|
|
517
|
+
else:
|
|
518
|
+
return ResolvedVariable(name=name, value=serialized, reason='context_override')
|
|
519
|
+
|
|
520
|
+
# 2. Provider (label-specific first, falling back to default targeting)
|
|
521
|
+
if label is not None:
|
|
522
|
+
provider_result = provider.get_serialized_value_for_label(name, label)
|
|
523
|
+
if provider_result.value is None:
|
|
524
|
+
provider_result = provider.get_serialized_value(name, targeting_key, attributes)
|
|
525
|
+
else:
|
|
526
|
+
provider_result = provider.get_serialized_value(name, targeting_key, attributes)
|
|
527
|
+
|
|
528
|
+
if provider_result.value is not None:
|
|
529
|
+
return provider_result
|
|
530
|
+
|
|
531
|
+
# 3. Registered code default
|
|
532
|
+
if variable is not None:
|
|
533
|
+
serialized_default = variable._get_serialized_default(targeting_key, attributes)
|
|
534
|
+
if serialized_default is not None:
|
|
535
|
+
return ResolvedVariable(
|
|
536
|
+
name=name,
|
|
537
|
+
value=serialized_default,
|
|
538
|
+
label=None,
|
|
539
|
+
version=None,
|
|
540
|
+
reason='code_default',
|
|
541
|
+
exception=provider_result.exception,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# No value at any tier; propagate the provider's metadata so callers
|
|
545
|
+
# can surface the original exception/reason.
|
|
546
|
+
return provider_result
|
|
547
|
+
|
|
548
|
+
def _try_resolve(
|
|
549
|
+
self,
|
|
550
|
+
serialized_result: ResolvedVariable[str | None],
|
|
551
|
+
provider: VariableProvider,
|
|
552
|
+
targeting_key: str | None,
|
|
553
|
+
attributes: Mapping[str, Any] | None,
|
|
554
|
+
span: logfire.LogfireSpan | None,
|
|
555
|
+
render_fn: _RenderFunction | None = None,
|
|
556
|
+
*,
|
|
557
|
+
strict: bool,
|
|
558
|
+
) -> _ResolveAttempt:
|
|
559
|
+
"""Attempt one compose -> render -> deserialize pass over a serialized value.
|
|
560
|
+
|
|
561
|
+
Composition runs in *strict* mode when `strict=True`: an unresolved
|
|
562
|
+
`@{ref}@`/`@{ref.field}@` raises rather than rendering empty, so the
|
|
563
|
+
caller can fall back. On any failure this returns an unsuccessful
|
|
564
|
+
`_ResolveAttempt` (with the failing stage) rather than falling back
|
|
565
|
+
itself — the caller decides what to do. `render_fn` (template `{{}}`
|
|
566
|
+
rendering) is applied after composition and is always non-strict.
|
|
567
|
+
"""
|
|
568
|
+
from pydantic_handlebars import HandlebarsError
|
|
569
|
+
|
|
570
|
+
assert serialized_result.value is not None
|
|
571
|
+
serialized_value = serialized_result.value
|
|
572
|
+
|
|
573
|
+
def resolve_ref(
|
|
574
|
+
ref_name: str,
|
|
575
|
+
) -> tuple[str | None, str | None, int | None, ResolutionReason]:
|
|
576
|
+
# Shares the lookup priority with `_resolve` so that composition
|
|
577
|
+
# respects overrides and registered code defaults rather than
|
|
578
|
+
# only consulting the provider.
|
|
579
|
+
ref_result = self._lookup_serialized(
|
|
580
|
+
ref_name,
|
|
581
|
+
provider=provider,
|
|
582
|
+
targeting_key=targeting_key,
|
|
583
|
+
attributes=attributes,
|
|
584
|
+
)
|
|
585
|
+
return (ref_result.value, ref_result.label, ref_result.version, ref_result.reason)
|
|
586
|
+
|
|
587
|
+
composed: list[ComposedReference] = []
|
|
588
|
+
# Always run through `expand_references`, even when no `@{ref}@` tags
|
|
589
|
+
# are present: it's also responsible for unescaping `\@{...}@` →
|
|
590
|
+
# `@{...}@`. Gating on `has_references` produced inconsistent
|
|
591
|
+
# observable behaviour where an escaped-only value (e.g.
|
|
592
|
+
# `r'\@{baz}@'`) kept its backslash, but the same escape combined
|
|
593
|
+
# with a real reference correctly produced the literal `@{baz}@`.
|
|
594
|
+
try:
|
|
595
|
+
serialized_value, composed = expand_references(
|
|
596
|
+
serialized_value,
|
|
597
|
+
self.name,
|
|
598
|
+
resolve_ref,
|
|
599
|
+
strict=strict,
|
|
600
|
+
)
|
|
601
|
+
if fatal_error := _first_fatal_composition_error(composed):
|
|
602
|
+
# Cycles / depth overflow are structural failures — the value can't be
|
|
603
|
+
# meaningfully composed at all, in either strictness mode. Carry `composed`
|
|
604
|
+
# so the fall-back result can surface the failure chain in `composed_from`.
|
|
605
|
+
return _ResolveAttempt(
|
|
606
|
+
ok=False,
|
|
607
|
+
exception=VariableCompositionError(fatal_error),
|
|
608
|
+
stage='composition',
|
|
609
|
+
composed=composed,
|
|
610
|
+
)
|
|
611
|
+
except (VariableCompositionError, HandlebarsError, AssertionError) as e:
|
|
612
|
+
# VariableCompositionError: cycle/depth overflow raised by expand_references.
|
|
613
|
+
# HandlebarsError: an unresolved reference under strict mode, or a malformed
|
|
614
|
+
# (`@{#if x}@`) value; AssertionError: a reserved name (`@{true}@`). All are
|
|
615
|
+
# composition failures the caller turns into a code-default fall back.
|
|
616
|
+
return _ResolveAttempt(ok=False, exception=e, stage='composition')
|
|
617
|
+
|
|
618
|
+
# Apply render_fn (template rendering) if provided. Note: a
|
|
619
|
+
# `TemplateInputsMismatchError` from the `'error'` policy is intentionally
|
|
620
|
+
# not caught here — it propagates to the caller as a loud failure.
|
|
621
|
+
if render_fn is not None:
|
|
622
|
+
try:
|
|
623
|
+
serialized_value = render_fn(serialized_value)
|
|
624
|
+
except (HandlebarsError, ValueError, TypeError) as e:
|
|
625
|
+
return _ResolveAttempt(ok=False, exception=e, stage='template rendering')
|
|
626
|
+
|
|
627
|
+
# Deserialize the (possibly expanded/rendered) value.
|
|
628
|
+
value_or_exc = self._deserialize(serialized_value)
|
|
629
|
+
if isinstance(value_or_exc, Exception):
|
|
630
|
+
if span: # pragma: no branch
|
|
631
|
+
span.set_attribute('invalid_serialized_label', serialized_result.label)
|
|
632
|
+
span.set_attribute('invalid_serialized_value', serialized_value)
|
|
633
|
+
return _ResolveAttempt(ok=False, exception=value_or_exc, stage='validation')
|
|
634
|
+
|
|
635
|
+
return _ResolveAttempt(
|
|
636
|
+
ok=True,
|
|
637
|
+
result=ResolvedVariable(
|
|
638
|
+
name=self.name,
|
|
639
|
+
value=value_or_exc,
|
|
640
|
+
label=serialized_result.label,
|
|
641
|
+
version=serialized_result.version,
|
|
642
|
+
reason='resolved',
|
|
643
|
+
composed_from=composed,
|
|
644
|
+
),
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def _resolve_code_default_value(
|
|
648
|
+
self,
|
|
649
|
+
serialized_default: str | None,
|
|
650
|
+
provider: VariableProvider,
|
|
651
|
+
targeting_key: str | None,
|
|
652
|
+
attributes: Mapping[str, Any] | None,
|
|
653
|
+
span: logfire.LogfireSpan | None,
|
|
654
|
+
render_fn: _RenderFunction | None,
|
|
655
|
+
*,
|
|
656
|
+
trigger_exc: Exception | None,
|
|
657
|
+
trigger_stage: _FailureStage,
|
|
658
|
+
trigger_composed: list[ComposedReference] | None = None,
|
|
659
|
+
label: str | None,
|
|
660
|
+
version: int | None,
|
|
661
|
+
provider_exception: Exception | None,
|
|
662
|
+
) -> ResolvedVariable[T_co]:
|
|
663
|
+
"""Resolve via the code default: compose strict, then non-strict, then raw.
|
|
664
|
+
|
|
665
|
+
The code default is the lenient last resort, so an unresolved `@{ref}@`
|
|
666
|
+
*within it* renders as an empty string (non-strict) rather than failing —
|
|
667
|
+
there is nowhere further to fall back to. A structural failure (cycle /
|
|
668
|
+
depth / parse) even under non-strict, or a render/validation failure,
|
|
669
|
+
falls back to the raw, uncomposed default value.
|
|
670
|
+
|
|
671
|
+
`trigger_exc`/`trigger_stage` describe the provider/override failure that
|
|
672
|
+
triggered this fall back, or are `None` when the code default is itself
|
|
673
|
+
the primary resolved value. When set, the result carries that provenance
|
|
674
|
+
(`reason`, `exception`, and the original `label`/`version`).
|
|
675
|
+
"""
|
|
676
|
+
if trigger_exc is not None:
|
|
677
|
+
self._warn_fallback(trigger_stage, trigger_exc)
|
|
678
|
+
|
|
679
|
+
if serialized_default is None:
|
|
680
|
+
# The code default isn't JSON-serializable (e.g. an arbitrary Python object or a
|
|
681
|
+
# callable returning one), so there's nothing to compose — return it verbatim.
|
|
682
|
+
return ResolvedVariable(
|
|
683
|
+
name=self.name,
|
|
684
|
+
value=self._get_default_cached(targeting_key, attributes),
|
|
685
|
+
exception=trigger_exc if trigger_exc is not None else provider_exception,
|
|
686
|
+
reason=self._fallback_reason(trigger_stage) if trigger_exc is not None else 'code_default',
|
|
687
|
+
label=label,
|
|
688
|
+
version=version,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
default_result = ResolvedVariable[str | None](name=self.name, value=serialized_default, reason='code_default')
|
|
692
|
+
# Compose the code default strictly first; if a reference within it is unresolved,
|
|
693
|
+
# re-compose non-strict so the missing reference renders as an empty string.
|
|
694
|
+
attempt = self._try_resolve(default_result, provider, targeting_key, attributes, span, render_fn, strict=True)
|
|
695
|
+
if not attempt.ok and attempt.stage == 'composition':
|
|
696
|
+
attempt = self._try_resolve(
|
|
697
|
+
default_result, provider, targeting_key, attributes, span, render_fn, strict=False
|
|
698
|
+
)
|
|
699
|
+
if attempt.ok:
|
|
700
|
+
_emit_resolution_warning(
|
|
701
|
+
f"Variable '{self.name}' code default has unresolved composition reference(s); "
|
|
702
|
+
'rendering them as empty strings.'
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
if attempt.ok:
|
|
706
|
+
result = cast('ResolvedVariable[T_co]', attempt.result)
|
|
707
|
+
if trigger_exc is not None:
|
|
708
|
+
result.reason = self._fallback_reason(trigger_stage)
|
|
709
|
+
result.exception = trigger_exc
|
|
710
|
+
result.label = label
|
|
711
|
+
result.version = version
|
|
712
|
+
# Surface why the original value was discarded (e.g. the cycle chain), rather
|
|
713
|
+
# than the code default's own (here, successful) composition tree.
|
|
714
|
+
if trigger_composed:
|
|
715
|
+
result.composed_from = trigger_composed
|
|
716
|
+
else:
|
|
717
|
+
result.reason = 'code_default'
|
|
718
|
+
result.label = None
|
|
719
|
+
result.version = None
|
|
720
|
+
if result.exception is None and provider_exception is not None:
|
|
721
|
+
result.exception = provider_exception
|
|
722
|
+
return result
|
|
723
|
+
|
|
724
|
+
# The code default failed even non-strict (a cycle/parse/depth error, or a
|
|
725
|
+
# render/validation failure) — return the raw, uncomposed default value.
|
|
726
|
+
final_exc = trigger_exc if trigger_exc is not None else attempt.exception
|
|
727
|
+
final_stage = trigger_stage if trigger_exc is not None else attempt.stage
|
|
728
|
+
if trigger_exc is None and final_exc is not None:
|
|
729
|
+
self._warn_fallback(final_stage, final_exc, code_default=True)
|
|
730
|
+
return ResolvedVariable(
|
|
731
|
+
name=self.name,
|
|
732
|
+
value=self._get_default_cached(targeting_key, attributes),
|
|
733
|
+
exception=final_exc,
|
|
734
|
+
reason=self._fallback_reason(final_stage),
|
|
735
|
+
label=label,
|
|
736
|
+
version=version,
|
|
737
|
+
composed_from=trigger_composed or [],
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
@staticmethod
|
|
741
|
+
def _fallback_reason(stage: _FailureStage) -> ResolutionReason:
|
|
742
|
+
"""Map a failing pipeline stage to the resolution reason for the fallback."""
|
|
743
|
+
return 'validation_error' if stage == 'validation' else 'other_error'
|
|
744
|
+
|
|
745
|
+
def _warn_fallback(self, stage: _FailureStage, exception: Exception, *, code_default: bool = False) -> None:
|
|
746
|
+
"""Warn that resolution fell back after a failure at *stage*.
|
|
747
|
+
|
|
748
|
+
*stage* identifies which step failed (composition / template rendering /
|
|
749
|
+
validation) so the message is accurate. A validation failure uses the
|
|
750
|
+
richer `_warn_validation_fallback` formatting.
|
|
751
|
+
"""
|
|
752
|
+
if stage == 'validation':
|
|
753
|
+
self._warn_validation_fallback(exception)
|
|
754
|
+
elif code_default:
|
|
755
|
+
_emit_resolution_warning(
|
|
756
|
+
f"Variable '{self.name}' code default {stage} failed; returning the raw default: {exception}"
|
|
757
|
+
)
|
|
758
|
+
else:
|
|
759
|
+
_emit_resolution_warning(
|
|
760
|
+
f"Variable '{self.name}' {stage} failed; falling back to code default: {exception}"
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
def _get_default(
|
|
764
|
+
self, targeting_key: str | None = None, merged_attributes: Mapping[str, Any] | None = None
|
|
765
|
+
) -> T_co:
|
|
766
|
+
if is_resolve_function(self.default):
|
|
767
|
+
return self.default(targeting_key, merged_attributes)
|
|
768
|
+
else:
|
|
769
|
+
return self.default
|
|
770
|
+
|
|
771
|
+
def _get_default_cached(
|
|
772
|
+
self, targeting_key: str | None = None, merged_attributes: Mapping[str, Any] | None = None
|
|
773
|
+
) -> T_co:
|
|
774
|
+
"""Return the code default, memoised for the duration of one `_resolve` call.
|
|
775
|
+
|
|
776
|
+
Avoids re-invoking a callable default twice when the same `get()`
|
|
777
|
+
consults it on multiple fallback paths. Both successful values and
|
|
778
|
+
raised exceptions are cached -- a callable that raises on first
|
|
779
|
+
invocation re-raises (without re-invoking) on subsequent calls, so a
|
|
780
|
+
failing default doesn't get called multiple times either. Outside a
|
|
781
|
+
`_resolve` call the cache is not set and this is a direct passthrough to
|
|
782
|
+
`_get_default`.
|
|
783
|
+
"""
|
|
784
|
+
cache = _DEFAULT_CACHE.get()
|
|
785
|
+
if cache is None: # pragma: no cover
|
|
786
|
+
# Defensive: every production call site is inside `_resolve`, which
|
|
787
|
+
# sets the cache. Falling back to a direct compute keeps the helper
|
|
788
|
+
# safe if someone reaches in from an unexpected entry point in the
|
|
789
|
+
# future.
|
|
790
|
+
return self._get_default(targeting_key, merged_attributes)
|
|
791
|
+
key = id(self)
|
|
792
|
+
if key not in cache:
|
|
793
|
+
try:
|
|
794
|
+
cache[key] = (True, self._get_default(targeting_key, merged_attributes))
|
|
795
|
+
except Exception as e:
|
|
796
|
+
cache[key] = (False, e)
|
|
797
|
+
ok, payload = cache[key]
|
|
798
|
+
if ok:
|
|
799
|
+
return cast('T_co', payload)
|
|
800
|
+
raise cast('Exception', payload)
|
|
801
|
+
|
|
802
|
+
def _get_serialized_default(
|
|
803
|
+
self, targeting_key: str | None = None, merged_attributes: Mapping[str, Any] | None = None
|
|
804
|
+
) -> str | None:
|
|
805
|
+
"""Return the code default serialized as JSON, or None if serialization fails."""
|
|
806
|
+
try:
|
|
807
|
+
default = self._get_default_cached(targeting_key, merged_attributes)
|
|
808
|
+
return self.type_adapter.dump_json(default).decode('utf-8')
|
|
809
|
+
except (ValueError, TypeError, RuntimeError):
|
|
810
|
+
return None
|
|
811
|
+
|
|
812
|
+
def _resolve_code_default(
|
|
813
|
+
self,
|
|
814
|
+
targeting_key: str | None,
|
|
815
|
+
attributes: Mapping[str, Any] | None,
|
|
816
|
+
serialized_result: ResolvedVariable[str | None],
|
|
817
|
+
) -> ResolvedVariable[T_co]:
|
|
818
|
+
"""Build a ResolvedVariable from the registered code default."""
|
|
819
|
+
return ResolvedVariable(
|
|
820
|
+
name=self.name,
|
|
821
|
+
value=self._get_default_cached(targeting_key, attributes),
|
|
822
|
+
exception=serialized_result.exception,
|
|
823
|
+
label=None,
|
|
824
|
+
version=None,
|
|
825
|
+
reason='code_default',
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
def _warn_validation_fallback(self, error: Exception) -> None:
|
|
829
|
+
detail: object = (
|
|
830
|
+
error.errors(include_input=False, include_url=False) if isinstance(error, ValidationError) else error
|
|
831
|
+
)
|
|
832
|
+
_emit_resolution_warning(
|
|
833
|
+
f"Variable '{self.name}' value failed validation; falling back to code default: {detail}"
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
def _get_merged_attributes(self, attributes: Mapping[str, Any] | None = None) -> Mapping[str, Any]:
|
|
837
|
+
from logfire._internal.config import LocalVariablesOptions, VariablesOptions
|
|
838
|
+
|
|
839
|
+
result: dict[str, Any] = {}
|
|
840
|
+
variables = self.logfire_instance.config.variables
|
|
841
|
+
# Apply in order of lowest to highest priority:
|
|
842
|
+
# resource attributes < baggage < user-provided attributes
|
|
843
|
+
include_resource = True
|
|
844
|
+
include_baggage = True
|
|
845
|
+
if isinstance(variables, (VariablesOptions, LocalVariablesOptions)):
|
|
846
|
+
include_resource = variables.include_resource_attributes_in_context
|
|
847
|
+
include_baggage = variables.include_baggage_in_context
|
|
848
|
+
if include_resource:
|
|
849
|
+
result.update(self.logfire_instance.resource_attributes)
|
|
850
|
+
if include_baggage:
|
|
851
|
+
result.update(logfire.get_baggage())
|
|
852
|
+
if attributes:
|
|
853
|
+
result.update(attributes)
|
|
854
|
+
return result
|
|
855
|
+
|
|
856
|
+
def to_config(self) -> VariableConfig:
|
|
857
|
+
"""Create a VariableConfig from this Variable instance.
|
|
858
|
+
|
|
859
|
+
This creates a minimal config with just the name, schema, and example.
|
|
860
|
+
No labels or versions are created - use this to generate a template config that can be edited.
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
A VariableConfig with minimal configuration.
|
|
864
|
+
"""
|
|
865
|
+
from logfire.variables.config import Rollout, VariableConfig
|
|
866
|
+
|
|
867
|
+
# Get JSON schema from the type adapter
|
|
868
|
+
json_schema = self.type_adapter.json_schema()
|
|
869
|
+
|
|
870
|
+
# Get the serialized default value as an example (if not a function). Use
|
|
871
|
+
# `_get_serialized_default`, which tolerates a non-serializable default by returning None.
|
|
872
|
+
example: str | None = None
|
|
873
|
+
if not is_resolve_function(self.default):
|
|
874
|
+
example = self._get_serialized_default()
|
|
875
|
+
|
|
876
|
+
return VariableConfig(
|
|
877
|
+
name=self.name,
|
|
878
|
+
description=self.description,
|
|
879
|
+
labels={},
|
|
880
|
+
rollout=Rollout(labels={}),
|
|
881
|
+
overrides=[],
|
|
882
|
+
json_schema=json_schema,
|
|
883
|
+
example=example,
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
def _get_result_and_record_span(
|
|
887
|
+
self,
|
|
888
|
+
targeting_key: str | None,
|
|
889
|
+
attributes: Mapping[str, Any] | None,
|
|
890
|
+
label: str | None,
|
|
891
|
+
render_fn: _RenderFunction | None = None,
|
|
892
|
+
) -> ResolvedVariable[T_co]:
|
|
893
|
+
"""Common get() logic: resolve targeting key, open span, call _resolve, record attributes."""
|
|
894
|
+
merged_attributes = self._get_merged_attributes(attributes)
|
|
895
|
+
|
|
896
|
+
# Targeting key resolution: call-site > contextvar > trace_id
|
|
897
|
+
if targeting_key is None:
|
|
898
|
+
targeting_key = _get_contextvar_targeting_key(self.name)
|
|
899
|
+
|
|
900
|
+
if targeting_key is None and (current_trace_id := get_current_span().get_span_context().trace_id):
|
|
901
|
+
# If there is no active trace, the current_trace_id will be zero
|
|
902
|
+
targeting_key = f'trace_id:{current_trace_id:032x}'
|
|
903
|
+
|
|
904
|
+
# Include the variable name directly here to make the span name more useful,
|
|
905
|
+
# it'll still be low cardinality. This also prevents it from being scrubbed from the message.
|
|
906
|
+
# Don't inline the f-string to avoid f-string magic.
|
|
907
|
+
span_name = f'Resolve variable {self.name}'
|
|
908
|
+
with ExitStack() as stack:
|
|
909
|
+
span: logfire.LogfireSpan | None = None
|
|
910
|
+
if _get_variables_instrument(self.logfire_instance.config.variables):
|
|
911
|
+
span = stack.enter_context(
|
|
912
|
+
self.logfire_instance.span(
|
|
913
|
+
span_name,
|
|
914
|
+
name=self.name,
|
|
915
|
+
targeting_key=targeting_key,
|
|
916
|
+
attributes=merged_attributes,
|
|
917
|
+
)
|
|
918
|
+
)
|
|
919
|
+
result = self._resolve(targeting_key, merged_attributes, span, label, render_fn=render_fn)
|
|
920
|
+
if span is not None:
|
|
921
|
+
# Serialize value safely for OTel span attributes, which only support primitives.
|
|
922
|
+
# Try to JSON serialize the value; if that fails, fall back to string representation.
|
|
923
|
+
try:
|
|
924
|
+
serialized_value = self.type_adapter.dump_json(result.value).decode('utf-8')
|
|
925
|
+
except (ValueError, TypeError, RuntimeError):
|
|
926
|
+
serialized_value = repr(result.value)
|
|
927
|
+
attrs: dict[str, Any] = {
|
|
928
|
+
'name': result.name,
|
|
929
|
+
'value': serialized_value,
|
|
930
|
+
'label': result.label,
|
|
931
|
+
'version': result.version,
|
|
932
|
+
'reason': result.reason,
|
|
933
|
+
}
|
|
934
|
+
if result.composed_from:
|
|
935
|
+
import json
|
|
936
|
+
|
|
937
|
+
attrs['composed_from'] = json.dumps(
|
|
938
|
+
[_serialize_composed_reference(c) for c in result.composed_from]
|
|
939
|
+
)
|
|
940
|
+
span.set_attributes(attrs)
|
|
941
|
+
if result.exception:
|
|
942
|
+
span.record_exception(result.exception)
|
|
943
|
+
return result
|
|
944
|
+
|
|
945
|
+
def get(
|
|
946
|
+
self,
|
|
947
|
+
targeting_key: str | None = None,
|
|
948
|
+
attributes: Mapping[str, Any] | None = None,
|
|
949
|
+
*,
|
|
950
|
+
label: str | None = None,
|
|
951
|
+
) -> ResolvedVariable[T_co]:
|
|
952
|
+
"""Resolve the variable and return full details including label, version, and any errors.
|
|
953
|
+
|
|
954
|
+
Args:
|
|
955
|
+
targeting_key: Optional key for deterministic label selection (e.g., user ID).
|
|
956
|
+
If not provided, falls back to contextvar targeting key (set via targeting_context),
|
|
957
|
+
then to the current trace ID if there is an active trace.
|
|
958
|
+
attributes: Optional attributes for condition-based targeting rules.
|
|
959
|
+
label: Optional explicit label name to select. If provided, bypasses rollout
|
|
960
|
+
weights and targeting, directly selecting the specified label. If the label
|
|
961
|
+
doesn't exist in the configuration, falls back to default resolution.
|
|
962
|
+
|
|
963
|
+
Returns:
|
|
964
|
+
A ResolvedVariable object containing the resolved value, selected label,
|
|
965
|
+
version, and any errors that occurred.
|
|
966
|
+
"""
|
|
967
|
+
return self._get_result_and_record_span(targeting_key, attributes, label)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
class TemplateVariable(Variable[T_co], Generic[T_co, InputsT]):
|
|
971
|
+
"""A managed variable with integrated template rendering.
|
|
972
|
+
|
|
973
|
+
Like `Variable`, but `get()` requires `inputs` and automatically renders
|
|
974
|
+
Handlebars `{{placeholder}}` templates in the resolved value before returning.
|
|
975
|
+
The pipeline is: resolve → compose `@{refs}@` → render `{{}}` → deserialize.
|
|
976
|
+
"""
|
|
977
|
+
|
|
978
|
+
inputs_type: type[InputsT]
|
|
979
|
+
"""The type used for template inputs."""
|
|
980
|
+
|
|
981
|
+
template_mismatch_policy: TemplateMismatchPolicy | None
|
|
982
|
+
"""Per-variable override of the render-time `{{field}}` mismatch policy.
|
|
983
|
+
|
|
984
|
+
`None` means "inherit from `VariablesOptions` / `LocalVariablesOptions`"; an
|
|
985
|
+
explicit value overrides the instance-level policy for this variable only,
|
|
986
|
+
even when relaxing.
|
|
987
|
+
"""
|
|
988
|
+
|
|
989
|
+
def __init__(
|
|
990
|
+
self,
|
|
991
|
+
name: str,
|
|
992
|
+
*,
|
|
993
|
+
type: type[T_co],
|
|
994
|
+
default: T_co | ResolveFunction[T_co],
|
|
995
|
+
inputs_type: type[InputsT],
|
|
996
|
+
description: str | None = None,
|
|
997
|
+
logfire_instance: logfire.Logfire,
|
|
998
|
+
template_mismatch_policy: TemplateMismatchPolicy | None = None,
|
|
999
|
+
):
|
|
1000
|
+
"""Create a new template variable.
|
|
1001
|
+
|
|
1002
|
+
Args:
|
|
1003
|
+
name: Unique name identifying this variable.
|
|
1004
|
+
type: The expected type of this variable's values, used for validation.
|
|
1005
|
+
default: Default value to use when no configuration is found, or a function
|
|
1006
|
+
that computes the default based on targeting_key and attributes.
|
|
1007
|
+
inputs_type: The type (typically a Pydantic `BaseModel`) describing the expected
|
|
1008
|
+
template inputs. Used for type-safe `get(inputs)` calls and JSON schema generation.
|
|
1009
|
+
description: Optional human-readable description of what this variable controls.
|
|
1010
|
+
logfire_instance: The Logfire instance this variable is associated with.
|
|
1011
|
+
template_mismatch_policy: Per-variable override of the render-time
|
|
1012
|
+
`{{field}}` mismatch policy. `None` (default) inherits from
|
|
1013
|
+
`VariablesOptions` / `LocalVariablesOptions`.
|
|
1014
|
+
"""
|
|
1015
|
+
super().__init__(
|
|
1016
|
+
name,
|
|
1017
|
+
type=type,
|
|
1018
|
+
default=default,
|
|
1019
|
+
description=description,
|
|
1020
|
+
logfire_instance=logfire_instance,
|
|
1021
|
+
)
|
|
1022
|
+
self.inputs_type = inputs_type
|
|
1023
|
+
self._inputs_type_adapter: TypeAdapter[InputsT] = TypeAdapter(inputs_type)
|
|
1024
|
+
self.template_mismatch_policy = template_mismatch_policy
|
|
1025
|
+
|
|
1026
|
+
def get_template_inputs_schema(self) -> dict[str, Any]:
|
|
1027
|
+
"""Return the JSON schema derived from `inputs_type`."""
|
|
1028
|
+
return self._inputs_type_adapter.json_schema()
|
|
1029
|
+
|
|
1030
|
+
def to_config(self) -> VariableConfig:
|
|
1031
|
+
"""Create a VariableConfig, including `template_inputs_schema`."""
|
|
1032
|
+
config = super().to_config()
|
|
1033
|
+
config.template_inputs_schema = self.get_template_inputs_schema()
|
|
1034
|
+
return config
|
|
1035
|
+
|
|
1036
|
+
def get( # pyright: ignore[reportIncompatibleMethodOverride]
|
|
1037
|
+
self,
|
|
1038
|
+
inputs: InputsT,
|
|
1039
|
+
targeting_key: str | None = None,
|
|
1040
|
+
attributes: Mapping[str, Any] | None = None,
|
|
1041
|
+
*,
|
|
1042
|
+
label: str | None = None,
|
|
1043
|
+
) -> ResolvedVariable[T_co]:
|
|
1044
|
+
"""Resolve the variable, render templates with the given inputs, and return the result.
|
|
1045
|
+
|
|
1046
|
+
The resolution pipeline is:
|
|
1047
|
+
1. Fetch serialized value from provider (or use default)
|
|
1048
|
+
2. Expand `@{variable_name}@` composition references
|
|
1049
|
+
3. Render `{{placeholder}}` Handlebars templates using `inputs`
|
|
1050
|
+
4. Deserialize to the variable's type
|
|
1051
|
+
|
|
1052
|
+
Args:
|
|
1053
|
+
inputs: Template context values. Typically a Pydantic `BaseModel` instance
|
|
1054
|
+
matching `inputs_type`. All `{{placeholder}}` expressions in the value
|
|
1055
|
+
are rendered using this context.
|
|
1056
|
+
targeting_key: Optional key for deterministic label selection (e.g., user ID).
|
|
1057
|
+
attributes: Optional attributes for condition-based targeting rules.
|
|
1058
|
+
label: Optional explicit label name to select.
|
|
1059
|
+
|
|
1060
|
+
Returns:
|
|
1061
|
+
A ResolvedVariable with the fully rendered and deserialized value.
|
|
1062
|
+
|
|
1063
|
+
Raises:
|
|
1064
|
+
TemplateInputsMismatchError: When `template_mismatch_policy` resolves
|
|
1065
|
+
to `'error'` and the post-composition template references a
|
|
1066
|
+
`{{field}}` not declared in `inputs_type`.
|
|
1067
|
+
"""
|
|
1068
|
+
from logfire.variables.abstract import render_serialized_string
|
|
1069
|
+
|
|
1070
|
+
policy = self._effective_template_mismatch_policy()
|
|
1071
|
+
|
|
1072
|
+
def _render_fn(serialized_json: str) -> str:
|
|
1073
|
+
if policy != 'ignore':
|
|
1074
|
+
self._check_template_fields(serialized_json, policy)
|
|
1075
|
+
return render_serialized_string(serialized_json, inputs)
|
|
1076
|
+
|
|
1077
|
+
return self._get_result_and_record_span(targeting_key, attributes, label, render_fn=_render_fn)
|
|
1078
|
+
|
|
1079
|
+
def _effective_template_mismatch_policy(self) -> TemplateMismatchPolicy:
|
|
1080
|
+
"""Resolve the policy for this variable's next `get()` call.
|
|
1081
|
+
|
|
1082
|
+
Per-variable wins when set (even when relaxing). Otherwise reads the
|
|
1083
|
+
instance-level `VariablesOptions` / `LocalVariablesOptions` setting,
|
|
1084
|
+
falling back to `'warn'` if no managed-variables config is in use.
|
|
1085
|
+
"""
|
|
1086
|
+
if self.template_mismatch_policy is not None:
|
|
1087
|
+
return self.template_mismatch_policy
|
|
1088
|
+
from logfire._internal.config import LocalVariablesOptions, VariablesOptions
|
|
1089
|
+
|
|
1090
|
+
options = self.logfire_instance.config.variables
|
|
1091
|
+
if isinstance(options, (VariablesOptions, LocalVariablesOptions)):
|
|
1092
|
+
return options.template_mismatch_policy
|
|
1093
|
+
return 'warn'
|
|
1094
|
+
|
|
1095
|
+
def _check_template_fields(self, serialized_value: str, policy: TemplateMismatchPolicy) -> None:
|
|
1096
|
+
"""Apply the render-time mismatch policy.
|
|
1097
|
+
|
|
1098
|
+
Walks every `{{field}}` reference in the post-composition serialized
|
|
1099
|
+
template through `pydantic_handlebars.check_template_compatibility`
|
|
1100
|
+
against `inputs_type`'s JSON schema. Any error-severity issue
|
|
1101
|
+
triggers the policy: `'error'` raises `TemplateInputsMismatchError`,
|
|
1102
|
+
`'warn'` emits a `RuntimeWarning`. (`'ignore'` callers never reach
|
|
1103
|
+
this path.)
|
|
1104
|
+
"""
|
|
1105
|
+
from pydantic_handlebars import check_template_compatibility
|
|
1106
|
+
|
|
1107
|
+
from logfire.variables.template_validation import extract_template_strings
|
|
1108
|
+
|
|
1109
|
+
templates = extract_template_strings(serialized_value)
|
|
1110
|
+
if not templates:
|
|
1111
|
+
return
|
|
1112
|
+
schema = self.get_template_inputs_schema()
|
|
1113
|
+
result = check_template_compatibility(templates, schema)
|
|
1114
|
+
error_fields = [issue.field_path for issue in result.issues if issue.severity == 'error']
|
|
1115
|
+
if not error_fields:
|
|
1116
|
+
return
|
|
1117
|
+
|
|
1118
|
+
fields_str = ', '.join(repr(f) for f in dict.fromkeys(error_fields))
|
|
1119
|
+
message = (
|
|
1120
|
+
f"Variable '{self.name}': template references {fields_str} "
|
|
1121
|
+
f'which are not declared in inputs_type {self.inputs_type.__name__!r}.'
|
|
1122
|
+
)
|
|
1123
|
+
if policy == 'error':
|
|
1124
|
+
raise TemplateInputsMismatchError(message)
|
|
1125
|
+
# Use the filter-independent emitter: a raw `warnings.warn` here would, under
|
|
1126
|
+
# `-W error` / `filterwarnings=error`, escalate to a RuntimeWarning that the resolve
|
|
1127
|
+
# fallback `except` swallows — turning the 'warn' policy into a silent code-default
|
|
1128
|
+
# fallback (reason='other_error') instead of rendering-and-warning.
|
|
1129
|
+
_emit_resolution_warning(message)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def get_template_inputs_schema(variable: Variable[Any]) -> dict[str, Any] | None:
|
|
1133
|
+
"""Return the template inputs JSON schema, or None for non-template variables."""
|
|
1134
|
+
if isinstance(variable, TemplateVariable):
|
|
1135
|
+
return variable.get_template_inputs_schema()
|
|
1136
|
+
return None
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _serialize_composed_reference(ref: ComposedReference) -> dict[str, Any]:
|
|
1140
|
+
"""Serialize a composition reference for OTel span attributes."""
|
|
1141
|
+
data: dict[str, Any] = {
|
|
1142
|
+
'name': ref.name,
|
|
1143
|
+
'version': ref.version,
|
|
1144
|
+
'label': ref.label,
|
|
1145
|
+
'reason': ref.reason,
|
|
1146
|
+
'error': ref.error,
|
|
1147
|
+
}
|
|
1148
|
+
if ref.composed_from:
|
|
1149
|
+
data['composed_from'] = [_serialize_composed_reference(c) for c in ref.composed_from]
|
|
1150
|
+
return data
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _first_fatal_composition_error(composed: list[ComposedReference]) -> str | None:
|
|
1154
|
+
"""Return the first *fatal* nested composition error (cycle / depth overflow), if any.
|
|
1155
|
+
|
|
1156
|
+
Soft, per-reference failures (a missing/unresolvable reference, or a referenced variable
|
|
1157
|
+
with a malformed value) are deliberately ignored here: composition leaves the literal
|
|
1158
|
+
`@{ref}@` text in place for those, so the partially-composed value is still usable and
|
|
1159
|
+
should not trigger a wholesale fallback to the code default.
|
|
1160
|
+
"""
|
|
1161
|
+
for ref in composed:
|
|
1162
|
+
if ref.error is not None and ref.fatal:
|
|
1163
|
+
return ref.error
|
|
1164
|
+
if nested_error := _first_fatal_composition_error(ref.composed_from):
|
|
1165
|
+
return nested_error
|
|
1166
|
+
return None
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _static_composition_refs(variable: Variable[Any]) -> set[str]:
|
|
1170
|
+
"""Return the `@{ref}@` names in *variable*'s code default, or empty for a non-static default.
|
|
1171
|
+
|
|
1172
|
+
Only inspects a static (non-callable, JSON-serializable) default — a callable default can't
|
|
1173
|
+
be introspected at declaration time without invoking it, which would be inappropriate here.
|
|
1174
|
+
"""
|
|
1175
|
+
if is_resolve_function(variable.default):
|
|
1176
|
+
return set()
|
|
1177
|
+
try:
|
|
1178
|
+
serialized = variable.type_adapter.dump_json(variable.default).decode('utf-8')
|
|
1179
|
+
except (ValueError, TypeError, RuntimeError):
|
|
1180
|
+
return set()
|
|
1181
|
+
return set(find_references(serialized))
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def warn_on_template_inputs_composition_mismatch(
|
|
1185
|
+
registry: Mapping[str, Variable[Any]], variable: Variable[Any]
|
|
1186
|
+
) -> None:
|
|
1187
|
+
"""Warn when a variable *without* `inputs_type` composes one *with* `inputs_type`.
|
|
1188
|
+
|
|
1189
|
+
Composing a `template_var()` (which declares template inputs) into a plain `var()` is almost
|
|
1190
|
+
always a mistake: the plain variable has no inputs, so the composed template's placeholders
|
|
1191
|
+
can never receive values when resolved through it. This is checked at declaration time, in
|
|
1192
|
+
both directions, so the order in which the two variables are declared doesn't matter. Only
|
|
1193
|
+
static (non-callable) code defaults are inspected.
|
|
1194
|
+
"""
|
|
1195
|
+
|
|
1196
|
+
def _warn(plain_name: str, template_name: str) -> None:
|
|
1197
|
+
warnings.warn(
|
|
1198
|
+
f'Variable {plain_name!r} composes template variable {template_name!r}, but {plain_name!r} '
|
|
1199
|
+
f'has no inputs_type of its own, so the composed template placeholders cannot receive '
|
|
1200
|
+
f'inputs when {plain_name!r} is resolved. Declare {plain_name!r} with template_var() and a '
|
|
1201
|
+
f'compatible inputs_type if you intend to render it, or remove the reference.',
|
|
1202
|
+
category=RuntimeWarning,
|
|
1203
|
+
stacklevel=3,
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
if not isinstance(variable, TemplateVariable):
|
|
1207
|
+
# A plain variable being declared: does it compose an already-registered template var?
|
|
1208
|
+
for ref_name in _static_composition_refs(variable):
|
|
1209
|
+
if isinstance(registry.get(ref_name), TemplateVariable):
|
|
1210
|
+
_warn(variable.name, ref_name)
|
|
1211
|
+
else:
|
|
1212
|
+
# A template variable being declared: does an already-registered plain var compose it?
|
|
1213
|
+
for other in registry.values():
|
|
1214
|
+
if other is variable or isinstance(other, TemplateVariable):
|
|
1215
|
+
continue
|
|
1216
|
+
if variable.name in _static_composition_refs(other):
|
|
1217
|
+
_warn(other.name, variable.name)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
@contextmanager
|
|
1221
|
+
def targeting_context(
|
|
1222
|
+
targeting_key: str,
|
|
1223
|
+
variables: Sequence[Variable[Any] | TemplateVariable[Any, Any]] | None = None,
|
|
1224
|
+
) -> Generator[None]:
|
|
1225
|
+
"""Set the targeting key for variable resolution within this context.
|
|
1226
|
+
|
|
1227
|
+
The targeting key is used for deterministic label selection - the same targeting key
|
|
1228
|
+
will always resolve to the same label for a given variable configuration.
|
|
1229
|
+
|
|
1230
|
+
Args:
|
|
1231
|
+
targeting_key: The targeting key to use for deterministic label selection
|
|
1232
|
+
(e.g., user ID, organization ID).
|
|
1233
|
+
variables: If provided, only apply this targeting key to these specific variables.
|
|
1234
|
+
If not provided, this becomes the default targeting key for all variables.
|
|
1235
|
+
|
|
1236
|
+
Variable-specific targeting always takes precedence over the default, regardless
|
|
1237
|
+
of nesting order. Call-site explicit targeting_key still wins over everything.
|
|
1238
|
+
|
|
1239
|
+
Example:
|
|
1240
|
+
# Set default targeting for all variables
|
|
1241
|
+
with targeting_context("user123"):
|
|
1242
|
+
value = my_variable.get() # uses "user123"
|
|
1243
|
+
|
|
1244
|
+
# Set targeting for specific variables
|
|
1245
|
+
with targeting_context("org456", variables=[org_variable]):
|
|
1246
|
+
org_value = org_variable.get() # uses "org456"
|
|
1247
|
+
other_value = other_variable.get() # uses default or trace_id
|
|
1248
|
+
|
|
1249
|
+
# Combine default and specific - order doesn't matter for precedence
|
|
1250
|
+
with targeting_context("user123"):
|
|
1251
|
+
with targeting_context("org456", variables=[org_variable]):
|
|
1252
|
+
org_value = org_variable.get() # uses "org456" (specific wins)
|
|
1253
|
+
other_value = other_variable.get() # uses "user123" (default)
|
|
1254
|
+
"""
|
|
1255
|
+
current = _TARGETING_CONTEXT.get()
|
|
1256
|
+
|
|
1257
|
+
# Build new context by merging with current
|
|
1258
|
+
new_data = _TargetingContextData(
|
|
1259
|
+
default=current.default if current else None,
|
|
1260
|
+
by_variable=dict(current.by_variable) if current else {},
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
if variables is None:
|
|
1264
|
+
new_data.default = targeting_key
|
|
1265
|
+
else:
|
|
1266
|
+
for var in variables:
|
|
1267
|
+
new_data.by_variable[var.name] = targeting_key
|
|
1268
|
+
|
|
1269
|
+
token = _TARGETING_CONTEXT.set(new_data)
|
|
1270
|
+
try:
|
|
1271
|
+
yield
|
|
1272
|
+
finally:
|
|
1273
|
+
_TARGETING_CONTEXT.reset(token)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _get_contextvar_targeting_key(variable_name: str) -> str | None:
|
|
1277
|
+
"""Get the targeting key from context for a specific variable.
|
|
1278
|
+
|
|
1279
|
+
Args:
|
|
1280
|
+
variable_name: The name of the variable to get the targeting key for.
|
|
1281
|
+
|
|
1282
|
+
Returns:
|
|
1283
|
+
The targeting key if one is set in context, None otherwise.
|
|
1284
|
+
Variable-specific targeting takes precedence over the default.
|
|
1285
|
+
"""
|
|
1286
|
+
ctx = _TARGETING_CONTEXT.get()
|
|
1287
|
+
if ctx is None:
|
|
1288
|
+
return None
|
|
1289
|
+
# Variable-specific takes precedence over default
|
|
1290
|
+
return ctx.by_variable.get(variable_name, ctx.default)
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _get_variables_instrument(variables: Any) -> bool:
|
|
1294
|
+
"""Get the `instrument` setting from variables config, defaulting to True."""
|
|
1295
|
+
from logfire._internal.config import LocalVariablesOptions, VariablesOptions
|
|
1296
|
+
|
|
1297
|
+
if isinstance(variables, (VariablesOptions, LocalVariablesOptions)):
|
|
1298
|
+
return variables.instrument
|
|
1299
|
+
return True
|