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/context.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import asyncio
|
|
6
|
+
import copy
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Iterable
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .config import AvraeLSConfig, ContextProfile, VarSources
|
|
14
|
+
from .cvars import derive_character_cvars
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ContextData:
|
|
21
|
+
ctx: Dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
combat: Dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
character: Dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
vars: VarSources = field(default_factory=VarSources)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContextBuilder:
|
|
28
|
+
def __init__(self, config: AvraeLSConfig):
|
|
29
|
+
self._config = config
|
|
30
|
+
self._gvar_resolver = GVarResolver(config)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def gvar_resolver(self) -> "GVarResolver":
|
|
34
|
+
return self._gvar_resolver
|
|
35
|
+
|
|
36
|
+
def build(self, profile_name: str | None = None) -> ContextData:
|
|
37
|
+
profile = self._select_profile(profile_name)
|
|
38
|
+
# Deep copy profile data so mutations during a run do not persist.
|
|
39
|
+
profile_character = copy.deepcopy(profile.character)
|
|
40
|
+
profile_combat = copy.deepcopy(profile.combat)
|
|
41
|
+
profile_ctx = copy.deepcopy(profile.ctx)
|
|
42
|
+
|
|
43
|
+
combat = self._ensure_me_combatant(profile_combat, profile_ctx.get("author"))
|
|
44
|
+
merged_vars = self._merge_character_cvars(profile_character, self._load_var_files().merge(profile.vars))
|
|
45
|
+
self._gvar_resolver.reset(merged_vars.gvars)
|
|
46
|
+
return ContextData(
|
|
47
|
+
ctx=profile_ctx,
|
|
48
|
+
combat=combat,
|
|
49
|
+
character=profile_character,
|
|
50
|
+
vars=merged_vars,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _select_profile(self, profile_name: str | None) -> ContextProfile:
|
|
54
|
+
if profile_name and profile_name in self._config.profiles:
|
|
55
|
+
return self._config.profiles[profile_name]
|
|
56
|
+
if self._config.default_profile in self._config.profiles:
|
|
57
|
+
return self._config.profiles[self._config.default_profile]
|
|
58
|
+
return next(iter(self._config.profiles.values()))
|
|
59
|
+
|
|
60
|
+
def _load_var_files(self) -> VarSources:
|
|
61
|
+
merged = VarSources()
|
|
62
|
+
for path in self._config.var_files:
|
|
63
|
+
data = _read_json_file(path)
|
|
64
|
+
if data is None:
|
|
65
|
+
continue
|
|
66
|
+
merged = merged.merge(VarSources.from_data(data))
|
|
67
|
+
return merged
|
|
68
|
+
|
|
69
|
+
def _merge_character_cvars(self, character: Dict[str, Any], vars: VarSources) -> VarSources:
|
|
70
|
+
merged = vars
|
|
71
|
+
char_cvars = character.get("cvars") or {}
|
|
72
|
+
if char_cvars:
|
|
73
|
+
merged = merged.merge(VarSources(cvars=dict(char_cvars)))
|
|
74
|
+
|
|
75
|
+
builtin_cvars = derive_character_cvars(character)
|
|
76
|
+
if builtin_cvars:
|
|
77
|
+
merged = merged.merge(VarSources(cvars=builtin_cvars))
|
|
78
|
+
return merged
|
|
79
|
+
|
|
80
|
+
def _ensure_me_combatant(self, profile: Dict[str, Any], ctx_author: Dict[str, Any] | None) -> Dict[str, Any]:
|
|
81
|
+
combat = dict(profile or {})
|
|
82
|
+
combatants = list(combat.get("combatants") or [])
|
|
83
|
+
me = combat.get("me")
|
|
84
|
+
author_id = (ctx_author or {}).get("id")
|
|
85
|
+
|
|
86
|
+
def _matches_author(combatant: Dict[str, Any]) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
return author_id is not None and str(combatant.get("controller")) == str(author_id)
|
|
89
|
+
except Exception:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
# Use an existing combatant controlled by the author if me is missing.
|
|
93
|
+
if me is None:
|
|
94
|
+
for existing in combatants:
|
|
95
|
+
if _matches_author(existing):
|
|
96
|
+
me = existing
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
# If still missing, synthesize a combatant from the character sheet.
|
|
100
|
+
if me is None and profile.character:
|
|
101
|
+
me = {
|
|
102
|
+
"name": profile.character.get("name", "Player"),
|
|
103
|
+
"id": "cmb_player",
|
|
104
|
+
"controller": author_id,
|
|
105
|
+
"group": None,
|
|
106
|
+
"race": profile.character.get("race"),
|
|
107
|
+
"monster_name": None,
|
|
108
|
+
"is_hidden": False,
|
|
109
|
+
"init": profile.character.get("stats", {}).get("dexterity", 10),
|
|
110
|
+
"initmod": 0,
|
|
111
|
+
"type": "combatant",
|
|
112
|
+
"note": "Mock combatant for preview",
|
|
113
|
+
"effects": [],
|
|
114
|
+
"stats": profile.character.get("stats") or {},
|
|
115
|
+
"levels": profile.character.get("levels") or profile.character.get("class_levels") or {},
|
|
116
|
+
"skills": profile.character.get("skills") or {},
|
|
117
|
+
"saves": profile.character.get("saves") or {},
|
|
118
|
+
"resistances": profile.character.get("resistances") or {},
|
|
119
|
+
"spellbook": profile.character.get("spellbook") or {},
|
|
120
|
+
"attacks": profile.character.get("attacks") or [],
|
|
121
|
+
"max_hp": profile.character.get("max_hp"),
|
|
122
|
+
"hp": profile.character.get("hp"),
|
|
123
|
+
"temp_hp": profile.character.get("temp_hp"),
|
|
124
|
+
"ac": profile.character.get("ac"),
|
|
125
|
+
"creature_type": profile.character.get("creature_type"),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if me is not None:
|
|
129
|
+
combat["me"] = me
|
|
130
|
+
if not any(c is me for c in combatants) and not any(_matches_author(c) for c in combatants):
|
|
131
|
+
combatants.insert(0, me)
|
|
132
|
+
combat["combatants"] = combatants
|
|
133
|
+
if "current" not in combat or combat.get("current") is None:
|
|
134
|
+
combat["current"] = me
|
|
135
|
+
else:
|
|
136
|
+
combat["combatants"] = combatants
|
|
137
|
+
|
|
138
|
+
return combat
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class GVarResolver:
|
|
142
|
+
_CONCURRENCY = 5
|
|
143
|
+
|
|
144
|
+
def __init__(self, config: AvraeLSConfig):
|
|
145
|
+
self._config = config
|
|
146
|
+
self._cache: Dict[str, Any] = {}
|
|
147
|
+
|
|
148
|
+
def reset(self, gvars: Dict[str, Any] | None = None) -> None:
|
|
149
|
+
self._cache = {}
|
|
150
|
+
if gvars:
|
|
151
|
+
self._cache.update({str(k): v for k, v in gvars.items()})
|
|
152
|
+
|
|
153
|
+
def seed(self, gvars: Dict[str, Any] | None = None) -> None:
|
|
154
|
+
"""Merge provided gvars into the cache without dropping fetched values."""
|
|
155
|
+
if not gvars:
|
|
156
|
+
return
|
|
157
|
+
for k, v in gvars.items():
|
|
158
|
+
self._cache[str(k)] = v
|
|
159
|
+
|
|
160
|
+
def get_local(self, key: str) -> Any:
|
|
161
|
+
return self._cache.get(str(key))
|
|
162
|
+
|
|
163
|
+
async def ensure(self, key: str) -> bool:
|
|
164
|
+
key = str(key)
|
|
165
|
+
if key in self._cache:
|
|
166
|
+
log.debug("GVAR ensure cache hit for %s", key)
|
|
167
|
+
return True
|
|
168
|
+
return await self._fetch_remote(key)
|
|
169
|
+
|
|
170
|
+
async def ensure_many(self, keys: Iterable[str]) -> Dict[str, bool]:
|
|
171
|
+
results: dict[str, bool] = {}
|
|
172
|
+
missing = [str(k) for k in keys if str(k) not in self._cache]
|
|
173
|
+
for key in keys:
|
|
174
|
+
results[str(key)] = str(key) in self._cache
|
|
175
|
+
|
|
176
|
+
if not missing:
|
|
177
|
+
return results
|
|
178
|
+
if not self._config.enable_gvar_fetch:
|
|
179
|
+
log.warning("GVAR fetch disabled; skipping %s", missing)
|
|
180
|
+
return results
|
|
181
|
+
if not self._config.service.token:
|
|
182
|
+
log.debug("GVAR fetch skipped for %s: no token configured", missing)
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
sem = asyncio.Semaphore(self._CONCURRENCY)
|
|
186
|
+
|
|
187
|
+
async def _fetch(key: str, client: httpx.AsyncClient) -> None:
|
|
188
|
+
if key in self._cache:
|
|
189
|
+
results[key] = True
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
ensured = await self._fetch_remote(key, client=client, sem=sem)
|
|
193
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
194
|
+
log.error("GVAR fetch failed for %s: %s", key, exc)
|
|
195
|
+
ensured = False
|
|
196
|
+
results[key] = ensured
|
|
197
|
+
|
|
198
|
+
async with httpx.AsyncClient(timeout=5) as client:
|
|
199
|
+
await asyncio.gather(*(_fetch(key, client) for key in missing))
|
|
200
|
+
return results
|
|
201
|
+
|
|
202
|
+
def ensure_blocking(self, key: str) -> bool:
|
|
203
|
+
key = str(key)
|
|
204
|
+
if key in self._cache:
|
|
205
|
+
log.debug("GVAR ensure_blocking cache hit for %s", key)
|
|
206
|
+
return True
|
|
207
|
+
if not self._config.enable_gvar_fetch:
|
|
208
|
+
log.warning("GVAR fetch disabled; skipping %s", key)
|
|
209
|
+
return False
|
|
210
|
+
if not self._config.service.token:
|
|
211
|
+
log.debug("GVAR fetch skipped for %s: no token configured", key)
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
base_url = self._config.service.base_url.rstrip("/")
|
|
215
|
+
url = f"{base_url}/customizations/gvars/{key}"
|
|
216
|
+
headers = {"Authorization": str(self._config.service.token)}
|
|
217
|
+
try:
|
|
218
|
+
log.debug("GVAR blocking fetch %s from %s", key, url)
|
|
219
|
+
with httpx.Client(timeout=5) as client:
|
|
220
|
+
resp = client.get(url, headers=headers)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
log.error("GVAR blocking fetch failed for %s: %s", key, exc)
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
if resp.status_code != 200:
|
|
226
|
+
log.warning(
|
|
227
|
+
"GVAR blocking fetch returned %s for %s (body: %s)",
|
|
228
|
+
resp.status_code,
|
|
229
|
+
key,
|
|
230
|
+
(resp.text or "").strip(),
|
|
231
|
+
)
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
value: Any = None
|
|
235
|
+
try:
|
|
236
|
+
payload = resp.json()
|
|
237
|
+
except Exception:
|
|
238
|
+
payload = None
|
|
239
|
+
|
|
240
|
+
if isinstance(payload, dict) and "value" in payload:
|
|
241
|
+
value = payload["value"]
|
|
242
|
+
|
|
243
|
+
if value is None:
|
|
244
|
+
log.error("GVAR %s payload missing value", key)
|
|
245
|
+
return False
|
|
246
|
+
self._cache[key] = value
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
def snapshot(self) -> Dict[str, Any]:
|
|
250
|
+
return dict(self._cache)
|
|
251
|
+
|
|
252
|
+
async def refresh(self, seed: Dict[str, Any] | None = None, keys: Iterable[str] | None = None) -> Dict[str, Any]:
|
|
253
|
+
self.reset(seed)
|
|
254
|
+
if keys:
|
|
255
|
+
await self.ensure_many(keys)
|
|
256
|
+
return self.snapshot()
|
|
257
|
+
|
|
258
|
+
async def _fetch_remote(
|
|
259
|
+
self, key: str, client: httpx.AsyncClient | None = None, sem: asyncio.Semaphore | None = None
|
|
260
|
+
) -> bool:
|
|
261
|
+
key = str(key)
|
|
262
|
+
if key in self._cache:
|
|
263
|
+
return True
|
|
264
|
+
if not self._config.enable_gvar_fetch:
|
|
265
|
+
return False
|
|
266
|
+
if not self._config.service.token:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
base_url = self._config.service.base_url.rstrip("/")
|
|
270
|
+
url = f"{base_url}/customizations/gvars/{key}"
|
|
271
|
+
headers = {"Authorization": str(self._config.service.token)}
|
|
272
|
+
|
|
273
|
+
async def _do_request(session: httpx.AsyncClient) -> httpx.Response:
|
|
274
|
+
if sem:
|
|
275
|
+
async with sem:
|
|
276
|
+
return await session.get(url, headers=headers)
|
|
277
|
+
return await session.get(url, headers=headers)
|
|
278
|
+
|
|
279
|
+
close_client = False
|
|
280
|
+
session = client
|
|
281
|
+
if session is None:
|
|
282
|
+
session = httpx.AsyncClient(timeout=5)
|
|
283
|
+
close_client = True
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
log.debug("GVAR fetching %s from %s", key, url)
|
|
287
|
+
resp = await _do_request(session)
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
log.error("GVAR fetch failed for %s: %s", key, exc)
|
|
290
|
+
if close_client:
|
|
291
|
+
await session.aclose()
|
|
292
|
+
return False
|
|
293
|
+
if close_client:
|
|
294
|
+
await session.aclose()
|
|
295
|
+
|
|
296
|
+
if resp.status_code != 200:
|
|
297
|
+
log.warning(
|
|
298
|
+
"GVAR fetch returned %s for %s (body: %s)",
|
|
299
|
+
resp.status_code,
|
|
300
|
+
key,
|
|
301
|
+
(resp.text or "").strip(),
|
|
302
|
+
)
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
value: Any = None
|
|
306
|
+
try:
|
|
307
|
+
payload = resp.json()
|
|
308
|
+
except Exception:
|
|
309
|
+
payload = None
|
|
310
|
+
|
|
311
|
+
if isinstance(payload, dict) and "value" in payload:
|
|
312
|
+
value = payload["value"]
|
|
313
|
+
|
|
314
|
+
log.debug("GVAR fetch parsed value for %s (type=%s)", key, type(value).__name__)
|
|
315
|
+
|
|
316
|
+
if value is None:
|
|
317
|
+
log.error("GVAR %s payload missing value", key)
|
|
318
|
+
return False
|
|
319
|
+
self._cache[key] = value
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _read_json_file(path: Path) -> Dict[str, Any] | None:
|
|
324
|
+
try:
|
|
325
|
+
text = path.read_text()
|
|
326
|
+
except FileNotFoundError:
|
|
327
|
+
log.debug("Var file not found: %s", path)
|
|
328
|
+
return None
|
|
329
|
+
except OSError as exc:
|
|
330
|
+
log.warning("Failed to read var file %s: %s", path, exc)
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
return json.loads(text)
|
|
335
|
+
except json.JSONDecodeError as exc:
|
|
336
|
+
log.warning("Failed to parse var file %s: %s", path, exc)
|
|
337
|
+
return None
|
avrae_ls/cvars.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any, Dict, Mapping
|
|
5
|
+
|
|
6
|
+
ABILITY_KEYS = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]
|
|
7
|
+
SAVE_KEYS = {
|
|
8
|
+
"strength": "str",
|
|
9
|
+
"dexterity": "dex",
|
|
10
|
+
"constitution": "con",
|
|
11
|
+
"intelligence": "int",
|
|
12
|
+
"wisdom": "wis",
|
|
13
|
+
"charisma": "cha",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def derive_character_cvars(character: Mapping[str, Any]) -> Dict[str, Any]:
|
|
18
|
+
"""Build the documented cvar table values from a character payload."""
|
|
19
|
+
stats = character.get("stats") or {}
|
|
20
|
+
saves = character.get("saves") or {}
|
|
21
|
+
levels = character.get("levels") or {}
|
|
22
|
+
spellbook = character.get("spellbook") or {}
|
|
23
|
+
csettings = character.get("csettings") or {}
|
|
24
|
+
|
|
25
|
+
cvars: dict[str, Any] = {}
|
|
26
|
+
|
|
27
|
+
for ability in ABILITY_KEYS:
|
|
28
|
+
score = _int_or_none(stats.get(ability))
|
|
29
|
+
save_val = _int_or_none(saves.get(SAVE_KEYS[ability]))
|
|
30
|
+
|
|
31
|
+
if score is not None:
|
|
32
|
+
cvars[ability] = score
|
|
33
|
+
cvars[f"{ability}Mod"] = math.floor((score - 10) / 2)
|
|
34
|
+
if save_val is not None:
|
|
35
|
+
cvars[f"{ability}Save"] = save_val
|
|
36
|
+
|
|
37
|
+
armor = _int_or_none(character.get("ac"))
|
|
38
|
+
if armor is not None:
|
|
39
|
+
cvars["armor"] = armor
|
|
40
|
+
|
|
41
|
+
description = character.get("description")
|
|
42
|
+
if description is not None:
|
|
43
|
+
cvars["description"] = description
|
|
44
|
+
|
|
45
|
+
image = character.get("image")
|
|
46
|
+
if image is not None:
|
|
47
|
+
cvars["image"] = image
|
|
48
|
+
|
|
49
|
+
name = character.get("name")
|
|
50
|
+
if name is not None:
|
|
51
|
+
cvars["name"] = name
|
|
52
|
+
|
|
53
|
+
max_hp = _int_or_none(character.get("max_hp"))
|
|
54
|
+
if max_hp is not None:
|
|
55
|
+
cvars["hp"] = max_hp
|
|
56
|
+
|
|
57
|
+
color = _color_hex(csettings.get("color"))
|
|
58
|
+
if color is not None:
|
|
59
|
+
cvars["color"] = color
|
|
60
|
+
|
|
61
|
+
prof = _int_or_none(stats.get("prof_bonus"))
|
|
62
|
+
if prof is not None:
|
|
63
|
+
cvars["proficiencyBonus"] = prof
|
|
64
|
+
|
|
65
|
+
spell_mod = _spell_mod(spellbook, prof)
|
|
66
|
+
if spell_mod is not None:
|
|
67
|
+
cvars["spell"] = spell_mod
|
|
68
|
+
|
|
69
|
+
total_level = _sum_ints(levels.values())
|
|
70
|
+
if total_level is not None:
|
|
71
|
+
cvars["level"] = total_level
|
|
72
|
+
|
|
73
|
+
for cls, lvl in levels.items():
|
|
74
|
+
lvl_int = _int_or_none(lvl)
|
|
75
|
+
if lvl_int is None:
|
|
76
|
+
continue
|
|
77
|
+
cvars[f"{str(cls).replace(' ', '')}Level"] = lvl_int
|
|
78
|
+
|
|
79
|
+
return cvars
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _int_or_none(value: Any) -> int | None:
|
|
83
|
+
if isinstance(value, dict) and "value" in value:
|
|
84
|
+
value = value.get("value")
|
|
85
|
+
try:
|
|
86
|
+
return int(value)
|
|
87
|
+
except (TypeError, ValueError):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _spell_mod(spellbook: Mapping[str, Any], prof_bonus: int | None) -> int | None:
|
|
92
|
+
if "spell_mod" in spellbook:
|
|
93
|
+
return _int_or_none(spellbook.get("spell_mod"))
|
|
94
|
+
if "sab" in spellbook and prof_bonus is not None:
|
|
95
|
+
sab = _int_or_none(spellbook.get("sab"))
|
|
96
|
+
if sab is not None:
|
|
97
|
+
return sab - prof_bonus
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _sum_ints(values: Any) -> int | None:
|
|
102
|
+
try:
|
|
103
|
+
total = sum(int(v) for v in values)
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
return total
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _color_hex(value: Any) -> str | None:
|
|
110
|
+
if value is None:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
return hex(int(value))[2:]
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
return None
|