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.
@@ -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