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.
Files changed (304) hide show
  1. _logfire_sdk/logfire/.agents/skills/logfire-evals/SKILL.md +170 -0
  2. _logfire_sdk/logfire/.agents/skills/logfire-gateway/SKILL.md +156 -0
  3. _logfire_sdk/logfire/.agents/skills/logfire-infrastructure/SKILL.md +61 -0
  4. _logfire_sdk/logfire/.agents/skills/logfire-infrastructure/references/collector/host-and-infra-metrics.md +229 -0
  5. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/SKILL.md +292 -0
  6. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/auth.md +100 -0
  7. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/ai-sdk.md +145 -0
  8. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/cloudflare-and-deno.md +99 -0
  9. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/frameworks.md +22 -0
  10. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/installation-and-env.md +75 -0
  11. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/nextjs.md +78 -0
  12. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/node-runtime.md +132 -0
  13. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/patterns.md +184 -0
  14. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/project-detection.md +43 -0
  15. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/react-browser.md +18 -0
  16. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/javascript/verification-troubleshooting.md +68 -0
  17. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/python/integrations.md +104 -0
  18. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/python/logging-patterns.md +120 -0
  19. _logfire_sdk/logfire/.agents/skills/logfire-instrumentation/references/rust/patterns.md +113 -0
  20. _logfire_sdk/logfire/.agents/skills/logfire-setup/SKILL.md +58 -0
  21. _logfire_sdk/logfire/.agents/skills/logfire-setup-offline.md +715 -0
  22. _logfire_sdk/logfire/__init__.py +226 -0
  23. _logfire_sdk/logfire/__main__.py +6 -0
  24. _logfire_sdk/logfire/_internal/__init__.py +14 -0
  25. _logfire_sdk/logfire/_internal/ast_utils.py +264 -0
  26. _logfire_sdk/logfire/_internal/async_.py +125 -0
  27. _logfire_sdk/logfire/_internal/auth.py +298 -0
  28. _logfire_sdk/logfire/_internal/auto_trace/__init__.py +74 -0
  29. _logfire_sdk/logfire/_internal/auto_trace/import_hook.py +137 -0
  30. _logfire_sdk/logfire/_internal/auto_trace/rewrite_ast.py +185 -0
  31. _logfire_sdk/logfire/_internal/auto_trace/types.py +44 -0
  32. _logfire_sdk/logfire/_internal/baggage.py +109 -0
  33. _logfire_sdk/logfire/_internal/cli/__init__.py +1081 -0
  34. _logfire_sdk/logfire/_internal/cli/ai_tools.py +231 -0
  35. _logfire_sdk/logfire/_internal/cli/auth.py +151 -0
  36. _logfire_sdk/logfire/_internal/cli/gateway.py +627 -0
  37. _logfire_sdk/logfire/_internal/cli/gateway_auth.py +313 -0
  38. _logfire_sdk/logfire/_internal/cli/prompt.py +50 -0
  39. _logfire_sdk/logfire/_internal/cli/run.py +590 -0
  40. _logfire_sdk/logfire/_internal/client.py +178 -0
  41. _logfire_sdk/logfire/_internal/collect_system_info.py +29 -0
  42. _logfire_sdk/logfire/_internal/config.py +2471 -0
  43. _logfire_sdk/logfire/_internal/config_params.py +343 -0
  44. _logfire_sdk/logfire/_internal/constants.py +185 -0
  45. _logfire_sdk/logfire/_internal/db_statement_summary.py +140 -0
  46. _logfire_sdk/logfire/_internal/exporters/__init__.py +1 -0
  47. _logfire_sdk/logfire/_internal/exporters/console.py +517 -0
  48. _logfire_sdk/logfire/_internal/exporters/dynamic_batch.py +49 -0
  49. _logfire_sdk/logfire/_internal/exporters/logs.py +30 -0
  50. _logfire_sdk/logfire/_internal/exporters/otlp.py +517 -0
  51. _logfire_sdk/logfire/_internal/exporters/processor_wrapper.py +652 -0
  52. _logfire_sdk/logfire/_internal/exporters/quiet_metrics.py +18 -0
  53. _logfire_sdk/logfire/_internal/exporters/remove_pending.py +45 -0
  54. _logfire_sdk/logfire/_internal/exporters/test.py +257 -0
  55. _logfire_sdk/logfire/_internal/exporters/wrapper.py +106 -0
  56. _logfire_sdk/logfire/_internal/formatter.py +379 -0
  57. _logfire_sdk/logfire/_internal/forwarding.py +585 -0
  58. _logfire_sdk/logfire/_internal/http_transport.py +193 -0
  59. _logfire_sdk/logfire/_internal/instrument.py +254 -0
  60. _logfire_sdk/logfire/_internal/integrations/__init__.py +1 -0
  61. _logfire_sdk/logfire/_internal/integrations/aiohttp_client.py +324 -0
  62. _logfire_sdk/logfire/_internal/integrations/aiohttp_server.py +22 -0
  63. _logfire_sdk/logfire/_internal/integrations/asgi.py +117 -0
  64. _logfire_sdk/logfire/_internal/integrations/asyncpg.py +21 -0
  65. _logfire_sdk/logfire/_internal/integrations/aws_lambda.py +36 -0
  66. _logfire_sdk/logfire/_internal/integrations/celery.py +20 -0
  67. _logfire_sdk/logfire/_internal/integrations/claude_agent_sdk.py +628 -0
  68. _logfire_sdk/logfire/_internal/integrations/django.py +41 -0
  69. _logfire_sdk/logfire/_internal/integrations/dspy.py +20 -0
  70. _logfire_sdk/logfire/_internal/integrations/executors.py +99 -0
  71. _logfire_sdk/logfire/_internal/integrations/fastapi.py +421 -0
  72. _logfire_sdk/logfire/_internal/integrations/flask.py +53 -0
  73. _logfire_sdk/logfire/_internal/integrations/google_genai.py +66 -0
  74. _logfire_sdk/logfire/_internal/integrations/httpx.py +577 -0
  75. _logfire_sdk/logfire/_internal/integrations/litellm.py +20 -0
  76. _logfire_sdk/logfire/_internal/integrations/litestar.py +77 -0
  77. _logfire_sdk/logfire/_internal/integrations/llm_providers/anthropic.py +406 -0
  78. _logfire_sdk/logfire/_internal/integrations/llm_providers/llm_provider.py +251 -0
  79. _logfire_sdk/logfire/_internal/integrations/llm_providers/openai.py +892 -0
  80. _logfire_sdk/logfire/_internal/integrations/llm_providers/semconv.py +194 -0
  81. _logfire_sdk/logfire/_internal/integrations/llm_providers/types.py +36 -0
  82. _logfire_sdk/logfire/_internal/integrations/llm_providers/usage.py +89 -0
  83. _logfire_sdk/logfire/_internal/integrations/mcp.py +191 -0
  84. _logfire_sdk/logfire/_internal/integrations/monty.py +155 -0
  85. _logfire_sdk/logfire/_internal/integrations/mysql.py +34 -0
  86. _logfire_sdk/logfire/_internal/integrations/openai_agents.py +460 -0
  87. _logfire_sdk/logfire/_internal/integrations/print.py +176 -0
  88. _logfire_sdk/logfire/_internal/integrations/psycopg.py +126 -0
  89. _logfire_sdk/logfire/_internal/integrations/pydantic_ai.py +65 -0
  90. _logfire_sdk/logfire/_internal/integrations/pymongo.py +40 -0
  91. _logfire_sdk/logfire/_internal/integrations/pytest.py +648 -0
  92. _logfire_sdk/logfire/_internal/integrations/redis.py +57 -0
  93. _logfire_sdk/logfire/_internal/integrations/requests.py +34 -0
  94. _logfire_sdk/logfire/_internal/integrations/snowflake.py +162 -0
  95. _logfire_sdk/logfire/_internal/integrations/sqlalchemy.py +70 -0
  96. _logfire_sdk/logfire/_internal/integrations/sqlite3.py +29 -0
  97. _logfire_sdk/logfire/_internal/integrations/starlette.py +50 -0
  98. _logfire_sdk/logfire/_internal/integrations/surrealdb.py +137 -0
  99. _logfire_sdk/logfire/_internal/integrations/system_metrics.py +322 -0
  100. _logfire_sdk/logfire/_internal/integrations/wsgi.py +34 -0
  101. _logfire_sdk/logfire/_internal/interactive.py +115 -0
  102. _logfire_sdk/logfire/_internal/json_encoder.py +344 -0
  103. _logfire_sdk/logfire/_internal/json_formatter.py +350 -0
  104. _logfire_sdk/logfire/_internal/json_schema.py +455 -0
  105. _logfire_sdk/logfire/_internal/json_types.py +147 -0
  106. _logfire_sdk/logfire/_internal/logs.py +142 -0
  107. _logfire_sdk/logfire/_internal/main.py +3498 -0
  108. _logfire_sdk/logfire/_internal/metrics.py +283 -0
  109. _logfire_sdk/logfire/_internal/scrubbing.py +404 -0
  110. _logfire_sdk/logfire/_internal/server_response.py +44 -0
  111. _logfire_sdk/logfire/_internal/stack_info.py +147 -0
  112. _logfire_sdk/logfire/_internal/tracer.py +538 -0
  113. _logfire_sdk/logfire/_internal/ulid.py +40 -0
  114. _logfire_sdk/logfire/_internal/utils.py +550 -0
  115. _logfire_sdk/logfire/cli.py +3 -0
  116. _logfire_sdk/logfire/db_api.py +393 -0
  117. _logfire_sdk/logfire/exceptions.py +9 -0
  118. _logfire_sdk/logfire/experimental/__init__.py +0 -0
  119. _logfire_sdk/logfire/experimental/annotations.py +80 -0
  120. _logfire_sdk/logfire/experimental/api_client.py +1421 -0
  121. _logfire_sdk/logfire/integrations/__init__.py +4 -0
  122. _logfire_sdk/logfire/integrations/aiohttp_client.py +12 -0
  123. _logfire_sdk/logfire/integrations/flask.py +26 -0
  124. _logfire_sdk/logfire/integrations/httpx.py +49 -0
  125. _logfire_sdk/logfire/integrations/logging.py +153 -0
  126. _logfire_sdk/logfire/integrations/loguru.py +98 -0
  127. _logfire_sdk/logfire/integrations/psycopg.py +23 -0
  128. _logfire_sdk/logfire/integrations/pydantic.py +541 -0
  129. _logfire_sdk/logfire/integrations/redis.py +31 -0
  130. _logfire_sdk/logfire/integrations/sqlalchemy.py +12 -0
  131. _logfire_sdk/logfire/integrations/structlog.py +55 -0
  132. _logfire_sdk/logfire/integrations/wsgi.py +15 -0
  133. _logfire_sdk/logfire/propagate.py +161 -0
  134. _logfire_sdk/logfire/py.typed +0 -0
  135. _logfire_sdk/logfire/query_client.py +701 -0
  136. _logfire_sdk/logfire/sampling/__init__.py +9 -0
  137. _logfire_sdk/logfire/sampling/_tail_sampling.py +292 -0
  138. _logfire_sdk/logfire/testing.py +127 -0
  139. _logfire_sdk/logfire/types.py +303 -0
  140. _logfire_sdk/logfire/variables/__init__.py +154 -0
  141. _logfire_sdk/logfire/variables/_handlebars.py +77 -0
  142. _logfire_sdk/logfire/variables/abstract.py +2027 -0
  143. _logfire_sdk/logfire/variables/composition.py +430 -0
  144. _logfire_sdk/logfire/variables/config.py +694 -0
  145. _logfire_sdk/logfire/variables/local.py +134 -0
  146. _logfire_sdk/logfire/variables/remote.py +877 -0
  147. _logfire_sdk/logfire/variables/template_validation.py +208 -0
  148. _logfire_sdk/logfire/variables/variable.py +1299 -0
  149. _logfire_sdk/logfire/version.py +5 -0
  150. logfire/.agents/skills/logfire-evals/SKILL.md +170 -0
  151. logfire/.agents/skills/logfire-gateway/SKILL.md +156 -0
  152. logfire/.agents/skills/logfire-infrastructure/SKILL.md +61 -0
  153. logfire/.agents/skills/logfire-infrastructure/references/collector/host-and-infra-metrics.md +229 -0
  154. logfire/.agents/skills/logfire-instrumentation/SKILL.md +292 -0
  155. logfire/.agents/skills/logfire-instrumentation/references/auth.md +100 -0
  156. logfire/.agents/skills/logfire-instrumentation/references/javascript/ai-sdk.md +145 -0
  157. logfire/.agents/skills/logfire-instrumentation/references/javascript/cloudflare-and-deno.md +99 -0
  158. logfire/.agents/skills/logfire-instrumentation/references/javascript/frameworks.md +22 -0
  159. logfire/.agents/skills/logfire-instrumentation/references/javascript/installation-and-env.md +75 -0
  160. logfire/.agents/skills/logfire-instrumentation/references/javascript/nextjs.md +78 -0
  161. logfire/.agents/skills/logfire-instrumentation/references/javascript/node-runtime.md +132 -0
  162. logfire/.agents/skills/logfire-instrumentation/references/javascript/patterns.md +184 -0
  163. logfire/.agents/skills/logfire-instrumentation/references/javascript/project-detection.md +43 -0
  164. logfire/.agents/skills/logfire-instrumentation/references/javascript/react-browser.md +18 -0
  165. logfire/.agents/skills/logfire-instrumentation/references/javascript/verification-troubleshooting.md +68 -0
  166. logfire/.agents/skills/logfire-instrumentation/references/python/integrations.md +104 -0
  167. logfire/.agents/skills/logfire-instrumentation/references/python/logging-patterns.md +120 -0
  168. logfire/.agents/skills/logfire-instrumentation/references/rust/patterns.md +113 -0
  169. logfire/.agents/skills/logfire-setup/SKILL.md +58 -0
  170. logfire/.agents/skills/logfire-setup-offline.md +715 -0
  171. logfire/__init__.py +226 -0
  172. logfire/__main__.py +6 -0
  173. logfire/_internal/__init__.py +14 -0
  174. logfire/_internal/ast_utils.py +264 -0
  175. logfire/_internal/async_.py +125 -0
  176. logfire/_internal/auth.py +298 -0
  177. logfire/_internal/auto_trace/__init__.py +74 -0
  178. logfire/_internal/auto_trace/import_hook.py +137 -0
  179. logfire/_internal/auto_trace/rewrite_ast.py +185 -0
  180. logfire/_internal/auto_trace/types.py +44 -0
  181. logfire/_internal/baggage.py +109 -0
  182. logfire/_internal/cli/__init__.py +1081 -0
  183. logfire/_internal/cli/ai_tools.py +231 -0
  184. logfire/_internal/cli/auth.py +151 -0
  185. logfire/_internal/cli/gateway.py +627 -0
  186. logfire/_internal/cli/gateway_auth.py +313 -0
  187. logfire/_internal/cli/prompt.py +50 -0
  188. logfire/_internal/cli/run.py +590 -0
  189. logfire/_internal/client.py +178 -0
  190. logfire/_internal/collect_system_info.py +29 -0
  191. logfire/_internal/config.py +2471 -0
  192. logfire/_internal/config_params.py +343 -0
  193. logfire/_internal/constants.py +185 -0
  194. logfire/_internal/db_statement_summary.py +140 -0
  195. logfire/_internal/exporters/__init__.py +1 -0
  196. logfire/_internal/exporters/console.py +517 -0
  197. logfire/_internal/exporters/dynamic_batch.py +49 -0
  198. logfire/_internal/exporters/logs.py +30 -0
  199. logfire/_internal/exporters/otlp.py +517 -0
  200. logfire/_internal/exporters/processor_wrapper.py +652 -0
  201. logfire/_internal/exporters/quiet_metrics.py +18 -0
  202. logfire/_internal/exporters/remove_pending.py +45 -0
  203. logfire/_internal/exporters/test.py +257 -0
  204. logfire/_internal/exporters/wrapper.py +106 -0
  205. logfire/_internal/formatter.py +379 -0
  206. logfire/_internal/forwarding.py +585 -0
  207. logfire/_internal/http_transport.py +193 -0
  208. logfire/_internal/instrument.py +254 -0
  209. logfire/_internal/integrations/__init__.py +1 -0
  210. logfire/_internal/integrations/aiohttp_client.py +324 -0
  211. logfire/_internal/integrations/aiohttp_server.py +22 -0
  212. logfire/_internal/integrations/asgi.py +117 -0
  213. logfire/_internal/integrations/asyncpg.py +21 -0
  214. logfire/_internal/integrations/aws_lambda.py +36 -0
  215. logfire/_internal/integrations/celery.py +20 -0
  216. logfire/_internal/integrations/claude_agent_sdk.py +628 -0
  217. logfire/_internal/integrations/django.py +41 -0
  218. logfire/_internal/integrations/dspy.py +20 -0
  219. logfire/_internal/integrations/executors.py +99 -0
  220. logfire/_internal/integrations/fastapi.py +421 -0
  221. logfire/_internal/integrations/flask.py +53 -0
  222. logfire/_internal/integrations/google_genai.py +66 -0
  223. logfire/_internal/integrations/httpx.py +577 -0
  224. logfire/_internal/integrations/litellm.py +20 -0
  225. logfire/_internal/integrations/litestar.py +77 -0
  226. logfire/_internal/integrations/llm_providers/anthropic.py +406 -0
  227. logfire/_internal/integrations/llm_providers/llm_provider.py +251 -0
  228. logfire/_internal/integrations/llm_providers/openai.py +892 -0
  229. logfire/_internal/integrations/llm_providers/semconv.py +194 -0
  230. logfire/_internal/integrations/llm_providers/types.py +36 -0
  231. logfire/_internal/integrations/llm_providers/usage.py +89 -0
  232. logfire/_internal/integrations/mcp.py +191 -0
  233. logfire/_internal/integrations/monty.py +155 -0
  234. logfire/_internal/integrations/mysql.py +34 -0
  235. logfire/_internal/integrations/openai_agents.py +460 -0
  236. logfire/_internal/integrations/print.py +176 -0
  237. logfire/_internal/integrations/psycopg.py +126 -0
  238. logfire/_internal/integrations/pydantic_ai.py +65 -0
  239. logfire/_internal/integrations/pymongo.py +40 -0
  240. logfire/_internal/integrations/pytest.py +648 -0
  241. logfire/_internal/integrations/redis.py +57 -0
  242. logfire/_internal/integrations/requests.py +34 -0
  243. logfire/_internal/integrations/snowflake.py +162 -0
  244. logfire/_internal/integrations/sqlalchemy.py +70 -0
  245. logfire/_internal/integrations/sqlite3.py +29 -0
  246. logfire/_internal/integrations/starlette.py +50 -0
  247. logfire/_internal/integrations/surrealdb.py +137 -0
  248. logfire/_internal/integrations/system_metrics.py +322 -0
  249. logfire/_internal/integrations/wsgi.py +34 -0
  250. logfire/_internal/interactive.py +115 -0
  251. logfire/_internal/json_encoder.py +344 -0
  252. logfire/_internal/json_formatter.py +350 -0
  253. logfire/_internal/json_schema.py +455 -0
  254. logfire/_internal/json_types.py +147 -0
  255. logfire/_internal/logs.py +142 -0
  256. logfire/_internal/main.py +3498 -0
  257. logfire/_internal/metrics.py +283 -0
  258. logfire/_internal/scrubbing.py +404 -0
  259. logfire/_internal/server_response.py +44 -0
  260. logfire/_internal/stack_info.py +147 -0
  261. logfire/_internal/tracer.py +538 -0
  262. logfire/_internal/ulid.py +40 -0
  263. logfire/_internal/utils.py +550 -0
  264. logfire/cli.py +3 -0
  265. logfire/db_api.py +393 -0
  266. logfire/exceptions.py +9 -0
  267. logfire/experimental/__init__.py +0 -0
  268. logfire/experimental/annotations.py +80 -0
  269. logfire/experimental/api_client.py +1421 -0
  270. logfire/integrations/__init__.py +4 -0
  271. logfire/integrations/aiohttp_client.py +12 -0
  272. logfire/integrations/flask.py +26 -0
  273. logfire/integrations/httpx.py +49 -0
  274. logfire/integrations/logging.py +153 -0
  275. logfire/integrations/loguru.py +98 -0
  276. logfire/integrations/psycopg.py +23 -0
  277. logfire/integrations/pydantic.py +541 -0
  278. logfire/integrations/redis.py +31 -0
  279. logfire/integrations/sqlalchemy.py +12 -0
  280. logfire/integrations/structlog.py +55 -0
  281. logfire/integrations/wsgi.py +15 -0
  282. logfire/propagate.py +161 -0
  283. logfire/py.typed +0 -0
  284. logfire/query_client.py +701 -0
  285. logfire/sampling/__init__.py +9 -0
  286. logfire/sampling/_tail_sampling.py +292 -0
  287. logfire/testing.py +127 -0
  288. logfire/types.py +303 -0
  289. logfire/variables/__init__.py +154 -0
  290. logfire/variables/_handlebars.py +77 -0
  291. logfire/variables/abstract.py +2027 -0
  292. logfire/variables/composition.py +430 -0
  293. logfire/variables/config.py +694 -0
  294. logfire/variables/local.py +134 -0
  295. logfire/variables/remote.py +877 -0
  296. logfire/variables/template_validation.py +208 -0
  297. logfire/variables/variable.py +1299 -0
  298. logfire/version.py +5 -0
  299. logfire_sdk-5.3.0.dist-info/METADATA +140 -0
  300. logfire_sdk-5.3.0.dist-info/RECORD +304 -0
  301. logfire_sdk-5.3.0.dist-info/WHEEL +4 -0
  302. logfire_sdk-5.3.0.dist-info/entry_points.txt +6 -0
  303. logfire_sdk-5.3.0.dist-info/licenses/LICENSE +21 -0
  304. 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