Make objects iterable by default, add tests, refactoring
This commit is contained in:
parent
44cd8fe9c9
commit
412efe2aec
|
@ -36,11 +36,14 @@ HOST={default_host}
|
|||
PORT={default_port}
|
||||
"""
|
||||
|
||||
db_app = typer.Typer()
|
||||
app = typer.Typer()
|
||||
app.add_typer(db_app, name="db", help="Manage the database.")
|
||||
app_state = dict()
|
||||
|
||||
|
||||
@app.callback()
|
||||
@db_app.callback()
|
||||
def main(
|
||||
context: typer.Context,
|
||||
root: Optional[Path] = typer.Option(
|
||||
|
@ -59,20 +62,6 @@ def main(
|
|||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def setup(context: typer.Context):
|
||||
"""
|
||||
(Re)Initialize TableTop Frog. Idempotent; will preserve any existing configuration.
|
||||
"""
|
||||
from ttfrog.db.bootstrap import bootstrap
|
||||
|
||||
if not os.path.exists(app_state["env"]):
|
||||
app_state["env"].parent.mkdir(parents=True, exist_ok=True)
|
||||
app_state["env"].write_text(dedent(SETUP_HELP))
|
||||
print(f"Wrote defaults file {app_state['env']}.")
|
||||
bootstrap()
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
context: typer.Context,
|
||||
|
@ -99,5 +88,35 @@ def serve(
|
|||
application.start(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
@db_app.command()
|
||||
def setup(context: typer.Context):
|
||||
"""
|
||||
(Re)Initialize TableTop Frog. Idempotent; will preserve any existing configuration.
|
||||
"""
|
||||
from ttfrog.db.bootstrap import bootstrap
|
||||
|
||||
if not os.path.exists(app_state["env"]):
|
||||
app_state["env"].parent.mkdir(parents=True, exist_ok=True)
|
||||
app_state["env"].write_text(dedent(SETUP_HELP))
|
||||
print(f"Wrote defaults file {app_state['env']}.")
|
||||
bootstrap()
|
||||
|
||||
|
||||
@db_app.command()
|
||||
def list(context: typer.Context):
|
||||
from ttfrog.db.manager import db
|
||||
print("\n".join(sorted(db.tables.keys())))
|
||||
|
||||
|
||||
@db_app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
||||
def dump(context: typer.Context):
|
||||
"""
|
||||
Dump tables (or the entire database) as a JSON blob.
|
||||
"""
|
||||
from ttfrog.db.manager import db
|
||||
db.init()
|
||||
print(db.dump(context.args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
|
@ -2,7 +2,7 @@ import enum
|
|||
|
||||
import nanoid
|
||||
from nanoid_dictionary import human_alphabet
|
||||
from pyramid_sqlalchemy import BaseObject
|
||||
from pyramid_sqlalchemy import BaseObject as _BaseObject
|
||||
from slugify import slugify
|
||||
from sqlalchemy import Column, String
|
||||
|
||||
|
@ -19,10 +19,11 @@ class SlugMixin:
|
|||
return "-".join([self.slug, slugify(self.name.title().replace(" ", ""), ok="", only_ascii=True, lower=False)])
|
||||
|
||||
|
||||
class IterableMixin:
|
||||
class BaseObject(_BaseObject):
|
||||
"""
|
||||
Allows for iterating over Model objects' column names and values
|
||||
"""
|
||||
__abstract__ = True
|
||||
|
||||
def __iter__(self):
|
||||
values = vars(self)
|
||||
|
@ -42,14 +43,11 @@ class IterableMixin:
|
|||
relvals.append(rel)
|
||||
yield relname, relvals
|
||||
|
||||
def __json__(self, request):
|
||||
serialized = dict()
|
||||
for key, value in self:
|
||||
try:
|
||||
serialized[key] = getattr(self.value, "__json__")(request)
|
||||
except AttributeError:
|
||||
serialized[key] = value
|
||||
return serialized
|
||||
def __json__(self):
|
||||
"""
|
||||
Provide a custom JSON encoder.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __repr__(self):
|
||||
return str(dict(self))
|
||||
|
@ -90,7 +88,7 @@ class EnumField(enum.Enum):
|
|||
A serializable enum.
|
||||
"""
|
||||
|
||||
def __json__(self, request):
|
||||
def __json__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
|
@ -116,6 +114,3 @@ CREATURE_TYPES = [
|
|||
]
|
||||
CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
|
||||
StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS))
|
||||
|
||||
# class Table(*Bases):
|
||||
Bases = [BaseObject, IterableMixin, SlugMixin]
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from functools import cached_property
|
||||
|
@ -9,13 +10,19 @@ from pyramid_sqlalchemy import Session, init_sqlalchemy
|
|||
from pyramid_sqlalchemy import metadata as _metadata
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
|
||||
import ttfrog.db.schema
|
||||
from ttfrog.path import database
|
||||
|
||||
# from sqlalchemy.exc import IntegrityError
|
||||
assert ttfrog.db.schema
|
||||
|
||||
|
||||
ttfrog.db.schema
|
||||
class AlchemyEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
try:
|
||||
return getattr(obj, '__json__')()
|
||||
except (AttributeError, NotImplementedError): # pragma: no cover
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
class SQLDatabaseManager:
|
||||
|
@ -49,11 +56,11 @@ class SQLDatabaseManager:
|
|||
yield tm
|
||||
try:
|
||||
tm.commit()
|
||||
except Exception:
|
||||
except Exception: # pragam: no cover
|
||||
tm.abort()
|
||||
raise
|
||||
|
||||
def add(self, *args, **kwargs):
|
||||
def add_or_update(self, *args, **kwargs):
|
||||
self.session.add(*args, **kwargs)
|
||||
self.session.flush()
|
||||
|
||||
|
@ -71,11 +78,12 @@ class SQLDatabaseManager:
|
|||
init_sqlalchemy(self.engine)
|
||||
self.metadata.create_all(self.engine)
|
||||
|
||||
def dump(self):
|
||||
def dump(self, names: list = []):
|
||||
results = {}
|
||||
for table_name, table in self.tables.items():
|
||||
results[table_name] = [row for row in self.query(table).all()]
|
||||
return results
|
||||
if not names or table_name in names:
|
||||
results[table_name] = [dict(row._mapping) for row in self.query(table).all()]
|
||||
return json.dumps(results, indent=2, cls=AlchemyEncoder)
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
try:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from .character import *
|
||||
from .classes import *
|
||||
from .property import *
|
||||
from .transaction import *
|
||||
from .log import *
|
||||
|
|
|
@ -2,7 +2,7 @@ from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Text, UniqueCo
|
|||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject, Bases, CreatureTypesEnum, IterableMixin, SavingThrowsMixin, SkillsMixin
|
||||
from ttfrog.db.base import BaseObject, CreatureTypesEnum, SavingThrowsMixin, SkillsMixin, SlugMixin
|
||||
|
||||
__all__ = [
|
||||
"Ancestry",
|
||||
|
@ -28,6 +28,7 @@ def attr_map_creator(fields):
|
|||
|
||||
class AncestryTraitMap(BaseObject):
|
||||
__tablename__ = "trait_map"
|
||||
__table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"), )
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
ancestry_id = Column(Integer, ForeignKey("ancestry.id"))
|
||||
ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"))
|
||||
|
@ -35,7 +36,7 @@ class AncestryTraitMap(BaseObject):
|
|||
level = Column(Integer, nullable=False, info={"min": 1, "max": 20})
|
||||
|
||||
|
||||
class Ancestry(*Bases):
|
||||
class Ancestry(BaseObject):
|
||||
"""
|
||||
A character ancestry ("race"), which has zero or more AncestryTraits.
|
||||
"""
|
||||
|
@ -44,13 +45,13 @@ class Ancestry(*Bases):
|
|||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
creature_type = Column(Enum(CreatureTypesEnum))
|
||||
traits = relationship("AncestryTraitMap", lazy="immediate")
|
||||
_traits = relationship("AncestryTraitMap", lazy="immediate")
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class AncestryTrait(BaseObject, IterableMixin):
|
||||
class AncestryTrait(BaseObject):
|
||||
"""
|
||||
A trait granted to a character via its Ancestry.
|
||||
"""
|
||||
|
@ -64,12 +65,12 @@ class AncestryTrait(BaseObject, IterableMixin):
|
|||
return self.name
|
||||
|
||||
|
||||
class CharacterClassMap(BaseObject, IterableMixin):
|
||||
class CharacterClassMap(BaseObject):
|
||||
__tablename__ = "class_map"
|
||||
__table_args__ = (UniqueConstraint("character_id", "character_class_id"), )
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
|
||||
character_class_id = Column(Integer, ForeignKey("character_class.id"), nullable=False)
|
||||
mapping = UniqueConstraint(character_id, character_class_id)
|
||||
level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
|
||||
|
||||
character_class = relationship("CharacterClass", lazy="immediate")
|
||||
|
@ -79,13 +80,13 @@ class CharacterClassMap(BaseObject, IterableMixin):
|
|||
return "{self.character.name}, {self.character_class.name}, level {self.level}"
|
||||
|
||||
|
||||
class CharacterClassAttributeMap(BaseObject, IterableMixin):
|
||||
class CharacterClassAttributeMap(BaseObject):
|
||||
__tablename__ = "character_class_attribute_map"
|
||||
__table_args__ = (UniqueConstraint("character_id", "class_attribute_id"), )
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
|
||||
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
|
||||
option_id = Column(Integer, ForeignKey("class_attribute_option.id"), nullable=False)
|
||||
mapping = UniqueConstraint(character_id, class_attribute_id)
|
||||
|
||||
class_attribute = relationship("ClassAttribute", lazy="immediate")
|
||||
option = relationship("ClassAttributeOption", lazy="immediate")
|
||||
|
@ -100,7 +101,7 @@ class CharacterClassAttributeMap(BaseObject, IterableMixin):
|
|||
)
|
||||
|
||||
|
||||
class Character(*Bases, SavingThrowsMixin, SkillsMixin):
|
||||
class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin):
|
||||
__tablename__ = "character"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, default="New Character", nullable=False)
|
||||
|
@ -132,7 +133,7 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin):
|
|||
|
||||
@property
|
||||
def traits(self):
|
||||
return [mapping.trait for mapping in self.ancestry.traits]
|
||||
return [mapping.trait for mapping in self.ancestry._traits]
|
||||
|
||||
@property
|
||||
def level(self):
|
||||
|
@ -172,8 +173,11 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin):
|
|||
|
||||
def add_class_attribute(self, attribute, option):
|
||||
for thisclass in self.classes.values():
|
||||
# this test is failing?
|
||||
if attribute.name in thisclass.attributes_by_level.get(self.levels[thisclass.name], {}):
|
||||
current_level = self.levels[thisclass.name]
|
||||
current_attributes = thisclass.attributes_by_level.get(current_level, {})
|
||||
if attribute.name in current_attributes:
|
||||
if attribute.name in self.class_attributes:
|
||||
return True
|
||||
self.attribute_list.append(
|
||||
CharacterClassAttributeMap(
|
||||
character_id=self.id, class_attribute_id=attribute.id, option_id=option.id
|
||||
|
|
|
@ -3,7 +3,7 @@ from collections import defaultdict
|
|||
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject, Bases, IterableMixin, SavingThrowsMixin, SkillsMixin, StatsEnum
|
||||
from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin, StatsEnum
|
||||
|
||||
__all__ = [
|
||||
"ClassAttributeMap",
|
||||
|
@ -13,7 +13,7 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
class ClassAttributeMap(BaseObject, IterableMixin):
|
||||
class ClassAttributeMap(BaseObject):
|
||||
__tablename__ = "class_attribute_map"
|
||||
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True)
|
||||
character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True)
|
||||
|
@ -21,7 +21,7 @@ class ClassAttributeMap(BaseObject, IterableMixin):
|
|||
attribute = relationship("ClassAttribute", uselist=False, viewonly=True, lazy="immediate")
|
||||
|
||||
|
||||
class ClassAttribute(BaseObject, IterableMixin):
|
||||
class ClassAttribute(BaseObject):
|
||||
__tablename__ = "class_attribute"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, nullable=False)
|
||||
|
@ -31,14 +31,14 @@ class ClassAttribute(BaseObject, IterableMixin):
|
|||
return f"{self.id}: {self.name}"
|
||||
|
||||
|
||||
class ClassAttributeOption(BaseObject, IterableMixin):
|
||||
class ClassAttributeOption(BaseObject):
|
||||
__tablename__ = "class_attribute_option"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, nullable=False)
|
||||
attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
|
||||
|
||||
|
||||
class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin):
|
||||
class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
|
||||
__tablename__ = "character_class"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
from sqlalchemy import Column, Integer, String, Text
|
||||
|
||||
from ttfrog.db.base import BaseObject, IterableMixin
|
||||
from ttfrog.db.base import BaseObject
|
||||
|
||||
__all__ = ["TransactionLog"]
|
||||
|
||||
|
||||
class TransactionLog(BaseObject, IterableMixin):
|
||||
class TransactionLog(BaseObject):
|
||||
__tablename__ = "transaction_log"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source_table_name = Column(String, index=True, nullable=False)
|
|
@ -1,6 +1,6 @@
|
|||
from sqlalchemy import Column, Integer, String, Text, UniqueConstraint
|
||||
|
||||
from ttfrog.db.base import BaseObject, Bases, IterableMixin
|
||||
from ttfrog.db.base import BaseObject
|
||||
|
||||
__all__ = [
|
||||
"Skill",
|
||||
|
@ -9,7 +9,7 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
class Skill(*Bases):
|
||||
class Skill(BaseObject):
|
||||
__tablename__ = "skill"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
|
@ -19,7 +19,7 @@ class Skill(*Bases):
|
|||
return str(self.name)
|
||||
|
||||
|
||||
class Proficiency(*Bases):
|
||||
class Proficiency(BaseObject):
|
||||
__tablename__ = "proficiency"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
|
@ -28,7 +28,7 @@ class Proficiency(*Bases):
|
|||
return str(self.name)
|
||||
|
||||
|
||||
class Modifier(BaseObject, IterableMixin):
|
||||
class Modifier(BaseObject):
|
||||
__tablename__ = "modifier"
|
||||
__table_args__ = (UniqueConstraint("source_table_name", "source_table_id", "value", "type", "target"),)
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
|
2
test/fixtures/ancestry.json
vendored
2
test/fixtures/ancestry.json
vendored
|
@ -13,7 +13,7 @@
|
|||
"AncestryTraitMap": [
|
||||
{"ancestry_id": 1, "ancestry_trait_id": 1, "level": 1},
|
||||
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1},
|
||||
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1},
|
||||
{"ancestry_id": 2, "ancestry_trait_id": 3, "level": 1},
|
||||
{"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import json
|
||||
from ttfrog.db import schema
|
||||
|
||||
|
||||
def test_create_character(db, classes_factory, ancestries_factory):
|
||||
def test_manage_character(db, classes_factory, ancestries_factory):
|
||||
with db.transaction():
|
||||
# load the fixtures so they are bound to the current session
|
||||
classes = classes_factory()
|
||||
|
@ -10,7 +11,7 @@ def test_create_character(db, classes_factory, ancestries_factory):
|
|||
|
||||
# create a human character (the default)
|
||||
char = schema.Character(name="Test Character")
|
||||
db.add(char)
|
||||
db.add_or_update(char)
|
||||
assert char.id == 1
|
||||
assert char.armor_class == 10
|
||||
assert char.name == "Test Character"
|
||||
|
@ -19,14 +20,14 @@ def test_create_character(db, classes_factory, ancestries_factory):
|
|||
|
||||
# switch ancestry to tiefling
|
||||
char.ancestry = ancestries["tiefling"]
|
||||
db.add(char)
|
||||
db.add_or_update(char)
|
||||
char = db.session.get(schema.Character, 1)
|
||||
assert char.ancestry.name == "tiefling"
|
||||
assert darkvision in char.traits
|
||||
|
||||
# assign a class and level
|
||||
char.add_class(classes["fighter"], level=1)
|
||||
db.add(char)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 1}
|
||||
assert char.level == 1
|
||||
assert char.class_attributes == {}
|
||||
|
@ -34,37 +35,39 @@ def test_create_character(db, classes_factory, ancestries_factory):
|
|||
# 'fighting style' is available, but not at this level
|
||||
fighting_style = char.classes["fighter"].attributes_by_level[2]["Fighting Style"]
|
||||
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is False
|
||||
db.add(char)
|
||||
db.add_or_update(char)
|
||||
assert char.class_attributes == {}
|
||||
|
||||
# level up
|
||||
char.add_class(classes["fighter"], level=2)
|
||||
db.add(char)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 2}
|
||||
assert char.level == 2
|
||||
|
||||
# Assign the fighting style
|
||||
assert char.add_class_attribute(fighting_style, fighting_style.options[0])
|
||||
db.add(char)
|
||||
# Assert the fighting style is added automatically and idempotent...ly?
|
||||
assert char.class_attributes[fighting_style.name] == fighting_style.options[0]
|
||||
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is True
|
||||
db.add_or_update(char)
|
||||
|
||||
# classes
|
||||
char.add_class(classes["rogue"], level=1)
|
||||
db.add(char)
|
||||
db.add_or_update(char)
|
||||
assert char.level == 3
|
||||
assert char.levels == {"fighter": 2, "rogue": 1}
|
||||
|
||||
# remove a class
|
||||
char.remove_class(classes["rogue"])
|
||||
db.add(char)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 2}
|
||||
assert char.level == 2
|
||||
|
||||
# remove all remaining classes
|
||||
char.remove_class(classes["fighter"])
|
||||
db.add(char)
|
||||
# remove remaining class by setting level to zero
|
||||
char.add_class(classes["fighter"], level=0)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {}
|
||||
|
||||
# ensure we're not persisting any orphan records in the map tables
|
||||
dump = db.dump()
|
||||
dump = json.loads(db.dump())
|
||||
assert dump["class_map"] == []
|
||||
assert dump["class_map"] == []
|
||||
assert dump["character_class_attribute_map"] == []
|
||||
|
|
Loading…
Reference in New Issue
Block a user