avrae-ls 0.3.1__py3-none-any.whl → 0.4.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/argparser.py CHANGED
@@ -2,7 +2,7 @@ import collections
2
2
  import itertools
3
3
  import re
4
4
  import string
5
- from typing import Iterator
5
+ from typing import ClassVar, Iterator
6
6
 
7
7
 
8
8
  class BadArgument(Exception):
@@ -169,6 +169,19 @@ def argparse(args, character=None, splitter=argsplit, parse_ephem=True) -> "Pars
169
169
 
170
170
 
171
171
  class ParsedArguments:
172
+ ATTRS: ClassVar[list[str]] = []
173
+ METHODS: ClassVar[list[str]] = [
174
+ "get",
175
+ "last",
176
+ "adv",
177
+ "join",
178
+ "ignore",
179
+ "update",
180
+ "update_nx",
181
+ "set_context",
182
+ "add_context",
183
+ ]
184
+
172
185
  def __init__(self, args: list[Argument]):
173
186
  self._parsed = collections.defaultdict(lambda: [])
174
187
  for arg in args:
@@ -299,7 +312,7 @@ class ParsedArguments:
299
312
  def __contains__(self, item):
300
313
  return item in self._parsed and self._parsed[item]
301
314
 
302
- def __len__(self):
315
+ def __len__(self) -> int:
303
316
  return len(self._parsed)
304
317
 
305
318
  def __setitem__(self, key, value):
@@ -317,7 +330,7 @@ class ParsedArguments:
317
330
  if arg in self._parsed:
318
331
  del self._parsed[arg]
319
332
 
320
- def __iter__(self):
333
+ def __iter__(self) -> Iterator[str]:
321
334
  return iter(self._parsed.keys())
322
335
 
323
336
  def __repr__(self):
avrae_ls/completions.py CHANGED
@@ -11,6 +11,7 @@ from typing import Any, ClassVar, Dict, Iterable, List, Optional
11
11
  from lsprotocol import types
12
12
 
13
13
  from .context import ContextData, GVarResolver
14
+ from .argparser import ParsedArguments
14
15
  from .runtime import _default_builtins
15
16
  from .api import (
16
17
  AliasAction,
@@ -147,6 +148,7 @@ TYPE_MAP: Dict[str, object] = {
147
148
  "list": _BuiltinList,
148
149
  "dict": _BuiltinDict,
149
150
  "str": _BuiltinStr,
151
+ "ParsedArguments": ParsedArguments,
150
152
  }
151
153
 
152
154
 
@@ -498,6 +500,8 @@ def _infer_type_map(code: str) -> Dict[str, str]:
498
500
  return value.func.id, None
499
501
  if value.func.id == "vroll":
500
502
  return "SimpleRollResult", None
503
+ if value.func.id == "argparse":
504
+ return "ParsedArguments", None
501
505
  if value.func.id in {"list", "dict", "str"}:
502
506
  return value.func.id, None
503
507
  if isinstance(value, ast.List):
avrae_ls/config.py CHANGED
@@ -3,12 +3,15 @@ from __future__ import annotations
3
3
  import json
4
4
  import logging
5
5
  import math
6
+ import os
7
+ import re
6
8
  from dataclasses import dataclass, field
7
9
  from pathlib import Path
8
- from typing import Any, Dict, Iterable, Tuple
10
+ from typing import Any, Dict, Iterable, Mapping, Tuple
9
11
 
10
12
  CONFIG_FILENAME = ".avraels.json"
11
13
  log = logging.getLogger(__name__)
14
+ _ENV_VAR_PATTERN = re.compile(r"\$(\w+)|\$\{([^}]+)\}")
12
15
 
13
16
 
14
17
  class ConfigError(Exception):
@@ -25,6 +28,8 @@ class DiagnosticSettings:
25
28
  class AvraeServiceConfig:
26
29
  base_url: str = "https://api.avrae.io"
27
30
  token: str | None = None
31
+ verify_timeout: float = 5.0
32
+ verify_retries: int = 0
28
33
 
29
34
 
30
35
  @dataclass
@@ -360,6 +365,30 @@ class AvraeLSConfig:
360
365
  )
361
366
 
362
367
 
