python-yama 0.1.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.
yama/mocks.py ADDED
@@ -0,0 +1,383 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import importlib.util
5
+ import inspect
6
+ import json
7
+ import time
8
+ from copy import deepcopy
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from types import ModuleType
12
+ from typing import Any, Callable, Sequence
13
+
14
+
15
+ class ToolMockError(Exception):
16
+ def __init__(
17
+ self,
18
+ code: str,
19
+ message: str,
20
+ details: Any = None,
21
+ retryable: bool = False,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.code = code
25
+ self.message = message
26
+ self.details = details
27
+ self.retryable = retryable
28
+
29
+ def as_result(self) -> dict[str, Any]:
30
+ return {
31
+ "error": {
32
+ "code": self.code,
33
+ "message": self.message,
34
+ "details": self.details,
35
+ "retryable": self.retryable,
36
+ }
37
+ }
38
+
39
+
40
+ class MockConfigurationError(ValueError):
41
+ pass
42
+
43
+
44
+ class MockInvocationError(RuntimeError):
45
+ pass
46
+
47
+
48
+ class MockImplementationError(RuntimeError):
49
+ pass
50
+
51
+
52
+ @dataclass
53
+ class ToolMockContext:
54
+ plugin_root: Path
55
+ case_key: str
56
+ run_index: int
57
+ step_id: str
58
+ step_index: int
59
+ request_index: int
60
+ tool_call_id: str
61
+ call_index: int
62
+ transcript: Sequence[dict[str, Any]]
63
+ state: dict[str, Any]
64
+
65
+
66
+ def load_python_callable(
67
+ reference: str,
68
+ plugin_root: Path,
69
+ *,
70
+ relative_root: Path | None = None,
71
+ ) -> Callable[..., Any]:
72
+ if ":" not in reference:
73
+ raise MockConfigurationError(f"Python callable must contain ':': {reference}")
74
+ location, attribute = reference.rsplit(":", 1)
75
+ if not attribute:
76
+ raise MockConfigurationError(f"Python callable name is missing: {reference}")
77
+ module: ModuleType
78
+ if location.endswith(".py") or "/" in location or "\\" in location:
79
+ path = ((relative_root or plugin_root) / location).resolve()
80
+ if not path.is_relative_to(plugin_root.resolve()):
81
+ raise MockConfigurationError(
82
+ f"Python mock must be inside plugin root: {path}"
83
+ )
84
+ if not path.is_file() or path.suffix != ".py":
85
+ raise MockConfigurationError(f"Python mock file does not exist: {path}")
86
+ module_name = f"yama_plugin_mock_{abs(hash(path))}"
87
+ spec = importlib.util.spec_from_file_location(module_name, path)
88
+ if spec is None or spec.loader is None:
89
+ raise MockConfigurationError(f"cannot load Python mock: {path}")
90
+ module = importlib.util.module_from_spec(spec)
91
+ spec.loader.exec_module(module)
92
+ else:
93
+ try:
94
+ module = importlib.import_module(location)
95
+ except ImportError as exc:
96
+ raise MockConfigurationError(
97
+ f"cannot import Python mock module: {location}"
98
+ ) from exc
99
+ target = getattr(module, attribute, None)
100
+ if not callable(target):
101
+ raise MockConfigurationError(f"Python mock target is not callable: {reference}")
102
+ return target
103
+
104
+
105
+ def _validate_handler_signature(target: Callable[..., Any], reference: str) -> None:
106
+ signature = inspect.signature(target)
107
+ try:
108
+ signature.bind(arguments={}, context=None, config={})
109
+ except TypeError as exc:
110
+ raise MockConfigurationError(
111
+ f"Python mock {reference} must accept keyword arguments, context and config"
112
+ ) from exc
113
+
114
+
115
+ def _json_clone(value: Any, label: str) -> Any:
116
+ try:
117
+ return json.loads(json.dumps(value, ensure_ascii=False))
118
+ except (TypeError, ValueError) as exc:
119
+ raise MockImplementationError(
120
+ f"{label} must be JSON serializable: {exc}"
121
+ ) from exc
122
+
123
+
124
+ def _matches(actual: Any, expected: Any) -> bool:
125
+ if isinstance(expected, dict):
126
+ matcher_keys = {
127
+ "equals",
128
+ "contains",
129
+ "matches",
130
+ "is_instance",
131
+ "exists",
132
+ "one_of",
133
+ }
134
+ selected = matcher_keys.intersection(expected)
135
+ if selected:
136
+ if len(selected) != 1:
137
+ raise MockConfigurationError("a matcher must use exactly one operation")
138
+ operation = next(iter(selected))
139
+ wanted = expected[operation]
140
+ if operation == "equals":
141
+ return actual == wanted
142
+ if operation == "contains":
143
+ return (
144
+ wanted in actual if isinstance(actual, (str, list, dict)) else False
145
+ )
146
+ if operation == "one_of":
147
+ return isinstance(wanted, list) and actual in wanted
148
+ if operation == "matches":
149
+ import re
150
+
151
+ return (
152
+ isinstance(actual, str)
153
+ and re.search(str(wanted), actual) is not None
154
+ )
155
+ if operation == "exists":
156
+ return (actual is not _MISSING) is bool(wanted)
157
+ types = {
158
+ "array": list,
159
+ "object": dict,
160
+ "string": str,
161
+ "number": (int, float),
162
+ "integer": int,
163
+ "boolean": bool,
164
+ "null": type(None),
165
+ }
166
+ return wanted in types and isinstance(actual, types[wanted])
167
+ if not isinstance(actual, dict):
168
+ return False
169
+ return all(
170
+ _matches(actual.get(key, _MISSING), value)
171
+ for key, value in expected.items()
172
+ )
173
+ if isinstance(expected, list):
174
+ return actual == expected
175
+ return actual == expected
176
+
177
+
178
+ _MISSING = object()
179
+
180
+
181
+ def _state_diff(before: Any, after: Any, path: str = "$") -> list[dict[str, Any]]:
182
+ if before == after:
183
+ return []
184
+ if isinstance(before, dict) and isinstance(after, dict):
185
+ changes: list[dict[str, Any]] = []
186
+ for key in sorted(set(before) | set(after)):
187
+ child_path = f"{path}.{key}"
188
+ if key not in before:
189
+ changes.append({"op": "add", "path": child_path, "value": after[key]})
190
+ elif key not in after:
191
+ changes.append({"op": "remove", "path": child_path, "old": before[key]})
192
+ else:
193
+ changes.extend(_state_diff(before[key], after[key], child_path))
194
+ return changes
195
+ return [{"op": "replace", "path": path, "old": before, "value": after}]
196
+
197
+
198
+ class MockToolRegistry:
199
+ def __init__(
200
+ self,
201
+ config: dict[str, Any] | None,
202
+ *,
203
+ plugin_root: Path,
204
+ relative_root: Path | None = None,
205
+ exposed_tools: set[str],
206
+ ) -> None:
207
+ config = config or {}
208
+ if not isinstance(config, dict):
209
+ raise MockConfigurationError("mocks must be an object")
210
+ self.plugin_root = plugin_root
211
+ self.relative_root = relative_root or plugin_root
212
+ unknown = set(config) - {"state", "tools"}
213
+ if unknown:
214
+ hint = (
215
+ "; undeclared tools and unmatched arguments always fail"
216
+ if "strict" in unknown
217
+ else ""
218
+ )
219
+ raise MockConfigurationError(
220
+ f"unknown mock fields: {sorted(unknown)}{hint}"
221
+ )
222
+ initial_state = config.get("state", {})
223
+ if not isinstance(initial_state, dict):
224
+ raise MockConfigurationError("mocks.state must be an object")
225
+ self.state: dict[str, Any] = _json_clone(initial_state, "mocks.state")
226
+ tools = config.get("tools", {})
227
+ if not isinstance(tools, dict):
228
+ raise MockConfigurationError("mocks.tools must be an object")
229
+ extra = set(tools) - exposed_tools
230
+ missing = exposed_tools - set(tools)
231
+ if extra:
232
+ raise MockConfigurationError(
233
+ f"mock handlers are not exposed in context.tools: {sorted(extra)}"
234
+ )
235
+ if missing:
236
+ raise MockConfigurationError(
237
+ f"context tools have no mock handlers: {sorted(missing)}"
238
+ )
239
+ self.tools: dict[str, dict[str, Any]] = deepcopy(tools)
240
+ self.call_counts = {name: 0 for name in tools}
241
+ self.sequence_indexes = {name: 0 for name in tools}
242
+ self.events: list[dict[str, Any]] = []
243
+ self.handlers: dict[str, Callable[..., Any]] = {}
244
+ for name, tool_config in self.tools.items():
245
+ self._validate_tool_config(name, tool_config)
246
+
247
+ def _validate_tool_config(self, name: str, config: Any) -> None:
248
+ if not isinstance(config, dict):
249
+ raise MockConfigurationError(f"mock config must be an object: {name}")
250
+ unknown = set(config) - {"match", "respond", "handler"}
251
+ if unknown:
252
+ hint = (
253
+ "; tool-call expectations belong in assert.hard (tool_called)"
254
+ if "required" in unknown
255
+ else ""
256
+ )
257
+ raise MockConfigurationError(
258
+ f"unknown mock fields for {name}: {sorted(unknown)}{hint}"
259
+ )
260
+ if ("respond" in config) == ("handler" in config):
261
+ raise MockConfigurationError(
262
+ f"mock {name} must choose exactly one of respond or handler"
263
+ )
264
+ if "handler" in config:
265
+ handler = config["handler"]
266
+ if not isinstance(handler, dict) or not isinstance(
267
+ handler.get("python"), str
268
+ ):
269
+ raise MockConfigurationError(f"mock {name} handler.python is required")
270
+ unknown = set(handler) - {"python", "with"}
271
+ if unknown:
272
+ raise MockConfigurationError(
273
+ f"unknown handler fields for {name}: {sorted(unknown)}"
274
+ )
275
+ target = load_python_callable(
276
+ handler["python"],
277
+ self.plugin_root,
278
+ relative_root=self.relative_root,
279
+ )
280
+ _validate_handler_signature(target, handler["python"])
281
+ self.handlers[name] = target
282
+ else:
283
+ respond = config["respond"]
284
+ if not isinstance(respond, dict):
285
+ raise MockConfigurationError(f"mock {name} respond must be an object")
286
+ modes = set(respond).intersection({"result", "error", "sequence"})
287
+ if len(modes) != 1:
288
+ raise MockConfigurationError(
289
+ f"mock {name} respond must choose exactly one of result, error or sequence"
290
+ )
291
+ if "sequence" in respond and (
292
+ not isinstance(respond["sequence"], list) or not respond["sequence"]
293
+ ):
294
+ raise MockConfigurationError(f"mock {name} sequence must be non-empty")
295
+
296
+ def next_call_index(self, name: str) -> int:
297
+ return self.call_counts.get(name, 0) + 1
298
+
299
+ async def call(
300
+ self,
301
+ name: str,
302
+ arguments: dict[str, Any],
303
+ context: ToolMockContext,
304
+ ) -> Any:
305
+ if name not in self.tools:
306
+ raise MockInvocationError(f"tool has no mock handler: {name}")
307
+ config = self.tools[name]
308
+ expected = config.get("match", {}).get("arguments", {})
309
+ match_failed = bool(expected) and not _matches(arguments, expected)
310
+ self.call_counts[name] += 1
311
+ before = _json_clone(self.state, "mock state")
312
+ started = time.perf_counter()
313
+ is_error = False
314
+ try:
315
+ if match_failed:
316
+ result = {
317
+ "error": {
318
+ "code": "UNKNOWN_ERROR",
319
+ "message": "unknown error",
320
+ "details": None,
321
+ "retryable": False,
322
+ }
323
+ }
324
+ is_error = True
325
+ elif "handler" in config:
326
+ handler = config["handler"]
327
+ target = self.handlers[name]
328
+ value = target(
329
+ arguments=deepcopy(arguments),
330
+ context=context,
331
+ config=deepcopy(handler.get("with", {})),
332
+ )
333
+ result = await value if inspect.isawaitable(value) else value
334
+ else:
335
+ result = self._declarative_result(name, config["respond"])
336
+ except ToolMockError as exc:
337
+ result = exc.as_result()
338
+ is_error = True
339
+ except (MockConfigurationError, MockInvocationError, MockImplementationError):
340
+ raise
341
+ except Exception as exc:
342
+ raise MockImplementationError(f"Python mock {name} failed: {exc}") from exc
343
+ result = _json_clone(result, f"mock result for {name}")
344
+ after = _json_clone(self.state, "mock state")
345
+ event = {
346
+ "tool": name,
347
+ "tool_call_id": context.tool_call_id,
348
+ "step_id": context.step_id,
349
+ "request_index": context.request_index,
350
+ "call_index": context.call_index,
351
+ "handler_kind": "python" if "handler" in config else "declarative",
352
+ "callable": config.get("handler", {}).get("python"),
353
+ "match_failed": match_failed,
354
+ "arguments": arguments,
355
+ "result": result,
356
+ "is_error": is_error or (isinstance(result, dict) and "error" in result),
357
+ "duration_ms": round((time.perf_counter() - started) * 1000, 3),
358
+ "state_diff": _state_diff(before, after),
359
+ }
360
+ self.events.append(event)
361
+ return result
362
+
363
+ def _declarative_result(self, name: str, respond: dict[str, Any]) -> Any:
364
+ item = respond
365
+ if "sequence" in respond:
366
+ index = self.sequence_indexes[name]
367
+ if index >= len(respond["sequence"]):
368
+ raise MockInvocationError(f"mock response sequence exhausted: {name}")
369
+ item = respond["sequence"][index]
370
+ self.sequence_indexes[name] += 1
371
+ if "result" in item:
372
+ return deepcopy(item["result"])
373
+ error = item.get("error")
374
+ if not isinstance(error, dict):
375
+ raise MockConfigurationError(f"mock error must be an object: {name}")
376
+ return {
377
+ "error": {
378
+ "code": error.get("code", "TOOL_ERROR"),
379
+ "message": error.get("message", "tool failed"),
380
+ "details": error.get("details"),
381
+ "retryable": bool(error.get("retryable", False)),
382
+ }
383
+ }
yama/models.py ADDED
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, NotRequired, TypedDict
6
+
7
+
8
+ Json = dict[str, Any]
9
+
10
+
11
+ class CheckerResult(TypedDict):
12
+ """Structured return value of a `python` hard-assertion checker.
13
+
14
+ Only `passed` is required. The optional fields refine the diagnostic
15
+ shown in reports; omitted ones fall back as implemented in
16
+ `assertions.py` (`detail` doubles as the failure reason by default).
17
+ Checkers may also return a bare bool or a `(passed, detail)` tuple.
18
+ """
19
+
20
+ passed: bool
21
+ detail: NotRequired[str]
22
+ label: NotRequired[str]
23
+ expected: NotRequired[str]
24
+ actual: NotRequired[str]
25
+ reason: NotRequired[str]
26
+
27
+
28
+ def sanitize_case_key(case_key: str) -> str:
29
+ key = case_key.replace("/", "-").replace("\\", "-")
30
+ if key.endswith(".yaml"):
31
+ key = key[:-5]
32
+ elif key.endswith(".yml"):
33
+ key = key[:-4]
34
+ return key
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ToolSchema:
39
+ name: str
40
+ description: str
41
+ parameters: Json
42
+ source_path: Path | None = None
43
+
44
+ def for_provider(self) -> Json:
45
+ return {
46
+ "type": "function",
47
+ "function": {
48
+ "name": self.name,
49
+ "description": self.description,
50
+ "parameters": self.parameters,
51
+ },
52
+ }
53
+
54
+ @dataclass(frozen=True)
55
+ class ToolCall:
56
+ id: str
57
+ name: str
58
+ arguments: Json
59
+
60
+
61
+ @dataclass
62
+ class LLMResponse:
63
+ content: str = ""
64
+ tool_calls: list[ToolCall] = field(default_factory=list)
65
+ raw: Any = None
66
+
67
+ def assistant_message(self) -> Json:
68
+ message: Json = {"role": "assistant", "content": self.content or ""}
69
+ if self.tool_calls:
70
+ message["tool_calls"] = [
71
+ {
72
+ "id": call.id,
73
+ "type": "function",
74
+ "function": {
75
+ "name": call.name,
76
+ "arguments": call.arguments,
77
+ },
78
+ }
79
+ for call in self.tool_calls
80
+ ]
81
+ return message
82
+
83
+
84
+ @dataclass
85
+ class ToolCallRecord:
86
+ step_id: str
87
+ request_index: int
88
+ call_index: int
89
+ call: ToolCall
90
+ result: Any
91
+ is_error: bool = False
92
+
93
+ def to_dict(self) -> Json:
94
+ return {
95
+ "step_id": self.step_id,
96
+ "request_index": self.request_index,
97
+ "call_index": self.call_index,
98
+ "call": asdict(self.call),
99
+ "result": self.result,
100
+ "is_error": self.is_error,
101
+ }
102
+
103
+
104
+ @dataclass
105
+ class StepResult:
106
+ step_id: str
107
+ user_message: Json
108
+ assistant_message: Json
109
+ tool_calls: list[ToolCallRecord]
110
+ messages: list[Json]
111
+ hard_checks: list[Json] = field(default_factory=list)
112
+ hard_pass: bool = True
113
+ judge_result: Json | None = None
114
+
115
+ def to_dict(self) -> Json:
116
+ return {
117
+ "step_id": self.step_id,
118
+ "user_message": self.user_message,
119
+ "assistant_message": self.assistant_message,
120
+ "tool_calls": [record.to_dict() for record in self.tool_calls],
121
+ "messages": self.messages,
122
+ "hard_checks": self.hard_checks,
123
+ "hard_pass": self.hard_pass,
124
+ "judge_result": self.judge_result,
125
+ }
126
+
127
+
128
+ @dataclass
129
+ class LoadedSkill:
130
+ requested_name: str
131
+ name: str
132
+ description: str
133
+ body: str
134
+ path: Path
135
+ content_hash: str
136
+
137
+ def to_dict(self) -> Json:
138
+ data = asdict(self)
139
+ data["path"] = str(self.path)
140
+ return data
141
+
142
+
143
+ @dataclass
144
+ class ResolvedCase:
145
+ path: Path
146
+ case_key: str
147
+ plugin_root: Path
148
+ evals_root: Path
149
+ model: Json
150
+ context: Json
151
+ mocks: Json
152
+ execution: Json
153
+ steps: list[Json]
154
+ outcome: Json
155
+ system_prompt: str
156
+ skills: list[LoadedSkill]
157
+ tools: list[ToolSchema]
158
+ initial_messages: list[Json]
159
+ fixture_manifest: list[Json] = field(default_factory=list)