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/config.py DELETED
@@ -1,496 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import logging
5
- import math
6
- import os
7
- import re
8
- from dataclasses import dataclass, field
9
- from pathlib import Path
10
- from typing import Any, Dict, Iterable, Mapping, Tuple
11
-
12
- CONFIG_FILENAME = ".avraels.json"
13
- log = logging.getLogger(__name__)
14
- _ENV_VAR_PATTERN = re.compile(r"\$(\w+)|\$\{([^}]+)\}")
15
-
16
-
17
- class ConfigError(Exception):
18
- """Raised when a workspace config file cannot be parsed."""
19
-
20
-
21
- @dataclass
22
- class DiagnosticSettings:
23
- semantic_level: str = "warning"
24
- runtime_level: str = "error"
25
-
26
-
27
- @dataclass
28
- class AvraeServiceConfig:
29
- base_url: str = "https://api.avrae.io"
30
- token: str | None = None
31
- verify_timeout: float = 5.0
32
- verify_retries: int = 0
33
-
34
-
35
- @dataclass
36
- class VarSources:
37
- cvars: Dict[str, Any] = field(default_factory=dict)
38
- uvars: Dict[str, Any] = field(default_factory=dict)
39
- svars: Dict[str, Any] = field(default_factory=dict)
40
- gvars: Dict[str, Any] = field(default_factory=dict)
41
-
42
- @classmethod
43
- def from_data(cls, data: Dict[str, Any] | None) -> "VarSources":
44
- data = data or {}
45
- return cls(
46
- cvars=dict(data.get("cvars") or {}),
47
- uvars=dict(data.get("uvars") or {}),
48
- svars=dict(data.get("svars") or {}),
49
- gvars=dict(data.get("gvars") or {}),
50
- )
51
-
52
- def merge(self, other: "VarSources") -> "VarSources":
53
- def _merge(lhs: Dict[str, Any], rhs: Dict[str, Any]) -> Dict[str, Any]:
54
- merged = dict(lhs)
55
- merged.update(rhs)
56
- return merged
57
-
58
- return VarSources(
59
- cvars=_merge(self.cvars, other.cvars),
60
- uvars=_merge(self.uvars, other.uvars),
61
- svars=_merge(self.svars, other.svars),
62
- gvars=_merge(self.gvars, other.gvars),
63
- )
64
-
65
- def to_initial_names(self) -> Dict[str, Any]:
66
- names: Dict[str, Any] = {}
67
- # Bind uvars first so cvars take precedence, matching Avrae's local > cvar > uvar lookup.
68
- names.update(self.uvars)
69
- names.update(self.cvars)
70
- return names
71
-
72
-
73
- @dataclass
74
- class ContextProfile:
75
- name: str
76
- ctx: Dict[str, Any] = field(default_factory=dict)
77
- combat: Dict[str, Any] = field(default_factory=dict)
78
- character: Dict[str, Any] = field(default_factory=dict)
79
- vars: VarSources = field(default_factory=VarSources)
80
- description: str = ""
81
-
82
-
83
- @dataclass
84
- class AvraeLSConfig:
85
- workspace_root: Path
86
- enable_gvar_fetch: bool = False
87
- service: AvraeServiceConfig = field(default_factory=AvraeServiceConfig)
88
- var_files: Tuple[Path, ...] = field(default_factory=tuple)
89
- default_profile: str = "default"
90
- profiles: Dict[str, ContextProfile] = field(default_factory=dict)
91
- diagnostics: DiagnosticSettings = field(default_factory=DiagnosticSettings)
92
-
93
- @classmethod
94
- def default(cls, workspace_root: Path) -> "AvraeLSConfig":
95
- abilities = {
96
- "strength": 16,
97
- "dexterity": 14,
98
- "constitution": 15,
99
- "intelligence": 10,
100
- "wisdom": 12,
101
- "charisma": 13,
102
- "prof_bonus": 3,
103
- }
104
-
105
- def _mod(score: int) -> int:
106
- return math.floor((score - 10) / 2)
107
-
108
- skill_profs = {"athletics", "perception", "stealth", "survival"}
109
- skills = {}
110
- for name, ability in {
111
- "acrobatics": "dexterity",
112
- "animalHandling": "wisdom",
113
- "arcana": "intelligence",
114
- "athletics": "strength",
115
- "deception": "charisma",
116
- "history": "intelligence",
117
- "initiative": "dexterity",
118
- "insight": "wisdom",
119
- "intimidation": "charisma",
120
- "investigation": "intelligence",
121
- "medicine": "wisdom",
122
- "nature": "intelligence",
123
- "perception": "wisdom",
124
- "performance": "charisma",
125
- "persuasion": "charisma",
126
- "religion": "intelligence",
127
- "sleightOfHand": "dexterity",
128
- "stealth": "dexterity",
129
- "survival": "wisdom",
130
- "strength": "strength",
131
- "dexterity": "dexterity",
132
- "constitution": "constitution",
133
- "intelligence": "intelligence",
134
- "wisdom": "wisdom",
135
- "charisma": "charisma",
136
- }.items():
137
- base = _mod(abilities[ability])
138
- prof = 1 if name in skill_profs else 0
139
- skills[name] = {"value": base + abilities["prof_bonus"] * prof, "prof": prof, "bonus": 0, "adv": None}
140
-
141
- saves = {
142
- "str": _mod(abilities["strength"]) + abilities["prof_bonus"],
143
- "dex": _mod(abilities["dexterity"]),
144
- "con": _mod(abilities["constitution"]) + abilities["prof_bonus"],
145
- "int": _mod(abilities["intelligence"]),
146
- "wis": _mod(abilities["wisdom"]),
147
- "cha": _mod(abilities["charisma"]),
148
- }
149
-
150
- attacks = [
151
- {
152
- "name": "Longsword",
153
- "verb": "swings",
154
- "proper": False,
155
- "activation_type": 1,
156
- "raw": {"name": "Longsword", "bonus": "+7", "damage": "1d8+4 slashing"},
157
- },
158
- {
159
- "name": "Shortbow",
160
- "verb": "looses",
161
- "proper": False,
162
- "activation_type": 1,
163
- "raw": {"name": "Shortbow", "bonus": "+6", "damage": "1d6+3 piercing"},
164
- },
165
- ]
166
-
167
- spellbook = {
168
- "dc": 14,
169
- "sab": 6,
170
- "caster_level": 5,
171
- "spell_mod": 3,
172
- "pact_slot_level": None,
173
- "num_pact_slots": None,
174
- "max_pact_slots": None,
175
- "slots": {1: 4, 2: 2},
176
- "max_slots": {1: 4, 2: 2},
177
- "spells": [
178
- {"name": "Cure Wounds", "dc": None, "sab": None, "mod": None, "prepared": True},
179
- {"name": "Hunter's Mark", "dc": None, "sab": None, "mod": None, "prepared": True},
180
- {"name": "Fire Bolt", "dc": None, "sab": None, "mod": None, "prepared": True},
181
- ],
182
- }
183
-
184
- consumables = {
185
- "Hit Dice": {
186
- "name": "Hit Dice",
187
- "value": 5,
188
- "max": 5,
189
- "min": 0,
190
- "reset_on": "long",
191
- "display_type": None,
192
- "reset_to": None,
193
- "reset_by": None,
194
- "title": "d10 hit dice",
195
- "desc": "Hit dice pool",
196
- },
197
- "Bardic Inspiration": {
198
- "name": "Bardic Inspiration",
199
- "value": 3,
200
- "max": 3,
201
- "min": 0,
202
- "reset_on": "long",
203
- "display_type": "bubble",
204
- "reset_to": "max",
205
- "reset_by": None,
206
- "title": None,
207
- "desc": None,
208
- },
209
- }
210
-
211
- character = {
212
- "name": "Aelar Wyn",
213
- "race": "Half-Elf",
214
- "background": "Outlander",
215
- "description": "Scout of the Emerald Enclave.",
216
- "image": "https://example.invalid/aelar.png",
217
- "owner": 1010101010,
218
- "upstream": "char_aelar_wyn",
219
- "sheet_type": "beyond",
220
- "creature_type": "humanoid",
221
- "stats": abilities,
222
- "levels": {"Fighter": 3, "Rogue": 2},
223
- "attacks": attacks,
224
- "actions": [
225
- {
226
- "name": "Second Wind",
227
- "activation_type": 3,
228
- "activation_type_name": "BONUS_ACTION",
229
- "description": "+1d10+5 hp",
230
- "snippet": "+1d10+5 hp",
231
- },
232
- {
233
- "name": "Action Surge",
234
- "activation_type": 1,
235
- "activation_type_name": "ACTION",
236
- "description": "Take one additional action.",
237
- "snippet": "Take one additional action.",
238
- },
239
- ],
240
- "skills": skills,
241
- "saves": saves,
242
- "resistances": {"resist": [{"dtype": "fire", "unless": [], "only": []}], "vuln": [], "immune": [], "neutral": []},
243
- "spellbook": spellbook,
244
- "consumables": consumables,
245
- "cvars": {"favorite_enemy": "goblinoids", "fighting_style": "defense"},
246
- "coinpurse": {"pp": 1, "gp": 47, "ep": 0, "sp": 12, "cp": 34},
247
- "death_saves": {"successes": 0, "fails": 0},
248
- "max_hp": 44,
249
- "hp": 38,
250
- "temp_hp": 3,
251
- "ac": 17,
252
- "passive_perception": 15,
253
- "speed": 30,
254
- "class_levels": {"Fighter": 3, "Rogue": 2},
255
- "csettings": {"compact_coins": False},
256
- }
257
-
258
- me_combatant = {
259
- "name": character["name"],
260
- "id": "cmb_aelar",
261
- "controller": character["owner"],
262
- "group": None,
263
- "race": character["race"],
264
- "monster_name": None,
265
- "is_hidden": False,
266
- "init": 18,
267
- "initmod": 4,
268
- "type": "combatant",
269
- "note": "On watch",
270
- "effects": [
271
- {
272
- "name": "Hunter's Mark",
273
- "duration": 600,
274
- "remaining": 540,
275
- "desc": "Mark one target; deal +1d6 damage to it.",
276
- "concentration": True,
277
- "combatant_name": character["name"],
278
- }
279
- ],
280
- "stats": character["stats"],
281
- "levels": character["levels"],
282
- "skills": character["skills"],
283
- "saves": character["saves"],
284
- "resistances": character["resistances"],
285
- "spellbook": character["spellbook"],
286
- "attacks": character["attacks"],
287
- "max_hp": character["max_hp"],
288
- "hp": character["hp"],
289
- "temp_hp": character["temp_hp"],
290
- "ac": character["ac"],
291
- "creature_type": character["creature_type"],
292
- }
293
-
294
- goblin = {
295
- "name": "Goblin Cutter",
296
- "id": "cmb_gob1",
297
- "controller": None,
298
- "group": "Goblins",
299
- "race": None,
300
- "monster_name": "Goblin",
301
- "is_hidden": False,
302
- "init": 12,
303
- "initmod": 2,
304
- "type": "combatant",
305
- "note": "",
306
- "effects": [],
307
- "stats": {"strength": 8, "dexterity": 14, "constitution": 10, "intelligence": 8, "wisdom": 10, "charisma": 8, "prof_bonus": 2},
308
- "levels": {},
309
- "skills": {"stealth": {"value": 6, "prof": 1, "bonus": 0, "adv": None}},
310
- "saves": {"dex": 4},
311
- "resistances": {"resist": [], "vuln": [], "immune": [], "neutral": []},
312
- "spellbook": {"spells": []},
313
- "attacks": [{"name": "Scimitar", "verb": "slashes", "proper": False, "activation_type": 1, "raw": {"name": "Scimitar", "bonus": "+4", "damage": "1d6+2 slashing"}}],
314
- "max_hp": 11,
315
- "hp": 11,
316
- "temp_hp": 0,
317
- "ac": 15,
318
- "creature_type": "humanoid",
319
- }
320
-
321
- group = {"name": "Goblins", "id": "grp_goblins", "init": 12, "type": "group", "combatants": [goblin]}
322
-
323
- combat = {
324
- "name": "Goblin Ambush",
325
- "round_num": 2,
326
- "turn_num": 15,
327
- "combatants": [me_combatant, goblin],
328
- "groups": [group],
329
- "me": me_combatant,
330
- "current": group,
331
- "metadata": {"scene": "Forest road", "weather": "light rain"},
332
- }
333
-
334
- ctx = {
335
- "guild": {"id": 123456789012345678, "name": "Fabled Realms"},
336
- "channel": {
337
- "id": 234567890123456789,
338
- "name": "tavern-rp",
339
- "topic": "Adventuring party chat",
340
- "category": {"id": 9876543210, "name": "Adventures"},
341
- "parent": None,
342
- },
343
- "author": {
344
- "id": 345678901234567890,
345
- "name": "AelarW",
346
- "discriminator": "0420",
347
- "display_name": "Aelar Wyn",
348
- "roles": [{"id": 4567, "name": "DM"}, {"id": 4568, "name": "Player"}],
349
- },
350
- "prefix": "!",
351
- "alias": "mockalias",
352
- "message_id": 456789012345678901,
353
- }
354
-
355
- profile = ContextProfile(
356
- name="default",
357
- ctx=ctx,
358
- combat=combat,
359
- character=character,
360
- description="Built-in Avrae LS mock profile with realistic sample data.",
361
- )
362
- return cls(
363
- workspace_root=workspace_root,
364
- profiles={"default": profile},
365
- )
366
-
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
-
392
- def load_config(workspace_root: Path) -> Tuple[AvraeLSConfig, Iterable[str]]:
393
- """Load `.avraels.json` from the workspace root, returning config and warnings."""
394
- path = workspace_root / CONFIG_FILENAME
395
- if not path.exists():
396
- return AvraeLSConfig.default(workspace_root), []
397
-
398
- try:
399
- raw = json.loads(path.read_text())
400
- except json.JSONDecodeError as exc:
401
- warning = f"Failed to parse {CONFIG_FILENAME}: {exc}"
402
- log.warning(warning)
403
- return AvraeLSConfig.default(workspace_root), [warning]
404
-
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
-
416
- enable_gvar_fetch = bool(raw.get("enableGvarFetch", False))
417
-
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
-
437
- service = AvraeServiceConfig(
438
- base_url=str(service_cfg.get("baseUrl") or AvraeServiceConfig.base_url),
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")),
442
- )
443
-
444
- diag_cfg = raw.get("diagnostics") or {}
445
- diagnostics = DiagnosticSettings(
446
- semantic_level=str(diag_cfg.get("semanticLevel") or "warning").lower(),
447
- runtime_level=str(diag_cfg.get("runtimeLevel") or "error").lower(),
448
- )
449
-
450
- var_files = tuple(
451
- _resolve_var_file(workspace_root, file_path)
452
- for file_path in raw.get("varFiles", [])
453
- if isinstance(file_path, str)
454
- )
455
-
456
- profiles: Dict[str, ContextProfile] = {}
457
- raw_profiles = raw.get("profiles") or {}
458
- for name, data in raw_profiles.items():
459
- profiles[name] = ContextProfile(
460
- name=name,
461
- ctx=dict(data.get("ctx") or {}),
462
- combat=dict(data.get("combat") or {}),
463
- character=dict(data.get("character") or {}),
464
- vars=VarSources.from_data(data.get("vars")),
465
- description=str(data.get("description") or ""),
466
- )
467
-
468
- default_profile = str(raw.get("defaultProfile") or "default")
469
- if default_profile not in profiles and profiles:
470
- warnings.append(
471
- f"defaultProfile '{default_profile}' not found; falling back to first profile in file."
472
- )
473
- default_profile = next(iter(profiles))
474
-
475
- if not profiles:
476
- base = AvraeLSConfig.default(workspace_root)
477
- profiles = base.profiles
478
- default_profile = base.default_profile
479
-
480
- cfg = AvraeLSConfig(
481
- workspace_root=workspace_root,
482
- enable_gvar_fetch=enable_gvar_fetch,
483
- service=service,
484
- var_files=var_files,
485
- default_profile=default_profile,
486
- profiles=profiles,
487
- diagnostics=diagnostics,
488
- )
489
- return cfg, warnings
490
-
491
-
492
- def _resolve_var_file(root: Path, file_path: str) -> Path:
493
- candidate = Path(file_path)
494
- if not candidate.is_absolute():
495
- candidate = root / candidate
496
- return candidate