368
+ def _expand_env_vars(data: Any, env: Mapping[str, str], missing_vars: set[str]) -> Any:
369
+ if isinstance(data, dict):
370
+ return {key: _expand_env_vars(value, env, missing_vars) for key, value in data.items()}
371
+ if isinstance(data, list):
372
+ return [_expand_env_vars(value, env, missing_vars) for value in data]
373
+ if isinstance(data, str):
374
+ def _replace(match: re.Match[str]) -> str:
375
+ var = match.group(1) or match.group(2) or ""
376
+ if var in env:
377
+ return env[var]
378
+ missing_vars.add(var)
379
+ return ""
380
+
381
+ return _ENV_VAR_PATTERN.sub(_replace, data)
382
+ return data
383
+
384
+
385
+ def _coerce_optional_str(value: Any) -> str | None:
386
+ if value is None:
387
+ return None
388
+ value_str = str(value)
389
+ return value_str if value_str.strip() else None
390
+
391
+
363
392
  def load_config(workspace_root: Path) -> Tuple[AvraeLSConfig, Iterable[str]]:
364
393
  """Load `.avraels.json` from the workspace root, returning config and warnings."""
365
394
  path = workspace_root / CONFIG_FILENAME
@@ -374,12 +403,42 @@ def load_config(workspace_root: Path) -> Tuple[AvraeLSConfig, Iterable[str]]:
374
403
  return AvraeLSConfig.default(workspace_root), [warning]
375
404
 
376
405
  warnings: list[str] = []
406
+ env_missing: set[str] = set()
407
+ env = dict(os.environ)
408
+ env.setdefault("workspaceRoot", str(workspace_root))
409
+ env.setdefault("workspaceFolder", str(workspace_root))
410
+ raw = _expand_env_vars(raw, env, env_missing)
411
+ for var in sorted(env_missing):
412
+ warning = f"{CONFIG_FILENAME}: environment variable '{var}' is not set; substituting an empty string."
413
+ warnings.append(warning)
414
+ log.warning(warning)
415
+
377
416
  enable_gvar_fetch = bool(raw.get("enableGvarFetch", False))
378
417
 
379
418
  service_cfg = raw.get("avraeService") or {}
419
+ def _get_service_timeout(raw_timeout) -> float:
420
+ try:
421
+ timeout = float(raw_timeout)
422
+ if timeout > 0:
423
+ return timeout
424
+ except Exception:
425
+ pass
426
+ return AvraeServiceConfig.verify_timeout
427
+
428
+ def _get_service_retries(raw_retries) -> int:
429
+ try:
430
+ retries = int(raw_retries)
431
+ if retries >= 0:
432
+ return retries
433
+ except Exception:
434
+ pass
435
+ return AvraeServiceConfig.verify_retries
436
+
380
437
  service = AvraeServiceConfig(
381
438
  base_url=str(service_cfg.get("baseUrl") or AvraeServiceConfig.base_url),
382
- token=service_cfg.get("token"),
439
+ token=_coerce_optional_str(service_cfg.get("token")),
440
+ verify_timeout=_get_service_timeout(service_cfg.get("verifySignatureTimeout")),
441
+ verify_retries=_get_service_retries(service_cfg.get("verifySignatureRetries")),
383
442
  )
384
443
 
385
444
  diag_cfg = raw.get("diagnostics") or {}
avrae_ls/context.py CHANGED
@@ -33,11 +33,12 @@ class ContextBuilder:
33
33
 
34
34
  def build(self, profile_name: str | None = None) -> ContextData:
35
35
  profile = self._select_profile(profile_name)
36
+ combat = self._ensure_me_combatant(profile)
36
37
  merged_vars = self._merge_character_cvars(profile.character, self._load_var_files().merge(profile.vars))
37
38
  self._gvar_resolver.seed(merged_vars.gvars)
38
39
  return ContextData(
39
40
  ctx=dict(profile.ctx),
40
- combat=dict(profile.combat),
41
+ combat=combat,
41
42
  character=dict(profile.character),
42
43
  vars=merged_vars,
43
44
  )
@@ -69,6 +70,66 @@ class ContextBuilder:
69
70
  merged = merged.merge(VarSources(cvars=builtin_cvars))
70
71
  return merged
71
72
 
