diff --git a/src/ttfrog/cli.py b/src/ttfrog/cli.py index 3e1e422..3632771 100644 --- a/src/ttfrog/cli.py +++ b/src/ttfrog/cli.py @@ -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() diff --git a/src/ttfrog/db/base.py b/src/ttfrog/db/base.py index ab63783..fbd0364 100644 --- a/src/ttfrog/db/base.py +++ b/src/ttfrog/db/base.py @@ -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] diff --git a/src/ttfrog/db/manager.py b/src/ttfrog/db/manager.py index 9731a60..30fb61b 100644 --- a/src/ttfrog/db/manager.py +++ b/src/ttfrog/db/manager.py @@ -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: diff --git a/src/ttfrog/db/schema/__init__.py b/src/ttfrog/db/schema/__init__.py index 24db301..f1f3437 100644 --- a/src/ttfrog/db/schema/__init__.py +++ b/src/ttfrog/db/schema/__init__.py @@ -1,4 +1,4 @@ from .character import * from .classes import * from .property import * -from .transaction import * +from .log import * diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 95dd675..7889ce0 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -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 diff --git a/src/ttfrog/db/schema/classes.py b/src/ttfrog/db/schema/classes.py index 2f81354..6c66ecc 100644 --- a/src/ttfrog/db/schema/classes.py +++ b/src/ttfrog/db/schema/classes.py @@ -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) diff --git a/src/ttfrog/db/schema/transaction.py b/src/ttfrog/db/schema/log.py similarity index 76% rename from src/ttfrog/db/schema/transaction.py rename to src/ttfrog/db/schema/log.py index b677845..e1ab677 100644 --- a/src/ttfrog/db/schema/transaction.py +++ b/src/ttfrog/db/schema/log.py @@ -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) diff --git a/src/ttfrog/db/schema/property.py b/src/ttfrog/db/schema/property.py index 6a590c2..47bfd98 100644 --- a/src/ttfrog/db/schema/property.py +++ b/src/ttfrog/db/schema/property.py @@ -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) diff --git a/test/fixtures/ancestry.json b/test/fixtures/ancestry.json index df91106..e5ac04b 100644 --- a/test/fixtures/ancestry.json +++ b/test/fixtures/ancestry.json @@ -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} ] } diff --git a/test/test_schema.py b/test/test_schema.py index b57cfb9..8beb37e 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -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"] == []