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.
- python_yama-0.1.0.dist-info/METADATA +98 -0
- python_yama-0.1.0.dist-info/RECORD +27 -0
- python_yama-0.1.0.dist-info/WHEEL +4 -0
- python_yama-0.1.0.dist-info/entry_points.txt +2 -0
- python_yama-0.1.0.dist-info/licenses/LICENSE +21 -0
- yama/__init__.py +31 -0
- yama/__main__.py +3 -0
- yama/assertions.py +577 -0
- yama/cli.py +318 -0
- yama/llm.py +365 -0
- yama/loaders/__init__.py +6 -0
- yama/loaders/case.py +378 -0
- yama/loaders/common.py +29 -0
- yama/loaders/tool_loader.py +173 -0
- yama/mocks.py +383 -0
- yama/models.py +159 -0
- yama/report.py +854 -0
- yama/runner.py +531 -0
- yama/tools/__init__.py +0 -0
- yama/tools/base.py +19 -0
- yama/tools/bash/__init__.py +0 -0
- yama/tools/bash/_cli_shim.py +71 -0
- yama/tools/bash/bash.py +285 -0
- yama/tools/bash/fs.py +530 -0
- yama/tools/skill/__init__.py +0 -0
- yama/tools/skill/skill.py +57 -0
- yama/workspace.py +115 -0
yama/tools/bash/bash.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from ..base import Tool
|
|
9
|
+
from ...mocks import MockConfigurationError, ToolMockContext, ToolMockError
|
|
10
|
+
from .fs import run_bash_cli, run_bash_fs
|
|
11
|
+
|
|
12
|
+
_RULE_FIELDS = {"match", "stdout", "stderr", "exit_code", "passthrough"}
|
|
13
|
+
_NODE_FIELDS = {"description", "rules", "commands", "default"}
|
|
14
|
+
_SPEC_FIELDS = _NODE_FIELDS | {"name"}
|
|
15
|
+
_MATCH_FIELDS = {"argv", "argline"}
|
|
16
|
+
_RESERVED_CLI_FIELDS = {"passthrough", "passthrough_timeout_seconds"}
|
|
17
|
+
DEFAULT_PASSTHROUGH_TIMEOUT_SECONDS = 30.0
|
|
18
|
+
|
|
19
|
+
# normalize_cli_config only validates and shapes the declarative mocks.cli
|
|
20
|
+
# schema. Execution has moved to fs: each mocked command is compiled
|
|
21
|
+
# into a real, PATH-shadowed shell script running in a sandboxed workspace
|
|
22
|
+
# (see fs.compile_cli_config and yama.tools.bash._cli_shim) instead of being
|
|
23
|
+
# interpreted by a hand-rolled bash tokenizer/parser here.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_cli_config(
|
|
27
|
+
cli: Any,
|
|
28
|
+
*,
|
|
29
|
+
base_dir: Path | None = None,
|
|
30
|
+
root: Path | None = None,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
if cli is None:
|
|
33
|
+
cli = {}
|
|
34
|
+
if not isinstance(cli, dict):
|
|
35
|
+
raise MockConfigurationError("mocks.cli must be an object")
|
|
36
|
+
passthrough = cli.get("passthrough", False)
|
|
37
|
+
if not isinstance(passthrough, bool):
|
|
38
|
+
raise MockConfigurationError("mocks.cli.passthrough must be a boolean")
|
|
39
|
+
timeout = cli.get("passthrough_timeout_seconds", DEFAULT_PASSTHROUGH_TIMEOUT_SECONDS)
|
|
40
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
41
|
+
raise MockConfigurationError(
|
|
42
|
+
"mocks.cli.passthrough_timeout_seconds must be a positive number"
|
|
43
|
+
)
|
|
44
|
+
normalized: dict[str, Any] = {
|
|
45
|
+
"passthrough": passthrough,
|
|
46
|
+
"passthrough_timeout_seconds": float(timeout),
|
|
47
|
+
}
|
|
48
|
+
for name, value in cli.items():
|
|
49
|
+
if name in _RESERVED_CLI_FIELDS:
|
|
50
|
+
continue
|
|
51
|
+
_validate_command_name(name, "mocks.cli")
|
|
52
|
+
normalized[name] = _normalize_node(
|
|
53
|
+
value,
|
|
54
|
+
label=f"mocks.cli.{name}",
|
|
55
|
+
base_dir=base_dir,
|
|
56
|
+
root=root,
|
|
57
|
+
file_name=name,
|
|
58
|
+
)
|
|
59
|
+
return normalized
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate_command_name(name: Any, label: str) -> None:
|
|
63
|
+
if not isinstance(name, str) or not name.strip() or " " in name:
|
|
64
|
+
raise MockConfigurationError(
|
|
65
|
+
f"{label} command names must be single words: {name!r}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _normalize_node(
|
|
70
|
+
value: Any,
|
|
71
|
+
*,
|
|
72
|
+
label: str,
|
|
73
|
+
base_dir: Path | None,
|
|
74
|
+
root: Path | None,
|
|
75
|
+
file_name: str | None = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
return {
|
|
79
|
+
"rules": [],
|
|
80
|
+
"commands": {},
|
|
81
|
+
"default": _normalize_rule(label, {"stdout": value}),
|
|
82
|
+
}
|
|
83
|
+
if isinstance(value, list) and value:
|
|
84
|
+
return {
|
|
85
|
+
"rules": [_normalize_rule(label, rule) for rule in value],
|
|
86
|
+
"commands": {},
|
|
87
|
+
"default": None,
|
|
88
|
+
}
|
|
89
|
+
if not isinstance(value, dict) or not value:
|
|
90
|
+
raise MockConfigurationError(
|
|
91
|
+
f"{label} must be a string, a non-empty object or a non-empty list"
|
|
92
|
+
)
|
|
93
|
+
keys = set(value)
|
|
94
|
+
if "file" in keys:
|
|
95
|
+
if file_name is None:
|
|
96
|
+
raise MockConfigurationError(
|
|
97
|
+
f"{label} cannot use nested file references"
|
|
98
|
+
)
|
|
99
|
+
if keys != {"file"}:
|
|
100
|
+
raise MockConfigurationError(
|
|
101
|
+
f"{label} file entries cannot contain other fields"
|
|
102
|
+
)
|
|
103
|
+
return _normalize_spec_file(
|
|
104
|
+
value["file"],
|
|
105
|
+
label=label,
|
|
106
|
+
base_dir=base_dir,
|
|
107
|
+
root=root,
|
|
108
|
+
expected_name=file_name,
|
|
109
|
+
)
|
|
110
|
+
if keys <= _RULE_FIELDS:
|
|
111
|
+
return {
|
|
112
|
+
"rules": [_normalize_rule(label, value)],
|
|
113
|
+
"commands": {},
|
|
114
|
+
"default": None,
|
|
115
|
+
}
|
|
116
|
+
if not keys <= _NODE_FIELDS:
|
|
117
|
+
unknown = keys - (_NODE_FIELDS | _RULE_FIELDS)
|
|
118
|
+
if unknown:
|
|
119
|
+
raise MockConfigurationError(
|
|
120
|
+
f"unknown {label} fields: {sorted(unknown)}"
|
|
121
|
+
)
|
|
122
|
+
raise MockConfigurationError(
|
|
123
|
+
f"{label} cannot mix rule fields {sorted(keys & _RULE_FIELDS)} "
|
|
124
|
+
f"with {sorted(keys & _NODE_FIELDS)}"
|
|
125
|
+
)
|
|
126
|
+
return _normalize_node_fields(value, label=label, base_dir=base_dir, root=root)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _normalize_node_fields(
|
|
130
|
+
value: dict[str, Any],
|
|
131
|
+
*,
|
|
132
|
+
label: str,
|
|
133
|
+
base_dir: Path | None,
|
|
134
|
+
root: Path | None,
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
description = value.get("description")
|
|
137
|
+
if description is not None and not isinstance(description, str):
|
|
138
|
+
raise MockConfigurationError(f"{label} description must be a string")
|
|
139
|
+
rules_value = value.get("rules") or []
|
|
140
|
+
if not isinstance(rules_value, list):
|
|
141
|
+
raise MockConfigurationError(f"{label} rules must be a list")
|
|
142
|
+
rules = [_normalize_rule(label, rule) for rule in rules_value]
|
|
143
|
+
commands_value = value.get("commands") or {}
|
|
144
|
+
if not isinstance(commands_value, dict):
|
|
145
|
+
raise MockConfigurationError(f"{label} commands must be an object")
|
|
146
|
+
commands: dict[str, dict[str, Any]] = {}
|
|
147
|
+
for sub, sub_value in commands_value.items():
|
|
148
|
+
_validate_command_name(sub, f"{label}.commands")
|
|
149
|
+
commands[sub] = _normalize_node(
|
|
150
|
+
sub_value,
|
|
151
|
+
label=f"{label}.commands.{sub}",
|
|
152
|
+
base_dir=base_dir,
|
|
153
|
+
root=root,
|
|
154
|
+
)
|
|
155
|
+
default_value = value.get("default")
|
|
156
|
+
default = None
|
|
157
|
+
if default_value is not None:
|
|
158
|
+
if isinstance(default_value, str):
|
|
159
|
+
default_value = {"stdout": default_value}
|
|
160
|
+
default = _normalize_rule(f"{label}.default", default_value)
|
|
161
|
+
if default["match"]:
|
|
162
|
+
raise MockConfigurationError(
|
|
163
|
+
f"{label}.default cannot use match; add a rule instead"
|
|
164
|
+
)
|
|
165
|
+
if not rules and not commands and default is None:
|
|
166
|
+
raise MockConfigurationError(
|
|
167
|
+
f"{label} must declare rules, commands or default"
|
|
168
|
+
)
|
|
169
|
+
return {"rules": rules, "commands": commands, "default": default}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _normalize_spec_file(
|
|
173
|
+
reference: Any,
|
|
174
|
+
*,
|
|
175
|
+
label: str,
|
|
176
|
+
base_dir: Path | None,
|
|
177
|
+
root: Path | None,
|
|
178
|
+
expected_name: str,
|
|
179
|
+
) -> dict[str, Any]:
|
|
180
|
+
if not isinstance(reference, str) or not reference.strip():
|
|
181
|
+
raise MockConfigurationError(f"{label} file must be a relative path")
|
|
182
|
+
if base_dir is None:
|
|
183
|
+
raise MockConfigurationError(
|
|
184
|
+
f"{label} file references are only supported in mocks.cli"
|
|
185
|
+
)
|
|
186
|
+
path = (Path(base_dir) / reference).resolve()
|
|
187
|
+
boundary = Path(root or base_dir).resolve()
|
|
188
|
+
if not path.is_relative_to(boundary):
|
|
189
|
+
raise MockConfigurationError(
|
|
190
|
+
f"cli mock file must be inside the plugin: {path}"
|
|
191
|
+
)
|
|
192
|
+
if path.suffix not in {".yaml", ".yml"}:
|
|
193
|
+
raise MockConfigurationError(f"cli mock file must be YAML: {path}")
|
|
194
|
+
if not path.is_file():
|
|
195
|
+
raise MockConfigurationError(f"cli mock file does not exist: {path}")
|
|
196
|
+
try:
|
|
197
|
+
spec = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
198
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
199
|
+
raise MockConfigurationError(
|
|
200
|
+
f"cannot load cli mock file {path}: {exc}"
|
|
201
|
+
) from exc
|
|
202
|
+
if not isinstance(spec, dict):
|
|
203
|
+
raise MockConfigurationError(f"cli mock file root must be an object: {path}")
|
|
204
|
+
unknown = set(spec) - _SPEC_FIELDS
|
|
205
|
+
if unknown:
|
|
206
|
+
raise MockConfigurationError(
|
|
207
|
+
f"unknown cli mock file fields in {path.name}: {sorted(unknown)}"
|
|
208
|
+
)
|
|
209
|
+
name = spec.get("name")
|
|
210
|
+
if name != expected_name:
|
|
211
|
+
raise MockConfigurationError(
|
|
212
|
+
f"cli mock name mismatch: {label} expects {expected_name!r}, "
|
|
213
|
+
f"{path.name} has {name!r}"
|
|
214
|
+
)
|
|
215
|
+
node = {key: item for key, item in spec.items() if key in _NODE_FIELDS}
|
|
216
|
+
return _normalize_node_fields(
|
|
217
|
+
node,
|
|
218
|
+
label=f"{label} ({path.name})",
|
|
219
|
+
base_dir=base_dir,
|
|
220
|
+
root=root,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _normalize_rule(label: str, rule: Any) -> dict[str, Any]:
|
|
225
|
+
if isinstance(rule, str):
|
|
226
|
+
rule = {"stdout": rule}
|
|
227
|
+
if not isinstance(rule, dict):
|
|
228
|
+
raise MockConfigurationError(f"{label} rules must be strings or objects")
|
|
229
|
+
unknown = set(rule) - _RULE_FIELDS
|
|
230
|
+
if unknown:
|
|
231
|
+
raise MockConfigurationError(
|
|
232
|
+
f"unknown {label} rule fields: {sorted(unknown)}"
|
|
233
|
+
)
|
|
234
|
+
match = rule.get("match", {})
|
|
235
|
+
if not isinstance(match, dict):
|
|
236
|
+
raise MockConfigurationError(f"{label} match must be an object")
|
|
237
|
+
unknown = set(match) - _MATCH_FIELDS
|
|
238
|
+
if unknown:
|
|
239
|
+
raise MockConfigurationError(
|
|
240
|
+
f"{label} match only supports argv and argline: {sorted(unknown)}"
|
|
241
|
+
)
|
|
242
|
+
passthrough = rule.get("passthrough", False)
|
|
243
|
+
if not isinstance(passthrough, bool):
|
|
244
|
+
raise MockConfigurationError(f"{label} passthrough must be a boolean")
|
|
245
|
+
stdout = rule.get("stdout", "")
|
|
246
|
+
stderr = rule.get("stderr", "")
|
|
247
|
+
exit_code = rule.get("exit_code", 0)
|
|
248
|
+
non_default_outputs = [
|
|
249
|
+
key
|
|
250
|
+
for key, value in (("stdout", stdout), ("stderr", stderr), ("exit_code", exit_code))
|
|
251
|
+
if value not in ("", 0)
|
|
252
|
+
]
|
|
253
|
+
if passthrough and non_default_outputs:
|
|
254
|
+
raise MockConfigurationError(
|
|
255
|
+
f"{label} cannot combine passthrough with {sorted(non_default_outputs)}"
|
|
256
|
+
)
|
|
257
|
+
if not isinstance(stdout, str) or not isinstance(stderr, str):
|
|
258
|
+
raise MockConfigurationError(f"{label} stdout and stderr must be strings")
|
|
259
|
+
if isinstance(exit_code, bool) or not isinstance(exit_code, int):
|
|
260
|
+
raise MockConfigurationError(f"{label} exit_code must be an integer")
|
|
261
|
+
return {
|
|
262
|
+
"match": match,
|
|
263
|
+
"passthrough": passthrough,
|
|
264
|
+
"stdout": stdout,
|
|
265
|
+
"stderr": stderr,
|
|
266
|
+
"exit_code": exit_code,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class BashTool(Tool):
|
|
271
|
+
async def run(
|
|
272
|
+
self,
|
|
273
|
+
arguments: Mapping[str, Any],
|
|
274
|
+
context: ToolMockContext | None,
|
|
275
|
+
config: Mapping[str, Any],
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
command = arguments.get("command")
|
|
278
|
+
if not isinstance(command, str) or not command.strip():
|
|
279
|
+
raise ToolMockError("INVALID_COMMAND", "command must be a non-empty string")
|
|
280
|
+
if "fs" in config:
|
|
281
|
+
return await run_bash_fs(arguments, context, config["fs"])
|
|
282
|
+
return await run_bash_cli(arguments, context, config.get("cli", {}))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
run_bash = BashTool().run
|