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.
- avrae_ls/__init__.py +3 -0
- avrae_ls/__main__.py +272 -0
- avrae_ls/alias_preview.py +371 -0
- avrae_ls/alias_tests.py +351 -0
- avrae_ls/api.py +2015 -0
- avrae_ls/argparser.py +430 -0
- avrae_ls/argument_parsing.py +67 -0
- avrae_ls/code_actions.py +282 -0
- avrae_ls/codes.py +3 -0
- avrae_ls/completions.py +1695 -0
- avrae_ls/config.py +480 -0
- avrae_ls/context.py +337 -0
- avrae_ls/cvars.py +115 -0
- avrae_ls/diagnostics.py +826 -0
- avrae_ls/dice.py +33 -0
- avrae_ls/parser.py +68 -0
- avrae_ls/runtime.py +750 -0
- avrae_ls/server.py +447 -0
- avrae_ls/signature_help.py +248 -0
- avrae_ls/symbols.py +274 -0
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/METADATA +1 -1
- avrae_ls-0.6.2.dist-info/RECORD +34 -0
- draconic/__init__.py +4 -0
- draconic/exceptions.py +157 -0
- draconic/helpers.py +236 -0
- draconic/interpreter.py +1091 -0
- draconic/string.py +100 -0
- draconic/types.py +364 -0
- draconic/utils.py +78 -0
- draconic/versions.py +4 -0
- avrae_ls-0.6.0.dist-info/RECORD +0 -6
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/WHEEL +0 -0
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/entry_points.txt +0 -0
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/licenses/LICENSE +0 -0
avrae_ls/alias_tests.py
ADDED
|
@@ -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
|