fix(loaders): malformed spell_slots key raises CombatantLoadError, not bare ValueError

int(k) on a non-numeric JSON key escaped the loader as an unhandled
ValueError traceback (CLI exit 1) instead of the documented exit code 2.
Route the conversion through _parse_spell_slot_keys which wraps it in a
CombatantLoadError with a clear message. Audited the other loader-side
int()/conversions: foundry.py guards every int() with isinstance/isdigit
checks and combatant.py has no other unguarded conversion.
This commit is contained in:
2026-09-20 16:58:57 +02:00
parent 5911602736
commit 9656ae820a
2 changed files with 39 additions and 1 deletions
+20 -1
View File
@@ -221,6 +221,25 @@ def _merge_resource_grants(
return merged
def _parse_spell_slot_keys(raw_slots: dict[str, Json], path: Path) -> dict[int, Json]:
"""Convert JSON ``spell_slots`` string keys to ints, failing cleanly.
JSON object keys are always strings; the ``Combatant.spell_slots`` field
is ``dict[int, int]``. A non-numeric key (e.g. ``"one": 4``) is a data
error and must surface as a ``CombatantLoadError`` (CLI exit code 2),
never as a bare ``ValueError`` traceback escaping the loader.
"""
parsed: dict[int, Json] = {}
for key, value in raw_slots.items():
try:
slot_level = int(key)
except ValueError as exc:
msg = f"spell_slots keys must be integer spell levels, got {key!r}"
raise CombatantLoadError(path, msg) from exc
parsed[slot_level] = value
return parsed
def _auto_attack_ids(data: dict[str, Json], monster_id: str) -> None:
"""Auto-assign attack ids from file stem if missing."""
attacks = data.get("attacks")
@@ -284,7 +303,7 @@ def load_combatant(path: Path) -> Combatant:
raw_slots = data.pop("spell_slots", None)
if isinstance(raw_slots, dict):
validate_payload["spell_slots"] = {int(k): v for k, v in raw_slots.items()}
validate_payload["spell_slots"] = _parse_spell_slot_keys(raw_slots, path)
try:
return Combatant.model_validate(validate_payload)