73
+ def _ensure_me_combatant(self, profile: ContextProfile) -> Dict[str, Any]:
74
+ combat = dict(profile.combat or {})
75
+ combatants = list(combat.get("combatants") or [])
76
+ me = combat.get("me")
77
+ author_id = (profile.ctx.get("author") or {}).get("id")
78
+
79
+ def _matches_author(combatant: Dict[str, Any]) -> bool:
80
+ try:
81
+ return author_id is not None and str(combatant.get("controller")) == str(author_id)
82
+ except Exception:
83
+ return False
84
+
85
+ # Use an existing combatant controlled by the author if me is missing.
86
+ if me is None:
87
+ for existing in combatants:
88
+ if _matches_author(existing):
89
+ me = existing
90
+ break
91
+
92
+ # If still missing, synthesize a combatant from the character sheet.
93
+ if me is None and profile.character:
94
+ me = {
95
+ "name": profile.character.get("name", "Player"),
96
+ "id": "cmb_player",
97
+ "controller": author_id,
98
+ "group": None,
99
+ "race": profile.character.get("race"),
100
+ "monster_name": None,
101
+ "is_hidden": False,
102
+ "init": profile.character.get("stats", {}).get("dexterity", 10),
103
+ "initmod": 0,
104
+ "type": "combatant",
105
+ "note": "Mock combatant for preview",
106
+ "effects": [],
107
+ "stats": profile.character.get("stats") or {},
108
+ "levels": profile.character.get("levels") or profile.character.get("class_levels") or {},
109
+ "skills": profile.character.get("skills") or {},
110
+ "saves": profile.character.get("saves") or {},
111
+ "resistances": profile.character.get("resistances") or {},
112
+ "spellbook": profile.character.get("spellbook") or {},
113
+ "attacks": profile.character.get("attacks") or [],
114
+ "max_hp": profile.character.get("max_hp"),
115
+ "hp": profile.character.get("hp"),
116
+ "temp_hp": profile.character.get("temp_hp"),
117
+ "ac": profile.character.get("ac"),
118
+ "creature_type": profile.character.get("creature_type"),
119
+ }
120
+
121
+ if me is not None:
122
+ combat["me"] = me
123
+ if not any(c is me for c in combatants) and not any(_matches_author(c) for c in combatants):
124
+ combatants.insert(0, me)
125
+ combat["combatants"] = combatants
126
+ if "current" not in combat or combat.get("current") is None:
127
+ combat["current"] = me
128
+ else:
129
+ combat["combatants"] = combatants
130
+
131
+ return combat
132
+
72
133
 
73
134
  class GVarResolver:
74
135
  def __init__(self, config: AvraeLSConfig):
avrae_ls/diagnostics.py CHANGED
@@ -1,14 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  import ast
4
5
  import logging
5
- import inspect
6
- from typing import Iterable, List, Sequence, Set
6
+ from typing import Dict, Iterable, List, Sequence, Set
7
7
 
8
8
  import draconic
9
9
  from lsprotocol import types
10
10
 
11
11
  from .argument_parsing import apply_argument_parsing
12
+ from .completions import _infer_type_map, _resolve_type_name, _type_meta
12
13
  from .config import DiagnosticSettings
13
14
  from .context import ContextData, GVarResolver
14
15
  from .parser import find_draconic_blocks
@@ -78,6 +79,9 @@ class DiagnosticProvider:
78
79
  diagnostics.extend(_check_imports(body, self._settings.semantic_level))
79
80
  diagnostics.extend(_check_call_args(body, self._builtin_signatures, self._settings.semantic_level))
80
81
  diagnostics.extend(_check_private_method_calls(body))
82
+ diagnostics.extend(
83
+ _check_api_misuse(body, code, ctx_data, self._settings.semantic_level)
84
+ )
81
85
  if line_shift:
82
86
  diagnostics = _shift_diagnostics(diagnostics, line_shift, 0)
83
87
  return diagnostics
