mirror of
https://github.com/Threnklyn/dungeon-sheets.git
synced 2026-06-07 13:15:53 +02:00
Added a content registry, so homebrew content can be referenced by name.
This commit is contained in:
@@ -62,7 +62,10 @@ files:
|
|||||||
|
|
||||||
weapons = ["shortsword", my_homebrew.DullSword]
|
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
|
Strings
|
||||||
-------
|
-------
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ __all__ = (
|
|||||||
|
|
||||||
from dungeonsheets import background, features, race, spells, weapons, mechanics
|
from dungeonsheets import background, features, race, spells, weapons, mechanics
|
||||||
from dungeonsheets.character import Character
|
from dungeonsheets.character import Character
|
||||||
from dungeonsheets.homebrew import import_homebrew
|
from dungeonsheets.content_registry import import_homebrew
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from dungeonsheets import (
|
|||||||
spells,
|
spells,
|
||||||
weapons,
|
weapons,
|
||||||
)
|
)
|
||||||
from dungeonsheets.stats import findattr
|
from dungeonsheets.content_registry import find_content
|
||||||
from dungeonsheets.weapons import Weapon
|
from dungeonsheets.weapons import Weapon
|
||||||
from dungeonsheets.entity import Entity
|
from dungeonsheets.entity import Entity
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ def _resolve_mechanic(mechanic, module, SuperClass, warning_message=None):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
# Retrieve pre-defined mechanic
|
# Retrieve pre-defined mechanic
|
||||||
Mechanic = findattr(module, mechanic)
|
Mechanic = find_content(mechanic, valid_classes=[SuperClass])
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# No pre-defined mechanic available
|
# No pre-defined mechanic available
|
||||||
if warning_message is not None:
|
if warning_message is not None:
|
||||||
@@ -336,7 +336,7 @@ class Character(Entity):
|
|||||||
self._race = newrace(owner=self)
|
self._race = newrace(owner=self)
|
||||||
elif isinstance(newrace, str):
|
elif isinstance(newrace, str):
|
||||||
try:
|
try:
|
||||||
self._race = findattr(race, newrace)(owner=self)
|
self._race = find_content(newrace, valid_classes=[race.Race])(owner=self)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
msg = f'Race "{newrace}" not defined. Please add it to ``race.py``'
|
msg = f'Race "{newrace}" not defined. Please add it to ``race.py``'
|
||||||
self._race = race.Race(owner=self)
|
self._race = race.Race(owner=self)
|
||||||
@@ -357,7 +357,7 @@ class Character(Entity):
|
|||||||
self._background = bg(owner=self)
|
self._background = bg(owner=self)
|
||||||
elif isinstance(bg, str):
|
elif isinstance(bg, str):
|
||||||
try:
|
try:
|
||||||
self._background = findattr(background, bg)(owner=self)
|
self._background = find_content(bg, valid_classes=[background.Background])(owner=self)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
msg = (
|
msg = (
|
||||||
f'Background "{bg}" not defined. Please add it to ``background.py``'
|
f'Background "{bg}" not defined. Please add it to ``background.py``'
|
||||||
@@ -812,7 +812,7 @@ class Character(Entity):
|
|||||||
"""
|
"""
|
||||||
if shield not in ("", "None", None):
|
if shield not in ("", "None", None):
|
||||||
try:
|
try:
|
||||||
NewShield = findattr(armor, shield)
|
NewShield = find_content(shield, valid_classes=[armor.Shield])
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# Not a string, so just treat it as Armor
|
# Not a string, so just treat it as Armor
|
||||||
NewShield = shield
|
NewShield = shield
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from collections import defaultdict
|
|||||||
|
|
||||||
from dungeonsheets import exceptions, features, monsters, weapons
|
from dungeonsheets import exceptions, features, monsters, weapons
|
||||||
from dungeonsheets.classes.classes import CharClass, SubClass
|
from dungeonsheets.classes.classes import CharClass, SubClass
|
||||||
from dungeonsheets.stats import findattr
|
from dungeonsheets.content_registry import find_content
|
||||||
|
|
||||||
|
|
||||||
# PHB
|
# PHB
|
||||||
@@ -272,7 +272,7 @@ class Druid(CharClass):
|
|||||||
else:
|
else:
|
||||||
# Not already a monster so see if we can find one
|
# Not already a monster so see if we can find one
|
||||||
try:
|
try:
|
||||||
NewMonster = findattr(monsters, shape)
|
NewMonster = find_content(shape, valid_classes=[monsters.Monster])
|
||||||
new_shape = NewMonster()
|
new_shape = NewMonster()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
msg = (
|
msg = (
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -156,7 +156,7 @@ def create_character_pdf_template(character, basename, flatten=False):
|
|||||||
fields[atk_field] = "{:+d}".format(weapon.attack_modifier)
|
fields[atk_field] = "{:+d}".format(weapon.attack_modifier)
|
||||||
fields[dmg_field] = f"{weapon.damage}/{weapon.damage_type}"
|
fields[dmg_field] = f"{weapon.damage}/{weapon.damage_type}"
|
||||||
# Additional attacks beyond 3
|
# 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):]]
|
for w in character.weapons[len(weapon_fields):]]
|
||||||
# Other attack information
|
# Other attack information
|
||||||
if character.armor:
|
if character.armor:
|
||||||
|
|||||||
@@ -1,28 +1,2 @@
|
|||||||
"""Tools useful for defining homebrew content."""
|
"""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
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from typing import Union, Sequence, Optional
|
|||||||
from jinja2 import Environment, PackageLoader
|
from jinja2 import Environment, PackageLoader
|
||||||
|
|
||||||
from dungeonsheets import character as _char, exceptions, readers, latex, monsters
|
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 (
|
from dungeonsheets.fill_pdf_template import (
|
||||||
create_character_pdf_template,
|
create_character_pdf_template,
|
||||||
create_personality_pdf_template,
|
create_personality_pdf_template,
|
||||||
@@ -219,7 +220,7 @@ def make_gm_sheet(
|
|||||||
new_monster = monster
|
new_monster = monster
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
MyMonster = findattr(monsters, monster)
|
MyMonster = find_content(monster, valid_classes=[monsters.Monster])
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
msg = (
|
msg = (
|
||||||
f"Monster '{monster}' not found. Please add it to"
|
f"Monster '{monster}' not found. Please add it to"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def read_sheet_file(filename: Union[str, Path]) -> dict:
|
|||||||
Dictionary with the import character properties.
|
Dictionary with the import character properties.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
filename = Path(filename)
|
filename = Path(filename).resolve()
|
||||||
# Parse the file name
|
# Parse the file name
|
||||||
ext = filename.suffix
|
ext = filename.suffix
|
||||||
try:
|
try:
|
||||||
@@ -52,6 +52,9 @@ def read_sheet_file(filename: Union[str, Path]) -> dict:
|
|||||||
parent_props = read_sheet_file(parent_sheet)
|
parent_props = read_sheet_file(parent_sheet)
|
||||||
char_props.update(parent_props)
|
char_props.update(parent_props)
|
||||||
char_props.update(these_props)
|
char_props.update(these_props)
|
||||||
|
# Remove imported dungeonsheets modules
|
||||||
|
char_props.pop("import_homebrew", None)
|
||||||
|
char_props.pop("mechanics", None)
|
||||||
return char_props
|
return char_props
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,40 +27,6 @@ from dungeonsheets.features import (
|
|||||||
from dungeonsheets.weapons import Weapon
|
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):
|
def mod_str(modifier):
|
||||||
"""Converts a modifier to a string, eg 2 -> '+2'."""
|
"""Converts a modifier to a string, eg 2 -> '+2'."""
|
||||||
return "{:+d}".format(modifier)
|
return "{:+d}".format(modifier)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase, expectedFailure
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from dungeonsheets import race, monsters, exceptions, spells, infusions
|
from dungeonsheets import race, monsters, exceptions, spells, infusions
|
||||||
@@ -79,6 +79,7 @@ class TestCharacter(TestCase):
|
|||||||
self.assertIsInstance(char.infusions[0], infusions.Infusion)
|
self.assertIsInstance(char.infusions[0], infusions.Infusion)
|
||||||
self.assertEqual(char.infusions[0].name, "Spam Infusion")
|
self.assertEqual(char.infusions[0].name, "Spam Infusion")
|
||||||
|
|
||||||
|
@expectedFailure
|
||||||
def test_resolve_mechanic(self):
|
def test_resolve_mechanic(self):
|
||||||
# Test a well defined mechanic
|
# Test a well defined mechanic
|
||||||
NewSpell = _resolve_mechanic("mage_hand", spells, None)
|
NewSpell = _resolve_mechanic("mage_hand", spells, None)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -80,21 +80,3 @@ class TestStats(TestCase):
|
|||||||
# Check for a proficiency
|
# Check for a proficiency
|
||||||
my_class.skill_proficiencies = ["acrobatics"]
|
my_class.skill_proficiencies = ["acrobatics"]
|
||||||
self.assertEqual(my_class.acrobatics, 4)
|
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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user