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