@@ -282,6 +286,207 @@ def _check_private_method_calls(body: Sequence[ast.AST]) -> List[types.Diagnosti
282
286
  return diagnostics
283
287
 
284
288
 
289
+ def _check_api_misuse(
290
+ body: Sequence[ast.AST],
291
+ code: str,
292
+ ctx_data: ContextData,
293
+ severity_level: str,
294
+ ) -> List[types.Diagnostic]:
295
+ """Heuristics for common API mistakes (list vs scalar, missing context, property calls)."""
296
+ diagnostics: list[types.Diagnostic] = []
297
+ module = ast.Module(body=list(body), type_ignores=[])
298
+ parent_map = _build_parent_map(module)
299
+ assigned_names = _collect_assigned_names(module)
300
+ type_map = _diagnostic_type_map(code)
301
+ context_seen: set[str] = set()
302
+
303
+ for node in ast.walk(module):
304
+ if isinstance(node, ast.Call):
305
+ diagnostics.extend(_context_call_diagnostics(node, ctx_data, severity_level, context_seen))
306
+ diagnostics.extend(_property_call_diagnostics(node, type_map, code, severity_level))
307
+ if isinstance(node, ast.Attribute):
308
+ diagnostics.extend(_uncalled_context_attr_diagnostics(node, assigned_names, severity_level))
309
+ diagnostics.extend(_iterable_attr_diagnostics(node, parent_map, type_map, code, severity_level))
310
+ return diagnostics
311
+
312
+
313
+ def _context_call_diagnostics(
314
+ node: ast.Call,
315
+ ctx_data: ContextData,
316
+ severity_level: str,
317
+ seen: set[str],
318
+ ) -> List[types.Diagnostic]:
319
+ diagnostics: list[types.Diagnostic] = []
320
+ if isinstance(node.func, ast.Name):
321
+ if node.func.id == "character" and not ctx_data.character and "character" not in seen:
322
+ seen.add("character")
323
+ diagnostics.append(
324
+ _make_diagnostic(
325
+ node.func,
326
+ "No character context configured; character() will raise at runtime.",
327
+ severity_level,
328
+ )
329
+ )
330
+ elif node.func.id == "combat" and not ctx_data.combat and "combat" not in seen:
331
+ seen.add("combat")
332
+ diagnostics.append(
333
+ _make_diagnostic(
334
+ node.func,
335
+ "No combat context configured; combat() will return None.",
336
+ severity_level,
337
+ )
338
+ )
339
+ return diagnostics
340
+
341
+
342
+ def _property_call_diagnostics(
343
+ node: ast.Call,
344
+ type_map: Dict[str, str],
345
+ code: str,
346
+ severity_level: str,
347
+ ) -> List[types.Diagnostic]:
348
+ if not isinstance(node.func, ast.Attribute):
349
+ return []
350
+ base_type = _resolve_expr_type(node.func.value, type_map, code)
351
+ if not base_type:
352
+ return []
353
+ meta = _type_meta(base_type)
354
+ attr = node.func.attr
355
+ if attr in meta.methods or attr not in meta.attrs:
356
+ return []
357
+ receiver = _expr_to_str(node.func.value) or base_type
358
+ return [
359
+ _make_diagnostic(
360
+ node.func,
361
+ f"'{attr}' on {receiver} is a property; drop the parentheses.",
362
+ severity_level,
363
+ )
364
+ ]
365
+
366
+
367
+ def _uncalled_context_attr_diagnostics(
368
+ node: ast.Attribute,
369
+ assigned_names: Set[str],
370
+ severity_level: str,
371
+ ) -> List[types.Diagnostic]:
372
+ if isinstance(node.value, ast.Name) and node.value.id in {"character", "combat"} and node.value.id not in assigned_names:
373
+ call_hint = f"{node.value.id}()"
374
+ return [
375
+ _make_diagnostic(
376
+ node.value,
377
+ f"Call {call_hint} before accessing '{node.attr}'.",
378
+ severity_level,
379
+ )
380
+ ]
381
+ return []
382
+
383
+
384
+ def _iterable_attr_diagnostics(
385
+ node: ast.Attribute,
386
+ parent_map: Dict[ast.AST, ast.AST],
387
+ type_map: Dict[str, str],
388
+ code: str,
389
+ severity_level: str,
390
+ ) -> List[types.Diagnostic]:
391
+ parent = parent_map.get(node)
392
+ if parent is None:
393
+ return []
394
+ if isinstance(parent, ast.Subscript) and parent.value is node:
395
+ return []
396
+
397
+ base_type = _resolve_expr_type(node.value, type_map, code)
398
+ if not base_type:
399
+ return []
400
+ meta = _type_meta(base_type)
401
+ attr_meta = meta.attrs.get(node.attr)
402
+ if not attr_meta:
403
+ return []
404
+
405
+ is_collection = bool(attr_meta.element_type) or attr_meta.type_name in {"list", "dict"}
406
+ if not is_collection:
407
+ return []
408
+
409
+ expr_label = _expr_to_str(node) or node.attr
410
+ element_label = attr_meta.element_type or "items"
411
+ container_label = attr_meta.type_name or "collection"
412
+
413
+ if isinstance(parent, ast.Attribute) and parent.value is node:
414
+ next_attr = parent.attr
415
+ message = f"'{expr_label}' is a {container_label} of {element_label}; index or iterate before accessing '{next_attr}'."
416
+ return [_make_diagnostic(node, message, severity_level)]
417
+
418
+ if isinstance(parent, ast.Call) and parent.func is node:
419
+ message = f"'{expr_label}' is a {container_label} of {element_label}; index or iterate before calling it."
420
+ return [_make_diagnostic(node, message, severity_level)]
421
+
422
+ return []
423
+
424
+
425
+ def _diagnostic_type_map(code: str) -> Dict[str, str]:
426
+ mapping = _infer_type_map(code)
427
+ if mapping:
428
+ return mapping
429
+ wrapped, _ = _wrap_draconic(code)
430
+ return _infer_type_map(wrapped)
431
+
432
+
433
+ def _resolve_expr_type(expr: ast.AST, type_map: Dict[str, str], code: str) -> str:
434
+ expr_text = _expr_to_str(expr)
435
+ if not expr_text:
436
+ return ""
437
+ return _resolve_type_name(expr_text, code, type_map)
438
+
439
+
440
+ def _expr_to_str(expr: ast.AST) -> str:
441
+ try:
442
+ return ast.unparse(expr)
443
+ except Exception:
444
+ return ""
445
+
446
+
447
+ def _collect_assigned_names(module: ast.Module) -> Set[str]:
448
+ assigned: set[str] = set()
449
+
450
+ class Collector(ast.NodeVisitor):
451
+ def visit_Assign(self, node: ast.Assign):
452
+ for target in node.targets:
453
+ assigned.update(_names_in_target(target))
454
+ self.generic_visit(node)
455
+
456
+ def visit_AnnAssign(self, node: ast.AnnAssign):
457
+ assigned.update(_names_in_target(node.target))
458
+ self.generic_visit(node)
459
+
460
+ def visit_For(self, node: ast.For):
461
+ assigned.update(_names_in_target(node.target))
462
+ self.generic_visit(node)
463
+
464
+ def visit_AsyncFor(self, node: ast.AsyncFor):
465
+ assigned.update(_names_in_target(node.target))
466
+ self.generic_visit(node)
467
+
468
+ def visit_FunctionDef(self, node: ast.FunctionDef):
469
+ assigned.add(node.name)
470
+ for arg in node.args.args:
471
+ assigned.add(arg.arg)
472
+ self.generic_visit(node)
473
+
474
+ def visit_ClassDef(self, node: ast.ClassDef):
475
+ assigned.add(node.name)
476
+ self.generic_visit(node)
477
+
478
+ Collector().visit(module)
479
+ return assigned
480
+
481
+
482
+ def _build_parent_map(root: ast.AST) -> Dict[ast.AST, ast.AST]:
483
+ parents: dict[ast.AST, ast.AST] = {}
484
+ for parent in ast.walk(root):
485
+ for child in ast.iter_child_nodes(parent):
486
+ parents[child] = parent
487
+ return parents
488
+
489
+
285
490
  def _make_diagnostic(node: ast.AST, message: str, level: str) -> types.Diagnostic:
286
491
  severity = SEVERITY.get(level, types.DiagnosticSeverity.Warning)
287
492
  if hasattr(node, "lineno"):
avrae_ls/parser.py CHANGED
@@ -22,12 +22,17 @@ def find_draconic_blocks(source: str) -> List[DraconicBlock]:
22
22
  raw = match.group(1)
23
23
  prefix = source[: match.start()]
24
24
  line_offset = prefix.count("\n")
25
- line_count = raw.count("\n") + 1 if raw else 1
25
+ # Column where draconic content starts on its first line
26
+ last_nl = prefix.rfind("\n")
27
+ start_col = match.start(1) - (last_nl + 1 if last_nl != -1 else 0)
28
+ char_offset = start_col
26
29
  # Trim leading blank lines inside the block while tracking the line shift
27
30
  while raw.startswith("\n"):
28
31
  raw = raw[1:]
29
32
  line_offset += 1
30
- blocks.append(DraconicBlock(code=raw, line_offset=line_offset, char_offset=0, line_count=line_count))
33
+ char_offset = 0
34
+ line_count = raw.count("\n") + 1 if raw else 1
35
+ blocks.append(DraconicBlock(code=raw, line_offset=line_offset, char_offset=char_offset, line_count=line_count))
31
36
  return blocks
32
37
 
33
38
 
avrae_ls/runtime.py CHANGED
@@ -6,6 +6,7 @@ import json
6
6
  import logging
7
7
  import math
8
8
  import random
9
+ import re
9
10
  import time
10
11
  from types import SimpleNamespace
11
12
  try: # optional dependency
@@ -98,7 +99,66 @@ def _vroll_dice(dice: str, multiply: int = 1, add: int = 0) -> SimpleRollResult
98
99
  return SimpleRollResult(rolled)
99
100
 
100
101
 
101
- def _parse_coins(args: str):
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):
102
162
  try:
