Coverage for little_loops / fsm / interpolation.py: 25%
152 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:12 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:12 -0500
1"""Variable interpolation for FSM loop definitions.
3This module provides runtime variable substitution using ${namespace.path}
4syntax. Variables are resolved against an InterpolationContext that holds
5runtime state including user context, captured values, and metadata.
7Supported namespaces:
8 context: User-defined variables from FSM context block
9 captured: Values stored via capture: in previous states
10 prev: Previous state's result (shorthand)
11 result: Current evaluation result
12 state: Current state metadata (name, iteration)
13 loop: Loop-level metadata (name, started_at, elapsed_ms, elapsed)
14 env: Environment variables
15 messages: Shared append-only message log (${messages}, ${messages.last(N)}, ${messages.summary})
16 param: Per-state parameter bindings for fragment references
17"""
19from __future__ import annotations
21import os
22import re
23from dataclasses import dataclass, field
24from typing import Any
26# Pre-compiled patterns for performance
27VARIABLE_PATTERN = re.compile(r"\$\{([^}]+)\}")
28ESCAPED_PATTERN = re.compile(r"\$\$\{")
29ESCAPED_PLACEHOLDER = "\x00ESCAPED\x00"
32class InterpolationError(Exception):
33 """Raised when variable interpolation fails."""
35 pass
38@dataclass
39class InterpolationContext:
40 """Runtime context for variable resolution.
42 Holds all namespace data needed to resolve ${namespace.path} variables
43 during FSM execution.
45 Attributes:
46 context: User-defined variables from FSM context block
47 captured: Stored action results {varname: {output, stderr, exit_code, duration_ms}}
48 prev: Previous state result or None if first state
49 result: Current evaluation result or None
50 state_name: Current state name
51 iteration: Current loop iteration (1-based)
52 loop_name: FSM loop name
53 started_at: ISO timestamp when loop started
54 elapsed_ms: Milliseconds since loop started
55 param: Per-state parameter bindings for fragment references (resolved from fragment_bindings)
56 """
58 context: dict[str, Any] = field(default_factory=dict)
59 captured: dict[str, dict[str, Any]] = field(default_factory=dict)
60 prev: dict[str, Any] | None = None
61 result: dict[str, Any] | None = None
62 state_name: str = ""
63 iteration: int = 1
64 loop_name: str = ""
65 started_at: str = ""
66 elapsed_ms: int = 0
67 messages: list[str] = field(default_factory=list)
68 messages_summary: str = ""
69 param: dict[str, Any] = field(default_factory=dict)
71 def resolve(self, namespace: str, path: str) -> Any:
72 """Resolve a namespace.path reference to its value.
74 Args:
75 namespace: The namespace identifier (context, captured, etc.)
76 path: The dot-separated path within the namespace
78 Returns:
79 The resolved value
81 Raises:
82 InterpolationError: If namespace unknown or path not found
83 """
84 if namespace == "context":
85 return self._get_nested(self.context, path, "context")
86 elif namespace == "captured":
87 return self._get_nested(self.captured, path, "captured")
88 elif namespace == "prev":
89 if self.prev is None:
90 raise InterpolationError("No previous state result available")
91 return self._get_nested(self.prev, path, "prev")
92 elif namespace == "result":
93 if self.result is None:
94 raise InterpolationError("No evaluation result available")
95 return self._get_nested(self.result, path, "result")
96 elif namespace == "state":
97 return self._get_state_value(path)
98 elif namespace == "loop":
99 return self._get_loop_value(path)
100 elif namespace == "env":
101 value = os.environ.get(path)
102 if value is None:
103 raise InterpolationError(f"Environment variable '{path}' not set")
104 return value
105 elif namespace == "messages":
106 return self._get_messages_value(path)
107 elif namespace == "param":
108 return self._get_nested(self.param, path, "param")
109 else:
110 raise InterpolationError(f"Unknown namespace: {namespace}")
112 def _get_nested(self, obj: dict[str, Any], path: str, namespace: str) -> Any:
113 """Get nested value from dict using dot notation.
115 Args:
116 obj: Dictionary to traverse
117 path: Dot-separated path (e.g., "errors.output")
118 namespace: Namespace name for error messages
120 Returns:
121 The value at the path
123 Raises:
124 InterpolationError: If path not found
125 """
126 parts = path.split(".")
127 current: Any = obj
128 for i, part in enumerate(parts):
129 if isinstance(current, dict) and part in current:
130 current = current[part]
131 else:
132 traversed = ".".join(parts[: i + 1])
133 raise InterpolationError(f"Path '{traversed}' not found in {namespace}")
134 return current
136 def _get_state_value(self, key: str) -> Any:
137 """Get state metadata value.
139 Args:
140 key: State property name (name or iteration)
142 Returns:
143 The state property value
145 Raises:
146 InterpolationError: If key unknown
147 """
148 if key == "name":
149 return self.state_name
150 elif key == "iteration":
151 return self.iteration
152 else:
153 raise InterpolationError(f"Unknown state property: {key}")
155 def _get_messages_value(self, path: str) -> str:
156 """Get value from the shared messages log.
158 Args:
159 path: Empty string or "output" for full log; "last(N)" for last N entries;
160 "summary" for the pre-computed summary string.
162 Returns:
163 The resolved messages string
165 Raises:
166 InterpolationError: If path is unrecognised
167 """
168 if not path or path == "output":
169 return "\n".join(self.messages)
170 m = re.match(r"^last\((\d+)\)$", path)
171 if m:
172 n = int(m.group(1))
173 return "\n".join(self.messages[-n:])
174 if path == "summary":
175 return self.messages_summary
176 raise InterpolationError(f"Unknown messages property: {path!r}")
178 def _get_loop_value(self, key: str) -> Any:
179 """Get loop metadata value.
181 Args:
182 key: Loop property name
184 Returns:
185 The loop property value
187 Raises:
188 InterpolationError: If key unknown
189 """
190 if key == "name":
191 return self.loop_name
192 elif key == "started_at":
193 return self.started_at
194 elif key == "elapsed_ms":
195 return self.elapsed_ms
196 elif key == "elapsed":
197 return _format_duration(self.elapsed_ms)
198 else:
199 raise InterpolationError(f"Unknown loop property: {key}")
202def interpolate(template: str, ctx: InterpolationContext) -> str:
203 """Replace ${namespace.path} variables in template string.
205 Resolves variables at runtime against the provided context.
206 Handles $${...} escaping (becomes literal ${...}).
208 Args:
209 template: String containing variable references
210 ctx: Runtime context for resolution
212 Returns:
213 String with all variables resolved
215 Raises:
216 InterpolationError: If variable format invalid or value not found
217 """
218 # Replace escaped sequences with placeholder
219 result = ESCAPED_PATTERN.sub(ESCAPED_PLACEHOLDER, template)
221 def replace_var(match: re.Match[str]) -> str:
222 full_path = match.group(1)
224 # Parse optional fallback suffixes
225 # ${namespace.path:default=value} → use "value" on missing path
226 # ${namespace.path?} → use "" on missing path
227 default_value: str | None = None
228 nullable = False
230 # Check for :default= first (so ? inside a default value is literal)
231 if ":default=" in full_path:
232 var_part, default_value = full_path.split(":default=", 1)
233 if var_part.endswith("?"):
234 raise InterpolationError(
235 f"Ambiguous suffix: ${{{full_path}}} "
236 "(?:default=... and ? are mutually exclusive)"
237 )
238 full_path = var_part
239 elif full_path.endswith("?"):
240 nullable = True
241 full_path = full_path[:-1]
243 if full_path == "messages":
244 # Bare ${messages} is shorthand for the full message log
245 namespace, path = "messages", ""
246 elif "." not in full_path:
247 raise InterpolationError(
248 f"Invalid variable: ${{{full_path}}} (expected namespace.path)"
249 )
250 else:
251 namespace, path = full_path.split(".", 1)
253 try:
254 value = ctx.resolve(namespace, path)
255 if value is None:
256 return ""
257 return str(value)
258 except InterpolationError:
259 if default_value is not None:
260 return default_value
261 if nullable:
262 return ""
263 raise
265 result = VARIABLE_PATTERN.sub(replace_var, result)
267 # Restore escaped sequences as literal ${
268 result = result.replace(ESCAPED_PLACEHOLDER, "${")
270 return result
273def interpolate_dict(obj: dict[str, Any], ctx: InterpolationContext) -> dict[str, Any]:
274 """Recursively interpolate all string values in a dict.
276 Only string values are interpolated. Non-string values (int, float,
277 bool, None) are passed through unchanged. Nested dicts and lists
278 are recursively processed.
280 Args:
281 obj: Dictionary to process
282 ctx: Runtime context for resolution
284 Returns:
285 New dictionary with interpolated string values
287 Raises:
288 InterpolationError: If any variable resolution fails
289 """
290 result: dict[str, Any] = {}
291 for key, value in obj.items():
292 if isinstance(value, str):
293 result[key] = interpolate(value, ctx)
294 elif isinstance(value, dict):
295 result[key] = interpolate_dict(value, ctx)
296 elif isinstance(value, list):
297 result[key] = _interpolate_list(value, ctx)
298 else:
299 result[key] = value
300 return result
303def _interpolate_list(items: list[Any], ctx: InterpolationContext) -> list[Any]:
304 """Interpolate string values in a list.
306 Args:
307 items: List to process
308 ctx: Runtime context for resolution
310 Returns:
311 New list with interpolated string values
312 """
313 result: list[Any] = []
314 for item in items:
315 if isinstance(item, str):
316 result.append(interpolate(item, ctx))
317 elif isinstance(item, dict):
318 result.append(interpolate_dict(item, ctx))
319 elif isinstance(item, list):
320 result.append(_interpolate_list(item, ctx))
321 else:
322 result.append(item)
323 return result
326def _format_duration(ms: int) -> str:
327 """Format milliseconds as human-readable duration.
329 Args:
330 ms: Duration in milliseconds
332 Returns:
333 Formatted string like "500ms", "30s", or "2m 15s"
334 """
335 if ms < 1000:
336 return f"{ms}ms"
337 seconds = ms // 1000
338 if seconds < 60:
339 return f"{seconds}s"
340 minutes = seconds // 60
341 remaining_seconds = seconds % 60
342 if remaining_seconds == 0:
343 return f"{minutes}m"
344 return f"{minutes}m {remaining_seconds}s"