avrae-ls 0.6.0__py3-none-any.whl → 0.6.1__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,311 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shlex
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Iterable, Sequence
8
+
9
+ import yaml
10
+
11
+ from .alias_preview import render_alias_command, simulate_command
12
+ from .context import ContextBuilder
13
+ from .runtime import MockExecutor
14
+ from .config import VarSources
15
+
16
+
17
+ class AliasTestError(Exception):
18
+ """Raised when an alias test cannot be parsed or executed."""
19
+
20
+
21
+ @dataclass
22
+ class AliasTestCase:
23
+ path: Path
24
+ alias_path: Path
25
+ alias_name: str
26
+ name: str | None
27
+ args: list[str]
28
+ expected_raw: str
29
+ expected: Any
30
+ var_overrides: dict[str, Any] | None = None
31
+ character_overrides: dict[str, Any] | None = None
32
+
33
+
34
+ @dataclass
35
+ class AliasTestResult:
36
+ case: AliasTestCase
37
+ passed: bool
38
+ actual: Any
39
+ stdout: str
40
+ embed: dict[str, Any] | None = None
41
+ error: str | None = None
42
+ details: str | None = None
43
+
44
+
45
+ def discover_test_files(
46
+ target: Path, *, recursive: bool = True, patterns: Sequence[str] = ("*.alias-test", "*.aliastest")
47
+ ) -> list[Path]:
48
+ if target.is_file():
49
+ return [target]
50
+ files: set[Path] = set()
51
+ for pattern in patterns:
52
+ globber = target.rglob if recursive else target.glob
53
+ files.update(globber(pattern))
54
+ return sorted(files)
55
+
56
+
57
+ def parse_alias_tests(path: Path) -> list[AliasTestCase]:
58
+ try:
59
+ text = path.read_text()
60
+ except OSError as exc: # pragma: no cover - filesystem edge
61
+ raise AliasTestError(f"Failed to read {path}: {exc}") from exc
62
+
63
+ lines = text.splitlines()
64
+ idx = 0
65
+ cases: list[AliasTestCase] = []
66
+ while idx < len(lines):
67
+ while idx < len(lines) and not lines[idx].strip():
68
+ idx += 1
69
+ if idx >= len(lines):
70
+ break
71
+ command_lines: list[str] = []
72
+ while idx < len(lines) and lines[idx].strip() != "---":
73
+ command_lines.append(lines[idx])
74
+ idx += 1
75
+ if not command_lines:
76
+ raise AliasTestError(f"{path} has no command to execute before '---'")
77
+ if idx >= len(lines):
78
+ raise AliasTestError(f"{path} is missing a '---' separator")
79
+ idx += 1 # consume first ---
80
+
81
+ expected_lines: list[str] = []
82
+ while idx < len(lines) and lines[idx].strip() != "---" and not lines[idx].lstrip().startswith("!"):
83
+ expected_lines.append(lines[idx])
84
+ idx += 1
85
+
86
+ meta_lines: list[str] = []
87
+ if idx < len(lines) and lines[idx].strip() == "---":
88
+ idx += 1 # consume second ---
89
+ while idx < len(lines) and not lines[idx].lstrip().startswith("!"):
90
+ meta_lines.append(lines[idx])
91
+ idx += 1
92
+
93
+ command_part = "\n".join(command_lines).strip()
94
+ expected_raw = "\n".join(expected_lines)
95
+ meta_raw = "\n".join(meta_lines)
96
+
97
+ tokens = _split_command(command_part, path)
98
+ alias_name = tokens[0].lstrip("!")
99
+ args = tokens[1:]
100
+ alias_path = _resolve_alias_path(path, alias_name)
101
+ expected = yaml.safe_load(expected_raw) if expected_raw.strip() else ""
102
+ meta = yaml.safe_load(meta_raw) if meta_raw.strip() else None
103
+ if meta is not None and not isinstance(meta, dict):
104
+ raise AliasTestError(f"{path} metadata after second '---' must be a mapping")
105
+ name = meta.get("name") if isinstance(meta, dict) else None
106
+ var_overrides = meta.get("vars") if isinstance(meta, dict) else None
107
+ character_overrides = meta.get("character") if isinstance(meta, dict) else None
108
+
109
+ cases.append(
110
+ AliasTestCase(
111
+ path=path,
112
+ alias_path=alias_path,
113
+ alias_name=alias_name,
114
+ name=name,
115
+ args=args,
116
+ expected_raw=expected_raw,
117
+ expected=expected,
118
+ var_overrides=var_overrides if isinstance(var_overrides, dict) else None,
119
+ character_overrides=character_overrides if isinstance(character_overrides, dict) else None,
120
+ )
121
+ )
122
+ return cases
123
+
124
+
125
+ async def run_alias_tests(
126
+ cases: Iterable[AliasTestCase], builder: ContextBuilder, executor: MockExecutor
127
+ ) -> list[AliasTestResult]:
128
+ results: list[AliasTestResult] = []
129
+ for case in cases:
130
+ results.append(await run_alias_test(case, builder, executor))
131
+ return results
132
+
133
+
134
+ async def run_alias_test(case: AliasTestCase, builder: ContextBuilder, executor: MockExecutor) -> AliasTestResult:
135
+ try:
136
+ alias_source = case.alias_path.read_text()
137
+ except OSError as exc:
138
+ return AliasTestResult(
139
+ case=case,
140
+ passed=False,
141
+ actual=None,
142
+ stdout="",
143
+ error=f"Failed to read alias file {case.alias_path}: {exc}",
144
+ )
145
+
146
+ ctx_data = builder.build()
147
+ if case.var_overrides:
148
+ ctx_data.vars = ctx_data.vars.merge(VarSources.from_data(case.var_overrides))
149
+ if case.character_overrides:
150
+ ctx_data.character = _deep_merge_dicts(ctx_data.character, case.character_overrides)
151
+
152
+ rendered = await render_alias_command(alias_source, executor, ctx_data, builder.gvar_resolver, args=case.args)
153
+ if rendered.error:
154
+ return AliasTestResult(
155
+ case=case,
156
+ passed=False,
157
+ actual=None,
158
+ stdout=rendered.stdout,
159
+ error=str(rendered.error),
160
+ )
161
+
162
+ preview = simulate_command(rendered.command)
163
+ if preview.validation_error:
164
+ return AliasTestResult(
165
+ case=case,
166
+ passed=False,
167
+ actual=None,
168
+ stdout=rendered.stdout,
169
+ error=preview.validation_error,
170
+ )
171
+
172
+ actual = preview.preview if preview.preview is not None else rendered.last_value
173
+ embed_dict = preview.embed.to_dict() if preview.embed else None
174
+
175
+ if embed_dict is not None and isinstance(case.expected, dict):
176
+ passed = _dict_matches(embed_dict, case.expected)
177
+ details = None if passed else "Embed preview did not match expected dictionary"
178
+ actual_display = embed_dict
179
+ else:
180
+ passed = _scalar_matches(case.expected, actual)
181
+ details = None if passed else "Result did not match expected output"
182
+ actual_display = actual
183
+
184
+ return AliasTestResult(
185
+ case=case,
186
+ passed=passed,
187
+ actual=actual_display,
188
+ stdout=rendered.stdout,
189
+ embed=embed_dict,
190
+ details=details,
191
+ )
192
+
193
+
194
+ def _scalar_matches(expected: Any, actual: Any) -> bool:
195
+ if isinstance(expected, str):
196
+ if expected == "":
197
+ return True
198
+ pattern = _compile_expected_pattern(expected)
199
+ if pattern:
200
+ return pattern.search("" if actual is None else str(actual)) is not None
201
+ lhs = expected.strip()
202
+ rhs = "" if actual is None else str(actual).strip()
203
+ return lhs == rhs
204
+ return expected == actual
205
+
206
+
207
+ def _dict_matches(actual: dict[str, Any], expected: dict[str, Any]) -> bool:
208
+ for key, expected_val in expected.items():
209
+ if key not in actual:
210
+ return False
211
+ actual_val = actual[key]
212
+ if not _value_matches(expected_val, actual_val):
213
+ return False
214
+ return True
215
+
216
+
217
+ def _value_matches(expected: Any, actual: Any) -> bool:
218
+ if isinstance(expected, dict):
219
+ return isinstance(actual, dict) and _dict_matches(actual, expected)
220
+ if isinstance(expected, list):
221
+ if not isinstance(actual, list) or len(actual) < len(expected):
222
+ return False
223
+ return all(_value_matches(e, actual[idx]) for idx, e in enumerate(expected))
224
+ return _scalar_matches(expected, actual)
225
+
226
+
227
+ def _split_command(command: str, path: Path) -> list[str]:
228
+ try:
229
+ tokens = shlex.split(command, posix=True)
230
+ except ValueError as exc:
231
+ raise AliasTestError(f"{path} has an invalid command line: {exc}") from exc
232
+ if not tokens:
233
+ raise AliasTestError(f"{path} has an empty command")
234
+ return tokens
235
+
236
+
237
+ def _resolve_alias_path(path: Path, alias_name: str) -> Path:
238
+ base_dir = path.parent
239
+ candidates = _alias_candidates(path, alias_name)
240
+ for candidate in candidates:
241
+ target = base_dir / candidate
242
+ if target.exists():
243
+ return target
244
+
245
+ for child in base_dir.iterdir():
246
+ if child == path or not child.is_file():
247
+ continue
248
+ if child.stem in {alias_name, path.stem.removeprefix("test-")}:
249
+ return child
250
+
251
+ raise AliasTestError(
252
+ f"Could not find alias file for '{alias_name}'. Checked: {', '.join(str(base_dir / c) for c in candidates)}"
253
+ )
254
+
255
+ def _deep_merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
256
+ merged = dict(base or {})
257
+ for key, val in (override or {}).items():
258
+ if key in merged and isinstance(merged[key], dict) and isinstance(val, dict):
259
+ merged[key] = _deep_merge_dicts(merged[key], val)
260
+ else:
261
+ merged[key] = val
262
+ return merged
263
+
264
+
265
+ def _alias_candidates(path: Path, alias_name: str) -> Sequence[str]:
266
+ base = path.stem.removeprefix("test-") or alias_name
267
+ names = [alias_name]
268
+ if base not in names:
269
+ names.append(base)
270
+ suffixes = ["", ".alias", ".txt"]
271
+ return [f"{name}{suffix}" for name in names for suffix in suffixes]
272
+
273
+
274
+ def _compile_expected_pattern(text: str) -> re.Pattern[str] | None:
275
+ """
276
+ Interpret strings with /.../ segments (or re:prefix) as regex.
277
+
278
+ - `/foo/` or `re:foo` => regex `foo`
279
+ - Mixed literals + regex, e.g. `Hello /world.*/` => literal `Hello ` + regex `world.*`
280
+ """
281
+ if not text:
282
+ return None
283
+ if text.startswith("re:"):
284
+ try:
285
+ return re.compile(text[3:])
286
+ except re.error:
287
+ return None
288
+
289
+ parts = re.split(r"(?<!\\)/(.*?)(?<!\\)/", text)
290
+ if len(parts) == 1:
291
+ return None
292
+
293
+ if len(parts) == 3 and parts[0] == "" and parts[2] == "":
294
+ pattern = parts[1].replace("\\/", "/")
295
+ try:
296
+ return re.compile(pattern)
297
+ except re.error:
298
+ return None
299
+
300
+ regex_parts: list[str] = []
301
+ for idx, part in enumerate(parts):
302
+ unescaped = part.replace("\\/", "/")
303
+ if idx % 2 == 0:
304
+ regex_parts.append(re.escape(unescaped))
305
+ else:
306
+ regex_parts.append(unescaped)
307
+ pattern = "^" + "".join(regex_parts) + "$"
308
+ try:
309
+ return re.compile(pattern)
310
+ except re.error:
311
+ return None