103
163
  from avrae.aliasing.api.functions import parse_coins as avrae_parse_coins
104
164
  except Exception:
@@ -106,16 +166,21 @@ def _parse_coins(args: str):
106
166
 
107
167
  if avrae_parse_coins:
108
168
  try:
109
- return avrae_parse_coins(str(args))
169
+ return avrae_parse_coins(str(args), include_total=include_total)
110
170
  except Exception:
111
171
  pass
112
172
 
113
- # Fallback: accept numeric as gp, otherwise empty mapping.
114
- try:
115
- gp = float(str(args))
116
- return {"pp": 0, "gp": gp, "ep": 0, "sp": 0, "cp": 0, "total": gp}
117
- except Exception:
118
- return {"pp": 0, "gp": 0, "ep": 0, "sp": 0, "cp": 0, "total": 0}
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
119
184
 
120
185
 
121
186
  def _default_builtins() -> Dict[str, Any]:
@@ -414,6 +479,9 @@ class MockExecutor:
414
479
  verify_cache_sig = sig_str
415
480
  verify_cache_error = None
416
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)
417
485
 
418
486
  def _call_verify_api(signature: str) -> Dict[str, Any]:
419
487
  base_url = (service_cfg.base_url if service_cfg else AvraeServiceConfig.base_url).rstrip("/")
