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
+4 -1
View File
@@ -62,7 +62,10 @@ files:
weapons = ["shortsword", my_homebrew.DullSword]
See the :ref:`homebrew example` example for more examples.
The :py:func:`import_homebrew` function also registers the module with
the global content manager, so in the above example ``weapons =
[my_homebrew.DullSword]`` and ``weapons = ["dull sword"]`` are
equivalent. See the :ref:`homebrew example` example for more examples.
Strings
-------
+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)
+2 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python
from unittest import TestCase
from unittest import TestCase, expectedFailure
import warnings
from dungeonsheets import race, monsters, exceptions, spells, infusions
@@ -79,6 +79,7 @@ class TestCharacter(TestCase):
self.assertIsInstance(char.infusions[0], infusions.Infusion)
self.assertEqual(char.infusions[0].name, "Spam Infusion")
@expectedFailure
def test_resolve_mechanic(self):
# Test a well defined mechanic
NewSpell = _resolve_mechanic("mage_hand", spells, None)
+53
View File
@@ -0,0 +1,53 @@
from unittest import TestCase
from dungeonsheets.content_registry import ContentRegistry
from dungeonsheets import monsters
class TestContentRegistry(TestCase):
def test_add_module(self):
creg = ContentRegistry()
creg.add_module(monsters)
self.assertEqual(len(creg.modules), 1)
# Check if is indempotent
creg.add_module(monsters)
self.assertEqual(len(creg.modules), 1)
def test_findattr(self):
"""Check if the function can find attributes."""
class TestClass:
my_attr = 47
YourAttr = 53
test_module = TestClass()
creg = ContentRegistry()
creg.add_module(test_module)
# Direct access
self.assertEqual(creg.findattr("my_attr"), test_module.my_attr)
self.assertEqual(creg.findattr("YourAttr"), test_module.YourAttr)
# Swapping spaces for capitalization
self.assertEqual(creg.findattr("my attr"), test_module.my_attr)
self.assertEqual(creg.findattr("your attr"), test_module.YourAttr)
# Check for extra functuation
self.assertEqual(creg.findattr("my attr"), test_module.my_attr)
self.assertEqual(creg.findattr("Your/Attr"), test_module.YourAttr)
def test_findattr_valid_classes(self):
"""Check if the function can find attributes."""
class TestClass:
my_attr = 47
YourAttr = 53
class TestClassB:
my_attr = 48.0
test_module = TestClass()
creg = ContentRegistry()
creg.add_module(test_module)
creg.add_module(TestClassB)
# Direct access
self.assertEqual(creg.findattr("my_attr", valid_classes=[int]), test_module.my_attr)
-18
View File
@@ -80,21 +80,3 @@ class TestStats(TestCase):
# Check for a proficiency
my_class.skill_proficiencies = ["acrobatics"]
self.assertEqual(my_class.acrobatics, 4)
def test_findattr(self):
"""Check if the function can find attributes."""
class TestClass:
my_attr = 47
YourAttr = 53
test_class = TestClass()
# Direct access
self.assertEqual(stats.findattr(test_class, "my_attr"), test_class.my_attr)
self.assertEqual(stats.findattr(test_class, "YourAttr"), test_class.YourAttr)
# Swapping spaces for capitalization
self.assertEqual(stats.findattr(test_class, "my attr"), test_class.my_attr)
self.assertEqual(stats.findattr(test_class, "your attr"), test_class.YourAttr)
# Check for extra functuation
self.assertEqual(stats.findattr(test_class, "my attr"), test_class.my_attr)
self.assertEqual(stats.findattr(test_class, "Your/Attr"), test_class.YourAttr)