Files
dungeon-sheets/dungeonsheets/readers.py
T
Mark Wolfman 30369ce1d4 Ran flake8 and black linters, and other cleanup-related fixes.
Project now passes flake8 and black linter (also including more rst
cleanup). Moved latex related things to dedicated ``latex.py`` module,
and removed the ``makesheets -dF`` call from travis.
2021-04-16 11:10:17 -05:00

320 lines
11 KiB
Python

import importlib
import warnings
import json
import re
from functools import lru_cache
import logging
from pathlib import Path
from dungeonsheets import exceptions
log = logging.getLogger(__file__)
def read_character_file(filename: str):
"""Create a character object from the given definition file.
The definition file should be an importable python file or a JSON
file following one of the supported formats, filled with variables
describing the character.
Parameters
----------
filename
The path to the file that will be imported.
"""
filename = Path(filename)
# Parse the file name
ext = filename.suffix
try:
reader = readers_by_extension[ext]()
except KeyError:
raise ValueError(f"Character definition {filename} is not a known file type.")
else:
new_char = reader(filename=filename)
return new_char
class BaseCharacterReader:
"""Callable to parse a generic character file. Meant to be subclassed."""
def __call__(self, filename):
"""
Parameters
----------
filename
The path to the file that will be imported.
"""
raise NotImplementedError()
class JSONCharacterReader(BaseCharacterReader):
"""Callable to parse a JSON character file from Roll20 VVTES.
The definition file should be a JSON file following one of the
supported formats, filled with variables describing the character.
Parameters
----------
filename
The path to the file that will be imported.
"""
@lru_cache()
def json_data(self):
# Load the JSON data from disk
with open(self.filename, mode="r") as fp:
data = json.load(fp)
return data
def as_int(self, val):
try:
val = int(val)
except ValueError:
val = 0
return val
def get_attrib(self, key, which="current", default=None):
for obj in self.json_data()["attribs"]:
if obj["name"] == key:
val = obj[which]
return val
# No object was found
if default is not None:
return default
else:
raise KeyError(key)
def has_skill_proficiency(self, key):
false_profs = ["", "0"]
return self.get_attrib(key=f"{key}_prof") not in false_profs
def spells(self, prepared: bool = False):
"""Iterator over the spells the character knows.
Parameters
==========
prepared
If true, only return prepared spells.
"""
# "name": "repeating_spell-cantrip_-MEzYWPA5cUZYd4ZOvMS_spellname",
prof_re = re.compile(
"repeating_spell-(cantrip|[0-9]+)_([-0-9a-zA-Z]+)_spellname"
)
for obj in self.json_data()["attribs"]:
match = prof_re.match(obj["name"])
if match:
level = match.group(1)
spell_id = match.group(2)
spell_name = self.get_attrib(
f"repeating_spell-{level}_{spell_id}_spellname"
)
is_prepared = self.as_int(
self.get_attrib(
f"repeating_spell-{level}_{spell_id}_spellprepared", default=0
)
)
if not prepared or is_prepared:
yield spell_name.lower()
def proficiencies(self, kind=None):
"""Iterator over the skills in which the character is proficient.
*kind* can be one of "weapon", "language", or None (all
proficiencies).
"""
prof_re = re.compile("repeating_proficiencies_([-0-9a-zA-Z]+)_name")
for obj in self.json_data()["attribs"]:
match = prof_re.match(obj["name"])
if match:
prof_id = match.group(1)
prof_type = self.get_attrib(
f"repeating_proficiencies_{prof_id}_prof_type"
)
if kind is None or prof_type == kind.upper():
yield self.get_attrib(
f"repeating_proficiencies_{prof_id}_name"
).lower()
def equipment(self, kind=None):
"""Iterator over items in the character's inventory."""
prof_re = re.compile("repeating_inventory_([-0-9a-zA-Z]+)_itemname")
for obj in self.json_data()["attribs"]:
match = prof_re.match(obj["name"])
if match:
item_id = match.group(1)
item_name = self.get_attrib(match.group(0))
item_count = int(
self.get_attrib(
f"repeating_inventory_{item_id}_itemcount", default=1
)
)
# item_weight = self.get_attrib(
# f"repeating_inventory_{item_id}_itemweight", default=0
# )
item_str = item_name.lower().strip()
if item_count > 1:
item_str += f" ({item_count})"
yield item_str
def weapons(self):
"""Iterator over the weapons the character is carrying in her inventory."""
item_re = re.compile("repeating_attack_([-0-9a-zA-Z]+)_atkname")
for obj in self.json_data()["attribs"]:
match = item_re.match(obj["name"])
if match:
weapon_name = self.get_attrib(match.group()).lower()
weapon_name = weapon_name.split("(")[0].strip()
weapon_name = weapon_name.split(",")[0].strip()
yield weapon_name
def __call__(self, filename: str):
"""Create a character property dictionary from the JSON file."""
# Verify the version compatibility
self.filename = filename
version = self.json_data()["schema_version"]
if version != 2:
raise exceptions.JSONFormatError(
"Cannot parse JSON schema version: %s" % version
)
# Parse the json tree to get character properties
char_props = {}
char_props["name"] = self.json_data()["name"]
char_props["level"] = self.as_int(self.get_attrib("base_level"))
char_props["classes"] = [self.get_attrib("class")]
char_props["background"] = self.get_attrib("background")
char_props["race"] = self.get_attrib("subrace")
char_props["alignment"] = self.get_attrib("alignment")
char_props["xp"] = self.as_int(self.get_attrib("experience", default=0))
# Attributes
attribute_names = [
"strength",
"dexterity",
"constitution",
"intelligence",
"wisdom",
"charisma",
]
for attr in attribute_names:
char_props[attr] = self.as_int(self.get_attrib(f"{attr}_base"))
# Skill proficiencies
skill_names = [
"acrobatics",
"animal_handling",
"arcana",
"athletics",
"deception",
"history",
"insight",
"intimidation",
"investigation",
"medicine",
"nature",
"perception",
"performance",
"persuasion",
"religion",
"sleight_of_hand",
"stealth",
"survival",
]
skill_profs = [
skill for skill in skill_names if self.has_skill_proficiency(skill)
]
char_props["skill_proficiencies"] = skill_profs
# Other proficiencies
char_props["weapon_proficiencies"] = self.proficiencies("weapon")
char_props["languages"] = ", ".join(self.proficiencies("language"))
# Tool proficiencies
prof_re = re.compile("repeating_tool_([-0-9a-zA-Z]+)_toolname")
tool_profs = []
for obj in self.json_data()["attribs"]:
match = prof_re.match(obj["name"])
if match:
tool_profs.append(self.get_attrib(match.group(0)))
char_props["_proficiencies_text"] = tool_profs
# Combat stats
char_props["hp_max"] = self.as_int(self.get_attrib("hp", which="max"))
# Equipment
char_props["cp"] = self.as_int(self.get_attrib("cp", default=0))
char_props["sp"] = self.as_int(self.get_attrib("sp", default=0))
char_props["ep"] = self.as_int(self.get_attrib("ep", default=0))
char_props["gp"] = self.as_int(self.get_attrib("gp", default=0))
char_props["pp"] = self.as_int(self.get_attrib("pp", default=0))
char_props["weapons"] = self.weapons()
char_props["equipment"] = ", ".join(self.equipment())
# Personality, etc
char_props["personality_traits"] = self.get_attrib("personality_traits").strip()
char_props["flaws"] = self.get_attrib("flaws").strip()
char_props["ideals"] = self.get_attrib("ideals").strip()
char_props["bonds"] = self.get_attrib("bonds").strip()
# Spells
char_props["spells"] = self.spells()
char_props["spells_prepared"] = self.spells(prepared=True)
# Some unused values
warn_msg = (
"Importing the following traits from JSON is not yet supported: "
"magic_items, armor, shield, attacks_and_spellcasting, "
"infusions, wild_shapes"
)
warnings.warn(warn_msg)
log.warning(warn_msg)
char_props["magic_items"] = ()
char_props["armor"] = ""
char_props["shield"] = ""
char_props["attacks_and_spellcasting"] = ""
char_props["infusions"] = []
char_props["wild_shapes"] = []
return char_props
class PythonCharacterReader(BaseCharacterReader):
def __call__(self, filename: str):
"""Create a character object from the given definition file.
The definition file should be an importable python file, filled
with variables describing the character.
Parameters
----------
filename
The path to the file that will be imported.
"""
# Check if this file contains the version string
version_re = re.compile(r'dungeonsheets_version\s*=\s*[\'"]([0-9.]+)[\'"]')
with open(filename, mode="r") as f:
version = None
for line in f:
match = version_re.match(line)
if match:
version = match.group(1)
break
if version is None:
# Not a valid DND character file
raise exceptions.CharacterFileFormatError(
f"No ``dungeonsheets_version = `` entry in `{filename}`."
)
# Import the module to extract the information
spec = importlib.util.spec_from_file_location("module", filename)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Prepare a list of properties for this character
char_props = {}
for prop_name in dir(module):
if prop_name[0:2] != "__":
char_props[prop_name] = getattr(module, prop_name)
return char_props
readers_by_extension = {
".py": PythonCharacterReader,
".json": JSONCharacterReader,
}