@@ -421,10 +489,18 @@ class MockExecutor:
421
489
  headers = {"Content-Type": "application/json"}
422
490
  if service_cfg and service_cfg.token:
423
491
  headers["Authorization"] = str(service_cfg.token)
424
- try:
425
- resp = httpx.post(url, json={"signature": signature}, headers=headers, timeout=5)
426
- except Exception as exc:
427
- raise ValueError(f"Failed to verify signature: {exc}") from exc
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
428
504
 
429
505
  try:
430
506
  payload = resp.json()
@@ -432,14 +508,17 @@ class MockExecutor:
432
508
  raise ValueError("Failed to verify signature: invalid response body") from exc
433
509
 
434
510
  if resp.status_code != 200:
435
- message = payload.get("error") if isinstance(payload, dict) else None
436
- raise ValueError(message or f"Failed to verify signature: HTTP {resp.status_code}")
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}")
437
516
 
438
517
  if not isinstance(payload, dict):
439
518
  raise ValueError("Failed to verify signature: invalid response")
440
519
  if payload.get("success") is not True:
441
520
  message = payload.get("error")
442
- raise ValueError(message or "Failed to verify signature: unsuccessful response")
521
+ raise ValueError(f"Failed to verify signature: {message or 'unsuccessful response'}")
443
522
 
444
523
  data = payload.get("data")
445
524
  if not isinstance(data, dict):