avrae-ls 0.4.1__py3-none-any.whl → 0.5.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.
avrae_ls/runtime.py DELETED
@@ -1,661 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import ast
4
- import io
5
- import json
6
- import logging
7
- import math
8
- import random
9
- import re
10
- import time
11
- from types import SimpleNamespace
12
- try: # optional dependency
13
- import yaml
14
- except ImportError: # pragma: no cover - fallback when PyYAML is absent
15
- yaml = None # type: ignore
16
- from dataclasses import dataclass
17
- from typing import Any, Dict, Set, Callable
18
-
19
- import d20
20
- import draconic
21
- import httpx
22
- from draconic.interpreter import _Break, _Continue, _Return
23
-
24
- from .context import ContextData, GVarResolver
25
- from .config import AvraeServiceConfig, VarSources
26
- from .api import AliasContextAPI, CharacterAPI, SimpleCombat, SimpleRollResult
27
- from . import argparser as avrae_argparser
28
- # Minimal stand-in for Avrae's AliasException
29
- class AliasException(Exception):
30
- def __init__(self, msg, pm_user):
31
- super().__init__(msg)
32
- self.pm_user = pm_user
33
-
34
-
35
- try:
36
- from avrae.aliasing.errors import FunctionRequiresCharacter # type: ignore
37
- except Exception: # pragma: no cover - fallback when avrae is unavailable
38
- class FunctionRequiresCharacter(Exception):
39
- def __init__(self, msg: str | None = None):
40
- super().__init__(msg or "This alias requires an active character.")
41
-
42
- log = logging.getLogger(__name__)
43
-
44
-
45
- class MockNamespace:
46
- """A minimal attribute-friendly namespace used for ctx/combat/character."""
47
-
48
- def __init__(self, data: Dict[str, Any] | None = None):
49
- self._data = data or {}
50
-
51
- def __getattr__(self, item: str) -> Any:
52
- return self._data.get(item)
53
-
54
- def __getitem__(self, item: str) -> Any:
55
- return self._data.get(item)
56
-
57
- def __repr__(self) -> str: # pragma: no cover - debugging helper
58
- return f"MockNamespace({self._data})"
59
-
60
- def to_dict(self) -> Dict[str, Any]:
61
- return dict(self._data)
62
-
63
-
64
- @dataclass
65
- class ExecutionResult:
66
- stdout: str
67
- value: Any = None
68
- error: BaseException | None = None
69
-
70
-
71
- def _roll_dice(dice: str) -> int:
72
- roller = d20.Roller()
73
- try:
74
- result = roller.roll(str(dice))
75
- except d20.RollError:
76
- return 0
77
- return result.total
78
-
79
-
80
- def _vroll_dice(dice: str, multiply: int = 1, add: int = 0) -> SimpleRollResult | None:
81
- roller = d20.Roller()
82
- try:
83
- dice_ast = roller.parse(str(dice))
84
- except d20.RollError:
85
- return None
86
-
87
- if multiply != 1 or add != 0:
88
- def _scale(node):
89
- if isinstance(node, d20.ast.Dice):
90
- node.num = (node.num * multiply) + add
91
- return node
92
-
93
- dice_ast = d20.utils.tree_map(_scale, dice_ast)
94
-
95
- try:
96
- rolled = roller.roll(dice_ast)
97
- except d20.RollError:
98
- return None
99
- return SimpleRollResult(rolled)
100
-
101
-
102
- @dataclass
103
- class _CoinsArgs:
104
- pp: int = 0
105
- gp: int = 0
106
- ep: int = 0
107
- sp: int = 0
108
- cp: int = 0
109
- explicit: bool = False
110
-
111
- @property
112
- def total(self) -> float:
113
- return (self.pp * 10) + self.gp + (self.ep * 0.5) + (self.sp * 0.1) + (self.cp * 0.01)
114
-
115
-
116
- def _parse_coin_args(args: str) -> _CoinsArgs:
117
- cleaned = str(args).replace(",", "")
118
- try:
119
- return _parse_coin_args_float(float(cleaned))
120
- except ValueError:
121
- return _parse_coin_args_re(cleaned)
122
-
123
-
124
- def _parse_coin_args_float(coins: float) -> _CoinsArgs:
125
- total_copper = int(round(coins * 100, 1))
126
- if coins < 0:
127
- return _CoinsArgs(cp=total_copper)
128
- return _CoinsArgs(
129
- gp=total_copper // 100,
130
- sp=(total_copper % 100) // 10,
131
- cp=total_copper % 10,
132
- )
133
-
134
-
135
- def _parse_coin_args_re(args: str) -> _CoinsArgs:
136
- is_valid = re.fullmatch(r"(([+-]?\d+)\s*([pgesc]p)\s*)+", args, re.IGNORECASE)
137
- if not is_valid:
138
- raise avrae_argparser.InvalidArgument(
139
- "Coins must be a number or a currency string, e.g. `+101.2` or `10cp +101gp -2sp`."
140
- )
141
-
142
- out = _CoinsArgs(explicit=True)
143
- for coin_match in re.finditer(r"(?P<amount>[+-]?\d+)\s*(?P<currency>[pgesc]p)", args, re.IGNORECASE):
144
- amount = int(coin_match["amount"])
145
- currency = coin_match["currency"].lower()
146
-
147
- if currency == "pp":
148
- out.pp += amount
149
- elif currency == "gp":
150
- out.gp += amount
151
- elif currency == "ep":
152
- out.ep += amount
153
- elif currency == "sp":
154
- out.sp += amount
155
- else:
156
- out.cp += amount
157
-
158
- return out
159
-
160
-
161
- def _parse_coins(args: str, include_total: bool = True):
162
- try:
163
- from avrae.aliasing.api.functions import parse_coins as avrae_parse_coins
164
- except Exception:
165
- avrae_parse_coins = None
166
-
167
- if avrae_parse_coins:
168
- try:
169
- return avrae_parse_coins(str(args), include_total=include_total)
170
- except Exception:
171
- pass
172
-
173
- coin_args = _parse_coin_args(str(args))
174
- parsed = {
175
- "pp": coin_args.pp,
176
- "gp": coin_args.gp,
177
- "ep": coin_args.ep,
178
- "sp": coin_args.sp,
179
- "cp": coin_args.cp,
180
- }
181
- if include_total:
182
- parsed["total"] = coin_args.total
183
- return parsed
184
-
185
-
186
- def _default_builtins() -> Dict[str, Any]:
187
- return {
188
- "len": len,
189
- "min": min,
190
- "max": max,
191
- "sum": sum,
192
- "any": any,
193
- "all": all,
194
- "abs": abs,
195
- "range": range,
196
- "enumerate": enumerate,
197
- "int": int,
198
- "float": float,
199
- "str": str,
200
- "round": round,
201
- "ceil": math.ceil,
202
- "floor": math.floor,
203
- "sqrt": math.sqrt,
204
- "time": time.time,
205
- "roll": _roll_dice,
206
- "vroll": _vroll_dice,
207
- "rand": random.random,
208
- "randint": random.randrange,
209
- "randchoice": random.choice,
210
- "randchoices": random.choices,
211
- "typeof": lambda inst: type(inst).__name__,
212
- "parse_coins": _parse_coins,
213
- "load_json": lambda s: json.loads(str(s)),
214
- "dump_json": lambda obj: json.dumps(obj),
215
- "load_yaml": lambda s: yaml.safe_load(str(s)) if yaml else None,
216
- "dump_yaml": (
217
- (lambda obj, indent=2: yaml.safe_dump(obj, indent=indent, sort_keys=False)) if yaml else (lambda obj, indent=2: str(obj))
218
- ),
219
- }
220
-
221
-
222
- class MockExecutor:
223
- def __init__(self, service_config: AvraeServiceConfig | None = None):
224
- self._base_builtins = _default_builtins()
225
- self._service_config = service_config or AvraeServiceConfig()
226
-
227
- def available_names(self, ctx_data: ContextData) -> Set[str]:
228
- builtin_names = set(self._base_builtins.keys())
229
- runtime_names = {
230
- "ctx",
231
- "combat",
232
- "character",
233
- "roll",
234
- "vroll",
235
- "rand",
236
- "randint",
237
- "randchoice",
238
- "randchoices",
239
- "typeof",
240
- "parse_coins",
241
- "load_json",
242
- "dump_json",
243
- "load_yaml",
244
- "dump_yaml",
245
- "get_gvar",
246
- "get_svar",
247
- "get_cvar",
248
- "get_uvar",
249
- "get_uvars",
250
- "set_uvar",
251
- "set_uvar_nx",
252
- "delete_uvar",
253
- "uvar_exists",
254
- "print",
255
- "argparse",
256
- "err",
257
- "exists",
258
- "get",
259
- "using",
260
- "signature",
261
- "verify_signature",
262
- }
263
- variable_names = set(ctx_data.vars.to_initial_names().keys())
264
- return builtin_names | runtime_names | variable_names
265
-
266
- async def run(
267
- self,
268
- code: str,
269
- ctx_data: ContextData,
270
- gvar_resolver: GVarResolver | None = None,
271
- ) -> ExecutionResult:
272
- buffer = io.StringIO()
273
- resolver = gvar_resolver
274
- interpreter_ref: dict[str, draconic.DraconicInterpreter | None] = {"interpreter": None}
275
- runtime_character: CharacterAPI | None = None
276
-
277
- def _character_provider() -> CharacterAPI:
278
- nonlocal runtime_character
279
- interp = interpreter_ref["interpreter"]
280
- if not ctx_data.character:
281
- raise FunctionRequiresCharacter()
282
- if runtime_character is None and interp is not None:
283
- runtime_character = _RuntimeCharacter(ctx_data.character, ctx_data.vars, interp)
284
- if runtime_character is None:
285
- runtime_character = CharacterAPI(ctx_data.character)
286
- return runtime_character # type: ignore[return-value]
287
-
288
- import_cache: dict[str, SimpleNamespace] = {}
289
- import_stack: list[str] = []
290
- builtins = self._build_builtins(
291
- ctx_data,
292
- resolver,
293
- buffer,
294
- character_provider=_character_provider,
295
- interpreter_ref=interpreter_ref,
296
- import_cache=import_cache,
297
- import_stack=import_stack,
298
- )
299
- interpreter = draconic.DraconicInterpreter(
300
- builtins=builtins,
301
- initial_names=ctx_data.vars.to_initial_names(),
302
- )
303
- interpreter_ref["interpreter"] = interpreter
304
-
305
- value = None
306
- error: BaseException | None = None
307
- code_to_run = code
308
- try:
309
- parsed = interpreter.parse(code_to_run)
310
- except BaseException:
311
- wrapped, _ = _wrap_draconic(code_to_run)
312
- code_to_run = wrapped
313
- try:
314
- parsed = interpreter.parse(code_to_run)
315
- except BaseException as exc:
316
- error = exc
317
- log.debug("Mock execution error: %s", exc, exc_info=exc)
318
- return ExecutionResult(stdout=buffer.getvalue(), value=value, error=error)
319
-
320
- if resolver:
321
- await _ensure_literal_gvars(code_to_run, resolver)
322
-
323
- try:
324
- interpreter._preflight()
325
- value = self._exec_with_value(interpreter, parsed)
326
- except BaseException as exc: # draconic raises BaseException subclasses
327
- error = exc
328
- log.debug("Mock execution error: %s", exc, exc_info=exc)
329
- return ExecutionResult(stdout=buffer.getvalue(), value=value, error=error)
330
-
331
- def _build_builtins(
332
- self,
333
- ctx_data: ContextData,
334
- resolver: GVarResolver | None,
335
- buffer: io.StringIO,
336
- character_provider: Callable[[], CharacterAPI] | None = None,
337
- interpreter_ref: Dict[str, draconic.DraconicInterpreter | None] | None = None,
338
- import_cache: Dict[str, SimpleNamespace] | None = None,
339
- import_stack: list[str] | None = None,
340
- ) -> Dict[str, Any]:
341
- builtins = dict(self._base_builtins)
342
- var_store = ctx_data.vars
343
- interpreter_ref = interpreter_ref or {"interpreter": None}
344
- import_cache = import_cache or {}
345
- import_stack = import_stack or []
346
- service_cfg = self._service_config
347
- verify_cache_sig: str | None = None
348
- verify_cache_result: Dict[str, Any] | None = None
349
- verify_cache_error: ValueError | None = None
350
-
351
- def _print(*args, sep=" ", end="\n"):
352
- buffer.write(sep.join(map(str, args)) + end)
353
-
354
- def _get_gvar(address: str):
355
- if resolver is None:
356
- return None
357
- return resolver.get_local(address)
358
-
359
- def _get_svar(name: str, default=None):
360
- return var_store.svars.get(str(name), default)
361
-
362
- def _get_cvar(name: str, default=None):
363
- val = var_store.cvars.get(str(name), default)
364
- return str(val) if val is not None else default
365
-
366
- def _get_uvar(name: str, default=None):
367
- val = var_store.uvars.get(str(name), default)
368
- return str(val) if val is not None else default
369
-
370
- def _get_uvars():
371
- return {k: (str(v) if v is not None else v) for k, v in var_store.uvars.items()}
372
-
373
- def _set_uvar(name: str, value: Any):
374
- str_val = str(value) if value is not None else None
375
- var_store.uvars[str(name)] = str_val
376
- return str_val
377
-
378
- def _set_uvar_nx(name: str, value: Any):
379
- key = str(name)
380
- if key not in var_store.uvars:
381
- var_store.uvars[key] = str(value) if value is not None else None
382
- return var_store.uvars[key]
383
-
384
- def _delete_uvar(name: str):
385
- return var_store.uvars.pop(str(name), None)
386
-
387
- def _uvar_exists(name: str) -> bool:
388
- return str(name) in var_store.uvars
389
-
390
- def _resolve_name(key: str) -> tuple[bool, Any]:
391
- key = str(key)
392
- interp = interpreter_ref.get("interpreter")
393
- if interp is not None:
394
- names = getattr(interp, "_names", {})
395
- if key in names:
396
- return True, names[key]
397
-
398
- if key in var_store.cvars:
399
- return True, var_store.cvars[key]
400
-
401
- if key in var_store.uvars:
402
- return True, var_store.uvars[key]
403
-
404
- return False, None
405
-
406
- def _exists(name: str) -> bool:
407
- found, _ = _resolve_name(name)
408
- return found
409
-
410
- def _get(name: str, default=None):
411
- found, value = _resolve_name(name)
412
- return value if found else default
413
-
414
- def _using(**imports):
415
- interp = interpreter_ref.get("interpreter")
416
- if interp is None:
417
- return None
418
- user_ns = getattr(interp, "_names", {})
419
-
420
- def _load_module(addr: str) -> SimpleNamespace:
421
- if addr in import_cache:
422
- return import_cache[addr]
423
- if resolver is None:
424
- raise ModuleNotFoundError(f"No gvar named {addr!r}")
425
- mod_contents = resolver.get_local(addr)
426
- if mod_contents is None:
427
- raise ModuleNotFoundError(f"No gvar named {addr!r}")
428
-
429
- old_names = getattr(interp, "_names", {})
430
- depth_increased = False
431
- try:
432
- interp._names = {}
433
- interp._depth += 1
434
- depth_increased = True
435
- if interp._depth > interp._config.max_recursion_depth:
436
- raise RecursionError("Maximum recursion depth exceeded")
437
- interp.execute_module(str(mod_contents), module_name=addr)
438
- mod_ns = SimpleNamespace(**getattr(interp, "_names", {}))
439
- import_cache[addr] = mod_ns
440
- return mod_ns
441
- finally:
442
- if depth_increased:
443
- interp._depth -= 1
444
- interp._names = old_names
445
-
446
- for ns, addr in imports.items():
447
- addr_str = str(addr)
448
- if addr_str in import_stack:
449
- circle = " imports\n".join(import_stack)
450
- raise ImportError(f"Circular import detected!\n{circle} imports\n{addr_str}")
451
- import_stack.append(addr_str)
452
- try:
453
- mod_ns = _load_module(addr_str)
454
- finally:
455
- import_stack.pop()
456
- name = str(ns)
457
- if name in interp.builtins:
458
- raise ValueError(f"{name} is already builtin (no shadow assignments).")
459
- user_ns[name] = mod_ns
460
-
461
- interp._names = user_ns
462
- return None
463
-
464
- def _signature(data=0):
465
- try:
466
- data = int(data)
467
- except ValueError:
468
- raise TypeError(f"Data {data} could not be converted to integer.")
469
- return f"mock-signature:{int(data)}"
470
-
471
- def _verify_signature(sig):
472
- nonlocal verify_cache_sig, verify_cache_result, verify_cache_error
473
- sig_str = str(sig)
474
- if sig_str == verify_cache_sig:
475
- if verify_cache_error:
476
- raise verify_cache_error
477
- return verify_cache_result
478
-
479
- verify_cache_sig = sig_str
480
- verify_cache_error = None
481
- verify_cache_result = None
482
- timeout = float(service_cfg.verify_timeout if service_cfg else AvraeServiceConfig.verify_timeout)
483
- retries = int(service_cfg.verify_retries if service_cfg else AvraeServiceConfig.verify_retries)
484
- retries = max(0, retries)
485
-
486
- def _call_verify_api(signature: str) -> Dict[str, Any]:
487
- base_url = (service_cfg.base_url if service_cfg else AvraeServiceConfig.base_url).rstrip("/")
488
- url = f"{base_url}/bot/signature/verify"
489
- headers = {"Content-Type": "application/json"}
490
- if service_cfg and service_cfg.token:
491
- headers["Authorization"] = str(service_cfg.token)
492
- last_exc: Exception | None = None
493
- for attempt in range(retries + 1):
494
- try:
495
- resp = httpx.post(url, json={"signature": signature}, headers=headers, timeout=timeout)
496
- break
497
- except Exception as exc:
498
- last_exc = exc
499
- if attempt >= retries:
500
- raise ValueError(f"Failed to verify signature: {exc}") from exc
501
- continue
502
- else: # pragma: no cover - defensive
503
- raise ValueError(f"Failed to verify signature: {last_exc}") from last_exc
504
-
505
- try:
506
- payload = resp.json()
507
- except Exception as exc:
508
- raise ValueError("Failed to verify signature: invalid response body") from exc
509
-
510
- if resp.status_code != 200:
511
- message = None
512
- if isinstance(payload, dict):
513
- message = payload.get("error") or payload.get("message")
514
- detail = f"{message} (HTTP {resp.status_code})" if message else f"HTTP {resp.status_code}"
515
- raise ValueError(f"Failed to verify signature: {detail}")
516
-
517
- if not isinstance(payload, dict):
518
- raise ValueError("Failed to verify signature: invalid response")
519
- if payload.get("success") is not True:
520
- message = payload.get("error")
521
- raise ValueError(f"Failed to verify signature: {message or 'unsuccessful response'}")
522
-
523
- data = payload.get("data")
524
- if not isinstance(data, dict):
525
- raise ValueError("Failed to verify signature: malformed response")
526
- return data
527
-
528
- try:
529
- verify_cache_result = _call_verify_api(sig_str)
530
- except ValueError as exc:
531
- verify_cache_error = exc
532
- raise
533
- return verify_cache_result
534
-
535
- def _argparse(args, character=None, splitter=avrae_argparser.argsplit, parse_ephem=True):
536
- return avrae_argparser.argparse(args, character=character, splitter=splitter, parse_ephem=parse_ephem)
537
-
538
- def _err(reason, pm_user: bool = False):
539
- raise AliasException(str(reason), pm_user)
540
-
541
- ns_ctx = AliasContextAPI(ctx_data.ctx)
542
- ns_combat = SimpleCombat(ctx_data.combat) if ctx_data.combat else None
543
- if character_provider:
544
- character_fn = character_provider
545
- else:
546
- ns_character = CharacterAPI(ctx_data.character) if ctx_data.character else None
547
-
548
- def character_fn():
549
- if ns_character is None:
550
- raise FunctionRequiresCharacter()
551
- return ns_character
552
-
553
- builtins.update(
554
- print=_print,
555
- roll=_roll_dice,
556
- vroll=_vroll_dice,
557
- ctx=ns_ctx,
558
- combat=lambda: ns_combat,
559
- character=lambda: character_fn(),
560
- get_gvar=_get_gvar,
561
- get_svar=_get_svar,
562
- get_cvar=_get_cvar,
563
- get_uvar=_get_uvar,
564
- get_uvars=_get_uvars,
565
- set_uvar=_set_uvar,
566
- set_uvar_nx=_set_uvar_nx,
567
- delete_uvar=_delete_uvar,
568
- uvar_exists=_uvar_exists,
569
- argparse=_argparse,
570
- err=_err,
571
- exists=_exists,
572
- get=_get,
573
- using=_using,
574
- signature=_signature,
575
- verify_signature=_verify_signature,
576
- )
577
- return builtins
578
-
579
- def _exec_with_value(self, interpreter: draconic.DraconicInterpreter, body) -> Any:
580
- last_val = None
581
- for expression in body:
582
- retval = interpreter._eval(expression) # type: ignore[attr-defined]
583
- if isinstance(retval, (_Break, _Continue)):
584
- raise draconic.DraconicSyntaxError.from_node(retval.node, msg="Loop control outside loop", expr=interpreter._expr) # type: ignore[attr-defined]
585
- if isinstance(retval, _Return):
586
- return retval.value
587
- last_val = retval
588
- return last_val
589
-
590
-
591
- class _RuntimeCharacter(CharacterAPI):
592
- """Character wrapper that keeps mock runtime bindings in sync with cvar mutations."""
593
-
594
- def __init__(self, data: Dict[str, Any], var_store: VarSources, interpreter: draconic.DraconicInterpreter):
595
- super().__init__(data)
596
- self._var_store = var_store
597
- self._interpreter = interpreter
598
-
599
- def set_cvar(self, name: str, val: Any) -> Any:
600
- bound_val = super().set_cvar(name, val)
601
- key = str(name)
602
- self._var_store.cvars[key] = bound_val
603
- try:
604
- # Mirror Avrae behavior: new cvars are available as locals immediately.
605
- self._interpreter._names[key] = bound_val # type: ignore[attr-defined]
606
- except Exception:
607
- pass
608
- return bound_val
609
-
610
- def set_cvar_nx(self, name: str, val: Any) -> Any:
611
- key = str(name)
612
- if key in self._var_store.cvars:
613
- return self._var_store.cvars[key]
614
- return self.set_cvar(key, val)
615
-
616
- # delete_cvar intentionally does not unbind runtime names, matching Avrae's docs.
617
-
618
-
619
- def _wrap_draconic(code: str) -> tuple[str, int]:
620
- indented = "\n".join(f" {line}" for line in code.splitlines())
621
- wrapped = f"def __alias_main__():\n{indented}\n__alias_main__()"
622
- return wrapped, 1
623
-
624
-
625
- async def _ensure_literal_gvars(code: str, resolver: GVarResolver) -> None:
626
- for key in _literal_gvars(code):
627
- try:
628
- await resolver.ensure(key)
629
- except Exception as exc: # pragma: no cover - defensive
630
- log.debug("Failed to prefetch gvar %s: %s", key, exc)
631
-
632
-
633
- def _literal_gvars(code: str) -> Set[str]:
634
- try:
635
- tree = ast.parse(code)
636
- except SyntaxError:
637
- wrapped, _ = _wrap_draconic(code)
638
- try:
639
- tree = ast.parse(wrapped)
640
- except SyntaxError:
641
- return set()
642
-
643
- gvars: set[str] = set()
644
- for node in ast.walk(tree):
645
- if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
646
- if node.func.id == "get_gvar":
647
- if not node.args:
648
- continue
649
- arg = node.args[0]
650
- if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
651
- gvars.add(arg.value)
652
- elif isinstance(arg, ast.Str):
653
- gvars.add(arg.s)
654
- elif node.func.id == "using":
655
- for kw in node.keywords:
656
- val = kw.value
657
- if isinstance(val, ast.Constant) and isinstance(val.value, str):
658
- gvars.add(val.value)
659
- elif isinstance(val, ast.Str):
660
- gvars.add(val.s)
661
- return gvars