Added a content registry, so homebrew content can be referenced by name.

This commit is contained in:
Mark Wolfman
2021-06-13 14:29:57 -05:00
parent 1abfcaf657
commit cc430720fb
13 changed files with 200 additions and 92 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ __all__ = (
from dungeonsheets import background, features, race, spells, weapons, mechanics
from dungeonsheets.character import Character
from dungeonsheets.homebrew import import_homebrew
from dungeonsheets.content_registry import import_homebrew
import os
+5 -5
View File
@@ -20,7 +20,7 @@ from dungeonsheets import (
spells,
weapons,
)
from dungeonsheets.stats import findattr
from dungeonsheets.content_registry import find_content
from dungeonsheets.weapons import Weapon
from dungeonsheets.entity import Entity
@@ -120,7 +120,7 @@ def _resolve_mechanic(mechanic, module, SuperClass, warning_message=None):
else:
try:
# Retrieve pre-defined mechanic
Mechanic = findattr(module, mechanic)
Mechanic = find_content(mechanic, valid_classes=[SuperClass])
except AttributeError:
# No pre-defined mechanic available
if warning_message is not None:
@@ -336,7 +336,7 @@ class Character(Entity):
self._race = newrace(owner=self)
elif isinstance(newrace, str):
try:
self._race = findattr(race, newrace)(owner=self)
self._race = find_content(newrace, valid_classes=[race.Race])(owner=self)
except AttributeError:
msg = f'Race "{newrace}" not defined. Please add it to ``race.py``'
self._race = race.Race(owner=self)
@@ -357,7 +357,7 @@ class Character(Entity):
self._background = bg(owner=self)
elif isinstance(bg, str):
try:
self._background = findattr(background, bg)(owner=self)
self._background = find_content(bg, valid_classes=[background.Background])(owner=self)
except AttributeError:
msg = (
f'Background "{bg}" not defined. Please add it to ``background.py``'
@@ -812,7 +812,7 @@ class Character(Entity):
"""
if shield not in ("", "None", None):
try:
NewShield = findattr(armor, shield)
NewShield = find_content(shield, valid_classes=[armor.Shield])
except AttributeError:
# Not a string, so just treat it as Armor
NewShield = shield
+2 -2
View File
@@ -4,7 +4,7 @@ from collections import defaultdict
from dungeonsheets import exceptions, features, monsters, weapons
from dungeonsheets.classes.classes import CharClass, SubClass
from dungeonsheets.stats import findattr
from dungeonsheets.content_registry import find_content
# PHB
@@ -272,7 +272,7 @@ class Druid(CharClass):
else:
# Not already a monster so see if we can find one
try:
NewMonster = findattr(monsters, shape)
NewMonster = find_content(shape, valid_classes=[monsters.Monster])
new_shape = NewMonster()
except AttributeError:
msg = (
+125
View File
@@ -0,0 +1,125 @@
from pathlib import Path
from functools import lru_cache
import importlib.util
from typing import Union, List, Optional
from dungeonsheets import weapons, monsters, race, background, armor, spells, infusions, magic_items
class ContentRegistry():
modules = None
def __init__(self):
self.modules = []
def add_module(self, new_module):
if new_module not in self.modules:
self.modules.append(new_module)
def findattr(self, name, valid_classes=[]):
"""Resolve the name of a piece of content to the corresponding Class.
Similar to builtin getattr(obj, name) but more forgiving to
whitespace and capitalization.
valid_classes
If given, only subclasses of classes in this list will be
returned.
"""
# Come up with several options
name = name.strip()
# check for +X weapons, armor, shields
bonus = 0
for i in range(1, 11):
if (f"+{i}" in name) or (f"+ {i}" in name):
bonus = i
name = name.replace(f"+{i}", "").replace(f"+ {i}", "")
break
py_name = name.replace("-", "_").replace(" ", "_").replace("'", "").replace("/", "")
camel_case = "".join([s.capitalize() for s in py_name.split("_")])
# Check each module in the registry
found_attrs = []
for module in self.modules:
if hasattr(module, py_name):
# Direct lookup
found_attrs.append(getattr(module, py_name))
elif hasattr(module, camel_case):
# CamelCase lookup
found_attrs.append(getattr(module, camel_case))
# Filter by valid classes
if len(valid_classes) > 0:
is_valid = [False for attr in found_attrs]
for cls in valid_classes:
is_valid = [v or isinstance(attr, cls) or (isinstance(attr, type) and issubclass(attr, cls)) for v, attr in zip(is_valid, found_attrs)]
found_attrs = [attr for attr, v in zip(found_attrs, is_valid) if v]
# Check that we found a valid, unique attribute
if len(found_attrs) == 0:
raise AttributeError(f"Modules {self.modules} have no attribute {name}")
elif len(found_attrs) > 1:
raise RuntimeError(f"Found multiple content entries for {name}")
else:
attr = found_attrs[0]
# Apply weapon/etc. bonuses
if bonus > 0:
if (
issubclass(attr, weapons.Weapon)
or issubclass(attr, armor.Shield)
or issubclass(attr, armor.Armor)
):
attr = attr.improved_version(bonus)
return attr
default_content_registry = ContentRegistry()
default_content_registry.add_module(weapons)
default_content_registry.add_module(monsters)
default_content_registry.add_module(race)
default_content_registry.add_module(background)
default_content_registry.add_module(armor)
default_content_registry.add_module(spells)
default_content_registry.add_module(infusions)
default_content_registry.add_module(magic_items)
def find_content(name: str, valid_classes: Optional[List]):
"""Find content from a previously registered module.
Parameters
==========
name
The name of the item to find. Can be "CamelCase", "under_case",
or "lower case".
valid_classes
A list of parent classes to look for. If ``None`` or ``[]``, all
classes will be considered valid.
"""
if valid_classes is None:
valid_classes = []
return default_content_registry.findattr(name, valid_classes=valid_classes)
@lru_cache
def import_homebrew(filepath: Union[str, Path]):
"""Import a module file containing homebrew content.
This is intended to be used in a character/GM sheet to load in
homebrew content defined in an external file.
Parameters
==========
filepath
The location of the python file containing the homebrew content.
Returns
=======
mod
The imported module of homebrew content.
"""
spec = importlib.util.spec_from_file_location("module.name", filepath)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
default_content_registry.add_module(mod)
return mod
+1 -1
View File
@@ -156,7 +156,7 @@ def create_character_pdf_template(character, basename, flatten=False):
fields[atk_field] = "{:+d}".format(weapon.attack_modifier)
fields[dmg_field] = f"{weapon.damage}/{weapon.damage_type}"
# Additional attacks beyond 3
attack = [f"{w.name}: Atk {weapon.attack_modifier:+d}, Dam {weapon.damage}/{weapon.damage_type}"
attack = [f"{w.name}: Atk {w.attack_modifier:+d}, Dam {w.damage}/{w.damage_type}"
for w in character.weapons[len(weapon_fields):]]
# Other attack information
if character.armor:
-26
View File
@@ -1,28 +1,2 @@
"""Tools useful for defining homebrew content."""
from pathlib import Path
import importlib.util
from typing import Union
def import_homebrew(filepath: Union[str, Path]):
"""Import a module file containing homebrew content.
This is intended to be used in a character/GM sheet to load in
homebrew content defined in an external file.
Parameters
==========
filepath
The location of the python file containing the homebrew content.
Returns
=======
mod
The imported module of homebrew content.
"""
spec = importlib.util.spec_from_file_location("module.name", filepath)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
+3 -2
View File
@@ -14,7 +14,8 @@ from typing import Union, Sequence, Optional
from jinja2 import Environment, PackageLoader
from dungeonsheets import character as _char, exceptions, readers, latex, monsters
from dungeonsheets.stats import mod_str, findattr
from dungeonsheets.stats import mod_str
from dungeonsheets.content_registry import find_content
from dungeonsheets.fill_pdf_template import (
create_character_pdf_template,
create_personality_pdf_template,
@@ -219,7 +220,7 @@ def make_gm_sheet(
new_monster = monster
else:
try:
MyMonster = findattr(monsters, monster)
MyMonster = find_content(monster, valid_classes=[monsters.Monster])
except AttributeError:
msg = (
f"Monster '{monster}' not found. Please add it to"
+4 -1
View File
@@ -34,7 +34,7 @@ def read_sheet_file(filename: Union[str, Path]) -> dict:
Dictionary with the import character properties.
"""
filename = Path(filename)
filename = Path(filename).resolve()
# Parse the file name
ext = filename.suffix
try:
@@ -52,6 +52,9 @@ def read_sheet_file(filename: Union[str, Path]) -> dict:
parent_props = read_sheet_file(parent_sheet)
char_props.update(parent_props)
char_props.update(these_props)
# Remove imported dungeonsheets modules
char_props.pop("import_homebrew", None)
char_props.pop("mechanics", None)
return char_props
-34
View File
@@ -27,40 +27,6 @@ from dungeonsheets.features import (
from dungeonsheets.weapons import Weapon
def findattr(obj, name):
"""Similar to builtin getattr(obj, name) but more forgiving to
whitespace and capitalization.
"""
# Come up with several options
name = name.strip()
# check for +X weapons, armor, shields
bonus = 0
for i in range(1, 11):
if (f"+{i}" in name) or (f"+ {i}" in name):
bonus = i
name = name.replace(f"+{i}", "").replace(f"+ {i}", "")
break
py_name = name.replace("-", "_").replace(" ", "_").replace("'", "").replace("/", "")
camel_case = "".join([s.capitalize() for s in py_name.split("_")])
if hasattr(obj, py_name):
# Direct lookup
attr = getattr(obj, py_name)
elif hasattr(obj, camel_case):
# CamelCase lookup
attr = getattr(obj, camel_case)
else:
raise AttributeError(f"{obj} has no attribute {name}")
if bonus > 0:
if (
issubclass(attr, Weapon)
or issubclass(attr, Shield)
or issubclass(attr, Armor)
):
attr = attr.improved_version(bonus)
return attr
def mod_str(modifier):
"""Converts a modifier to a string, eg 2 -> '+2'."""
return "{:+d}".format(modifier)