diff --git a/pyproject.toml b/pyproject.toml index 91bf65a..3d2adc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "" authors = ["evilchili "] readme = "README.md" packages = [ - { include = 'ttfrog' }, + {include = "*", from = "src"}, ] [tool.poetry.dependencies] @@ -26,9 +26,9 @@ nanoid-dictionary = "^2.4.0" wtforms-alchemy = "^0.18.0" sqlalchemy-serializer = "^1.4.1" - [tool.poetry.group.dev.dependencies] pytest = "^8.1.1" +pytest-cov = "^5.0.0" [build-system] requires = ["poetry-core"] @@ -37,3 +37,30 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] ttfrog = "ttfrog.cli:app" + + +### SLAM + +[tool.black] +line-length = 120 +target-version = ['py310'] + +[tool.isort] +multi_line_output = 3 +line_length = 120 +include_trailing_comma = true + +[tool.autoflake] +check = false # return error code if changes are needed +in-place = true # make changes to files instead of printing diffs +recursive = true # drill down directories recursively +remove-all-unused-imports = true # remove all unused imports (not just those from the standard library) +ignore-init-module-imports = true # exclude __init__.py when removing unused imports +remove-duplicate-keys = true # remove all duplicate keys in objects +remove-unused-variables = true # remove unused variables + +[tool.pytest.ini_options] +log_cli_level = "DEBUG" +addopts = "--cov=src --cov-report=term-missing" + +### ENDSLAM diff --git a/ttfrog/__init__.py b/src/ttfrog/__init__.py similarity index 100% rename from ttfrog/__init__.py rename to src/ttfrog/__init__.py diff --git a/ttfrog/assets/__init__.py b/src/ttfrog/assets/__init__.py similarity index 100% rename from ttfrog/assets/__init__.py rename to src/ttfrog/assets/__init__.py diff --git a/ttfrog/assets/static/css/styles.css b/src/ttfrog/assets/static/css/styles.css similarity index 100% rename from ttfrog/assets/static/css/styles.css rename to src/ttfrog/assets/static/css/styles.css diff --git a/ttfrog/assets/static/js/character_sheet.js b/src/ttfrog/assets/static/js/character_sheet.js similarity index 100% rename from ttfrog/assets/static/js/character_sheet.js rename to src/ttfrog/assets/static/js/character_sheet.js diff --git a/ttfrog/assets/templates/__init__.py b/src/ttfrog/assets/templates/__init__.py similarity index 100% rename from ttfrog/assets/templates/__init__.py rename to src/ttfrog/assets/templates/__init__.py diff --git a/ttfrog/assets/templates/base.html b/src/ttfrog/assets/templates/base.html similarity index 100% rename from ttfrog/assets/templates/base.html rename to src/ttfrog/assets/templates/base.html diff --git a/ttfrog/assets/templates/character_sheet.html b/src/ttfrog/assets/templates/character_sheet.html similarity index 100% rename from ttfrog/assets/templates/character_sheet.html rename to src/ttfrog/assets/templates/character_sheet.html diff --git a/ttfrog/assets/templates/index.html b/src/ttfrog/assets/templates/index.html similarity index 100% rename from ttfrog/assets/templates/index.html rename to src/ttfrog/assets/templates/index.html diff --git a/ttfrog/assets/templates/list.html b/src/ttfrog/assets/templates/list.html similarity index 100% rename from ttfrog/assets/templates/list.html rename to src/ttfrog/assets/templates/list.html diff --git a/ttfrog/attribute_map.py b/src/ttfrog/attribute_map.py similarity index 99% rename from ttfrog/attribute_map.py rename to src/ttfrog/attribute_map.py index 77207e7..8d46fcc 100644 --- a/ttfrog/attribute_map.py +++ b/src/ttfrog/attribute_map.py @@ -41,6 +41,7 @@ class AttributeMap(Mapping): """ + attributes: field(default_factory=dict) def __getattr__(self, attr): diff --git a/ttfrog/cli.py b/src/ttfrog/cli.py similarity index 78% rename from ttfrog/cli.py rename to src/ttfrog/cli.py index e272464..3e1e422 100644 --- a/ttfrog/cli.py +++ b/src/ttfrog/cli.py @@ -2,8 +2,8 @@ import io import logging import os from pathlib import Path -from typing import Optional from textwrap import dedent +from typing import Optional import typer from dotenv import load_dotenv @@ -12,9 +12,8 @@ from rich.logging import RichHandler from ttfrog.path import assets - default_data_path = Path("~/.dnd/ttfrog") -default_host = '127.0.0.1' +default_host = "127.0.0.1" default_port = 2323 SETUP_HELP = f""" @@ -47,18 +46,16 @@ def main( root: Optional[Path] = typer.Option( default_data_path, help="Path to the TableTop Frog environment", - ) + ), ): - app_state['env'] = root.expanduser() / Path('defaults') + app_state["env"] = root.expanduser() / Path("defaults") load_dotenv(stream=io.StringIO(SETUP_HELP)) - load_dotenv(app_state['env']) - debug = os.getenv('DEBUG', None) + load_dotenv(app_state["env"]) + debug = os.getenv("DEBUG", None) logging.basicConfig( - format='%(message)s', + format="%(message)s", level=logging.DEBUG if debug else logging.INFO, - handlers=[ - RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer]) - ] + handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])], ) @@ -68,9 +65,10 @@ 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)) + + 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() @@ -86,23 +84,20 @@ def serve( default_port, help="bind port", ), - debug: bool = typer.Option( - False, - help='Enable debugging output' - ), + debug: bool = typer.Option(False, help="Enable debugging output"), ): """ Start the TableTop Frog server. """ # delay loading the app until we have configured our environment - from ttfrog.webserver import application from ttfrog.db.bootstrap import bootstrap + from ttfrog.webserver import application print("Starting TableTop Frog server...") bootstrap() application.start(host=host, port=port, debug=debug) -if __name__ == '__main__': +if __name__ == "__main__": app() diff --git a/ttfrog/db/__init__.py b/src/ttfrog/db/__init__.py similarity index 100% rename from ttfrog/db/__init__.py rename to src/ttfrog/db/__init__.py diff --git a/ttfrog/db/base.py b/src/ttfrog/db/base.py similarity index 72% rename from ttfrog/db/base.py rename to src/ttfrog/db/base.py index 53b8f8b..ab63783 100644 --- a/ttfrog/db/base.py +++ b/src/ttfrog/db/base.py @@ -1,11 +1,10 @@ import enum -import logging + import nanoid from nanoid_dictionary import human_alphabet -from sqlalchemy import Column -from sqlalchemy import String from pyramid_sqlalchemy import BaseObject from slugify import slugify +from sqlalchemy import Column, String def genslug(): @@ -17,16 +16,14 @@ class SlugMixin: @property def uri(self): - return '-'.join([ - self.slug, - slugify(self.name.title().replace(' ', ''), ok='', only_ascii=True, lower=False) - ]) + return "-".join([self.slug, slugify(self.name.title().replace(" ", ""), ok="", only_ascii=True, lower=False)]) class IterableMixin: """ Allows for iterating over Model objects' column names and values """ + def __iter__(self): values = vars(self) for attr in self.__mapper__.columns.keys(): @@ -40,16 +37,16 @@ class IterableMixin: continue for rel in reliter: try: - relvals.append({k: v for k, v in vars(rel).items() if not k.startswith('_')}) + relvals.append({k: v for k, v in vars(rel).items() if not k.startswith("_")}) except TypeError: relvals.append(rel) yield relname, relvals def __json__(self, request): serialized = dict() - for (key, value) in self: + for key, value in self: try: - serialized[key] = getattr(self.value, '__json__')(request) + serialized[key] = getattr(self.value, "__json__")(request) except AttributeError: serialized[key] = value return serialized @@ -58,7 +55,7 @@ class IterableMixin: return str(dict(self)) -def multivalue_string_factory(name, column=Column(String), separator=';'): +def multivalue_string_factory(name, column=Column(String), separator=";"): """ Generate a mixin class that adds a string column with getters and setters that convert list values to strings and back again. Equivalent to: @@ -77,27 +74,46 @@ def multivalue_string_factory(name, column=Column(String), separator=';'): attr = f"_{name}" prop = property(lambda self: getattr(self, attr).split(separator)) setter = prop.setter(lambda self, val: setattr(self, attr, separator.join(val))) - return type('MultiValueString', (object, ), { - attr: column, - f"{name}_property": prop, - name: setter, - }) + return type( + "MultiValueString", + (object,), + { + attr: column, + f"{name}_property": prop, + name: setter, + }, + ) class EnumField(enum.Enum): """ A serializable enum. """ + def __json__(self, request): return self.value -SavingThrowsMixin = multivalue_string_factory('saving_throws') -SkillsMixin = multivalue_string_factory('skills') +SavingThrowsMixin = multivalue_string_factory("saving_throws") +SkillsMixin = multivalue_string_factory("skills") -STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'] -CREATURE_TYPES = ['aberation', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'Giant', - 'humanoid', 'monstrosity', 'ooze', 'plant', 'undead'] +STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"] +CREATURE_TYPES = [ + "aberation", + "beast", + "celestial", + "construct", + "dragon", + "elemental", + "fey", + "fiend", + "Giant", + "humanoid", + "monstrosity", + "ooze", + "plant", + "undead", +] CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES)) StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS)) diff --git a/src/ttfrog/db/bootstrap.py b/src/ttfrog/db/bootstrap.py new file mode 100644 index 0000000..4ed1b43 --- /dev/null +++ b/src/ttfrog/db/bootstrap.py @@ -0,0 +1,192 @@ +import logging + +from sqlalchemy.exc import IntegrityError + +from ttfrog.db import schema +from ttfrog.db.manager import db + +# move this to json or whatever +data = { + "CharacterClass": [ + { + "id": 1, + "name": "fighter", + "hit_dice": "1d10", + "hit_dice_stat": "CON", + "proficiencies": "all armor, all shields, simple weapons, martial weapons", + "saving_throws": ["STR, CON"], + "skills": [ + "Acrobatics", + "Animal Handling", + "Athletics", + "History", + "Insight", + "Intimidation", + "Perception", + "Survival", + ], + }, + { + "id": 2, + "name": "rogue", + "hit_dice": "1d8", + "hit_dice_stat": "DEX", + "proficiencies": "simple weapons, hand crossbows, longswords, rapiers, shortswords", + "saving_throws": ["DEX", "INT"], + "skills": [ + "Acrobatics", + "Athletics", + "Deception", + "Insight", + "Intimidation", + "Investigation", + "Perception", + "Performance", + "Persuasion", + "Sleight of Hand", + "Stealth", + ], + }, + ], + "Skill": [ + {"name": "Acrobatics"}, + {"name": "Animal Handling"}, + {"name": "Athletics"}, + {"name": "Deception"}, + {"name": "History"}, + {"name": "Insight"}, + {"name": "Intimidation"}, + {"name": "Investigation"}, + {"name": "Perception"}, + {"name": "Performance"}, + {"name": "Persuasion"}, + {"name": "Sleight of Hand"}, + {"name": "Stealth"}, + {"name": "Survival"}, + ], + "Ancestry": [ + {"id": 1, "name": "human", "creature_type": "humanoid"}, + {"id": 2, "name": "dragonborn", "creature_type": "humanoid"}, + {"id": 3, "name": "tiefling", "creature_type": "humanoid"}, + {"id": 4, "name": "elf", "creature_type": "humanoid"}, + ], + "AncestryTrait": [ + { + "id": 1, + "name": "+1 to All Ability Scores", + }, + { + "id": 2, + "name": "Breath Weapon", + }, + { + "id": 3, + "name": "Darkvision", + }, + ], + "AncestryTraitMap": [ + {"ancestry_id": 1, "ancestry_trait_id": 1, "level": 1}, # human +1 to scores + {"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, # dragonborn breath weapon + {"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}, # tiefling darkvision + {"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, # elf darkvision + ], + "CharacterClassMap": [ + { + "character_id": 1, + "character_class_id": 1, + "level": 2, + }, + { + "character_id": 1, + "character_class_id": 2, + "level": 3, + }, + ], + "Character": [ + { + "id": 1, + "name": "Sabetha", + "ancestry_id": 1, + "armor_class": 10, + "max_hit_points": 14, + "hit_points": 14, + "temp_hit_points": 0, + "speed": 30, + "str": 16, + "dex": 12, + "con": 18, + "int": 11, + "wis": 12, + "cha": 8, + "proficiencies": "all armor, all shields, simple weapons, martial weapons", + "saving_throws": ["STR", "CON"], + "skills": ["Acrobatics", "Animal Handling"], + }, + ], + "ClassAttribute": [ + {"id": 1, "name": "Fighting Style"}, + {"id": 2, "name": "Another Attribute"}, + ], + "ClassAttributeOption": [ + {"id": 1, "attribute_id": 1, "name": "Archery"}, + {"id": 2, "attribute_id": 1, "name": "Battlemaster"}, + {"id": 3, "attribute_id": 2, "name": "Another Option 1"}, + {"id": 4, "attribute_id": 2, "name": "Another Option 2"}, + ], + "ClassAttributeMap": [ + {"class_attribute_id": 1, "character_class_id": 1, "level": 2}, # Fighter: Fighting Style + {"class_attribute_id": 2, "character_class_id": 1, "level": 1}, # Fighter: Another Attr + ], + "CharacterClassAttributeMap": [ + {"character_id": 1, "class_attribute_id": 2, "option_id": 4}, # Sabetha, another option, option 2 + {"character_id": 1, "class_attribute_id": 1, "option_id": 1}, # Sabetha, fighting style, archery + ], + "Modifier": [ + # Humans + {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "str"}, + {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "dex"}, + {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "con"}, + {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "int"}, + {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "wis"}, + {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "cha"}, + # Dragonborn + { + "source_table_name": "ancestry_trait", + "source_table_id": 2, + "value": "60", + "type": "attribute ", + "target": "Darkvision", + }, + {"source_table_name": "ancestry_trait", "source_table_id": 2, "value": "+1", "type": "stat", "target": ""}, + {"source_table_name": "ancestry_trait", "source_table_id": 2, "value": "+1", "type": "stat", "target": ""}, + # Fighting Style: Archery + { + "source_table_name": "class_attribute", + "source_table_id": 1, + "value": "+2", + "type": "weapon ", + "target": "ranged", + }, + ], +} + + +def bootstrap(): + """ + Initialize the database with source data. Idempotent; will skip anything that already exists. + """ + db.init() + for table, records in data.items(): + model = getattr(schema, table) + + for rec in records: + obj = model(**rec) + try: + with db.transaction(): + db.session.add(obj) + logging.info(f"Created {table} {obj}") + except IntegrityError as e: + if "UNIQUE constraint failed" in str(e): + logging.info(f"Skipping existing {table} {obj}") + continue + raise diff --git a/ttfrog/db/manager.py b/src/ttfrog/db/manager.py similarity index 87% rename from ttfrog/db/manager.py rename to src/ttfrog/db/manager.py index ecc223d..9731a60 100644 --- a/ttfrog/db/manager.py +++ b/src/ttfrog/db/manager.py @@ -1,20 +1,19 @@ -import os -import transaction import base64 import hashlib - +import os from contextlib import contextmanager from functools import cached_property -from pyramid_sqlalchemy import Session -from pyramid_sqlalchemy import init_sqlalchemy +import transaction +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 -from ttfrog.path import database -import ttfrog.db.schema ttfrog.db.schema @@ -23,9 +22,10 @@ class SQLDatabaseManager: """ A context manager for working with sqllite database. """ + @cached_property def url(self): - return os.environ.get('DATABASE_URL', f"sqlite:///{database()}") + return os.environ.get("DATABASE_URL", f"sqlite:///{database()}") @cached_property def engine(self): @@ -64,7 +64,7 @@ class SQLDatabaseManager: """ Create a uniquish slug from a dictionary. """ - sha1bytes = hashlib.sha1(str(rec['id']).encode()) + sha1bytes = hashlib.sha1(str(rec["id"]).encode()) return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10] def init(self): @@ -73,7 +73,7 @@ class SQLDatabaseManager: def dump(self): results = {} - for (table_name, table) in self.tables.items(): + for table_name, table in self.tables.items(): results[table_name] = [row for row in self.query(table).all()] return results diff --git a/ttfrog/db/schema/__init__.py b/src/ttfrog/db/schema/__init__.py similarity index 100% rename from ttfrog/db/schema/__init__.py rename to src/ttfrog/db/schema/__init__.py diff --git a/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py similarity index 64% rename from ttfrog/db/schema/character.py rename to src/ttfrog/db/schema/character.py index e6a942a..c0b0789 100644 --- a/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -1,24 +1,16 @@ -from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin -from ttfrog.db.base import CreatureTypesEnum - -from sqlalchemy import Column -from sqlalchemy import Enum -from sqlalchemy import Integer -from sqlalchemy import String -from sqlalchemy import Text -from sqlalchemy import ForeignKey -from sqlalchemy import UniqueConstraint -from sqlalchemy.orm import relationship +from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.orm import relationship +from ttfrog.db.base import BaseObject, Bases, CreatureTypesEnum, IterableMixin, SavingThrowsMixin, SkillsMixin __all__ = [ - 'Ancestry', - 'AncestryTrait', - 'AncestryTraitMap', - 'CharacterClassMap', - 'CharacterClassAttributeMap', - 'Character', + "Ancestry", + "AncestryTrait", + "AncestryTraitMap", + "CharacterClassMap", + "CharacterClassAttributeMap", + "Character", ] @@ -39,19 +31,20 @@ class AncestryTraitMap(BaseObject): id = Column(Integer, primary_key=True, autoincrement=True) ancestry_id = Column(Integer, ForeignKey("ancestry.id")) ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id")) - trait = relationship("AncestryTrait", lazy='immediate') - level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}) + trait = relationship("AncestryTrait", lazy="immediate") + level = Column(Integer, nullable=False, info={"min": 1, "max": 20}) class Ancestry(*Bases): """ A character ancestry ("race"), which has zero or more AncestryTraits. """ + __tablename__ = "ancestry" 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 @@ -61,6 +54,7 @@ class AncestryTrait(BaseObject, IterableMixin): """ A trait granted to a character via its Ancestry. """ + __tablename__ = "ancestry_trait" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String, nullable=False) @@ -76,9 +70,9 @@ class CharacterClassMap(BaseObject, IterableMixin): character_id = Column(Integer, ForeignKey("character.id")) character_class_id = Column(Integer, ForeignKey("character_class.id")) mapping = UniqueConstraint(character_id, character_class_id) - level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1) + level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1) - character_class = relationship("CharacterClass", lazy='immediate') + character_class = relationship("CharacterClass", lazy="immediate") character = relationship("Character", uselist=False, viewonly=True) def __repr__(self): @@ -93,42 +87,42 @@ class CharacterClassAttributeMap(BaseObject, IterableMixin): 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') + class_attribute = relationship("ClassAttribute", lazy="immediate") + option = relationship("ClassAttributeOption", lazy="immediate") character_class = relationship( "CharacterClass", secondary="class_map", primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id", secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id", - viewonly=True + viewonly=True, ) class Character(*Bases, SavingThrowsMixin, SkillsMixin): __tablename__ = "character" id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, default='New Character', nullable=False) - armor_class = Column(Integer, default=10, nullable=False, info={'min': 1, 'max': 99}) - hit_points = Column(Integer, default=1, nullable=False, info={'min': 0, 'max': 999}) - max_hit_points = Column(Integer, default=1, nullable=False, info={'min': 0, 'max': 999}) - temp_hit_points = Column(Integer, default=0, nullable=False, info={'min': 0, 'max': 999}) - speed = Column(Integer, nullable=False, default=30, info={'min': 0, 'max': 99}) - str = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) - dex = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) - con = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) - int = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) - wis = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) - cha = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) + name = Column(String, default="New Character", nullable=False) + armor_class = Column(Integer, default=10, nullable=False, info={"min": 1, "max": 99}) + hit_points = Column(Integer, default=1, nullable=False, info={"min": 0, "max": 999}) + max_hit_points = Column(Integer, default=1, nullable=False, info={"min": 0, "max": 999}) + temp_hit_points = Column(Integer, default=0, nullable=False, info={"min": 0, "max": 999}) + speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99}) + str = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + dex = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + con = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + int = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + wis = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + cha = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) proficiencies = Column(String) - class_map = relationship("CharacterClassMap", cascade='all,delete,delete-orphan') - classes = association_proxy('class_map', 'id', creator=class_map_creator) + class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan") + classes = association_proxy("class_map", "id", creator=class_map_creator) - character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade='all,delete,delete-orphan') - class_attributes = association_proxy('character_class_attribute_map', 'id', creator=attr_map_creator) + character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan") + class_attributes = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator) - ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1') + ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default="1") ancestry = relationship("Ancestry", uselist=False) @property @@ -151,11 +145,7 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin): level_in_class = level_in_class[0] level_in_class.level = level return - self.classes.append(CharacterClassMap( - character_id=self.id, - character_class_id=newclass.id, - level=level - )) + self.classes.append(CharacterClassMap(character_id=self.id, character_class_id=newclass.id, level=level)) def remove_class(self, target): self.class_map = [m for m in self.class_map if m.id != target.id] diff --git a/ttfrog/db/schema/classes.py b/src/ttfrog/db/schema/classes.py similarity index 70% rename from ttfrog/db/schema/classes.py rename to src/ttfrog/db/schema/classes.py index 4a9b121..21e4550 100644 --- a/ttfrog/db/schema/classes.py +++ b/src/ttfrog/db/schema/classes.py @@ -1,20 +1,13 @@ -from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin -from ttfrog.db.base import StatsEnum - -from sqlalchemy import Column -from sqlalchemy import Enum -from sqlalchemy import Integer -from sqlalchemy import String -from sqlalchemy import Text -from sqlalchemy import ForeignKey +from sqlalchemy import Column, Enum, ForeignKey, Integer, String from sqlalchemy.orm import relationship +from ttfrog.db.base import BaseObject, Bases, IterableMixin, SavingThrowsMixin, SkillsMixin, StatsEnum __all__ = [ - 'ClassAttributeMap', - 'ClassAttribute', - 'ClassAttributeOption', - 'CharacterClass', + "ClassAttributeMap", + "ClassAttribute", + "ClassAttributeOption", + "CharacterClass", ] @@ -22,7 +15,7 @@ class ClassAttributeMap(BaseObject, IterableMixin): __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) - level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1) + level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1) class ClassAttribute(BaseObject, IterableMixin): @@ -46,7 +39,7 @@ class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin): __tablename__ = "character_class" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String, index=True, unique=True) - hit_dice = Column(String, default='1d6') + hit_dice = Column(String, default="1d6") hit_dice_stat = Column(Enum(StatsEnum)) proficiencies = Column(String) attributes = relationship("ClassAttributeMap") diff --git a/ttfrog/db/schema/property.py b/src/ttfrog/db/schema/property.py similarity index 69% rename from ttfrog/db/schema/property.py rename to src/ttfrog/db/schema/property.py index 3899857..6a590c2 100644 --- a/ttfrog/db/schema/property.py +++ b/src/ttfrog/db/schema/property.py @@ -1,16 +1,11 @@ -from ttfrog.db.base import Bases, BaseObject, IterableMixin - -from sqlalchemy import Column -from sqlalchemy import Integer -from sqlalchemy import String -from sqlalchemy import Text -from sqlalchemy import UniqueConstraint +from sqlalchemy import Column, Integer, String, Text, UniqueConstraint +from ttfrog.db.base import BaseObject, Bases, IterableMixin __all__ = [ - 'Skill', - 'Proficiency', - 'Modifier', + "Skill", + "Proficiency", + "Modifier", ] @@ -35,9 +30,7 @@ class Proficiency(*Bases): class Modifier(BaseObject, IterableMixin): __tablename__ = "modifier" - __table_args__ = ( - UniqueConstraint('source_table_name', 'source_table_id', 'value', 'type', 'target'), - ) + __table_args__ = (UniqueConstraint("source_table_name", "source_table_id", "value", "type", "target"),) id = Column(Integer, primary_key=True, autoincrement=True) source_table_name = Column(String, index=True, nullable=False) source_table_id = Column(Integer, index=True, nullable=False) diff --git a/ttfrog/db/schema/transaction.py b/src/ttfrog/db/schema/transaction.py similarity index 69% rename from ttfrog/db/schema/transaction.py rename to src/ttfrog/db/schema/transaction.py index ba9d929..b677845 100644 --- a/ttfrog/db/schema/transaction.py +++ b/src/ttfrog/db/schema/transaction.py @@ -1,10 +1,9 @@ -from ttfrog.db.base import BaseObject, IterableMixin -from sqlalchemy import Column -from sqlalchemy import Integer -from sqlalchemy import String -from sqlalchemy import Text +from sqlalchemy import Column, Integer, String, Text + +from ttfrog.db.base import BaseObject, IterableMixin + +__all__ = ["TransactionLog"] -__all__ = ['TransactionLog'] class TransactionLog(BaseObject, IterableMixin): __tablename__ = "transaction_log" diff --git a/ttfrog/db/transaction_log.py b/src/ttfrog/db/transaction_log.py similarity index 100% rename from ttfrog/db/transaction_log.py rename to src/ttfrog/db/transaction_log.py diff --git a/src/ttfrog/path.py b/src/ttfrog/path.py new file mode 100644 index 0000000..d421d95 --- /dev/null +++ b/src/ttfrog/path.py @@ -0,0 +1,29 @@ +import os +from pathlib import Path + +_setup_hint = "You may be able to solve this error by running 'ttfrog setup' or specifying the --root parameter." + + +def database(): + path = Path(os.environ["DATA_PATH"]).expanduser() + if not path.exists() or not path.is_dir(): + raise RuntimeError(f"DATA_PATH {path} doesn't exist or isn't a directory.\n\n{_setup_hint}") + return path / Path("tabletop-frog.db") + + +def assets(): + return Path(__file__).parent / "assets" + + +def templates(): + try: + return Path(os.environ["TEMPLATES_PATH"]) + except KeyError: + return assets() / "templates" + + +def static_files(): + try: + return Path(os.environ["STATIC_FILES_PATH"]) + except KeyError: + return assets() / "public" diff --git a/ttfrog/webserver/__init__.py b/src/ttfrog/webserver/__init__.py similarity index 100% rename from ttfrog/webserver/__init__.py rename to src/ttfrog/webserver/__init__.py diff --git a/ttfrog/webserver/application.py b/src/ttfrog/webserver/application.py similarity index 53% rename from ttfrog/webserver/application.py rename to src/ttfrog/webserver/application.py index dd2faeb..2acfdae 100644 --- a/ttfrog/webserver/application.py +++ b/src/ttfrog/webserver/application.py @@ -1,6 +1,6 @@ import logging - from wsgiref.simple_server import make_server + from pyramid.config import Configurator from ttfrog.db.manager import db @@ -8,15 +8,12 @@ from ttfrog.webserver.routes import routes def configuration(): - config = Configurator(settings={ - 'sqlalchemy.url': db.url, - 'jinja2.directories': 'ttfrog.assets:templates/' - }) - config.include('pyramid_tm') - config.include('pyramid_sqlalchemy') - config.include('pyramid_jinja2') - config.add_static_view(name='/static', path='ttfrog.assets:static/') - config.add_jinja2_renderer('.html', settings_prefix='jinja2.') + config = Configurator(settings={"sqlalchemy.url": db.url, "jinja2.directories": "ttfrog.assets:templates/"}) + config.include("pyramid_tm") + config.include("pyramid_sqlalchemy") + config.include("pyramid_jinja2") + config.add_static_view(name="/static", path="ttfrog.assets:static/") + config.add_jinja2_renderer(".html", settings_prefix="jinja2.") return config @@ -25,5 +22,5 @@ def start(host: str, port: int, debug: bool = False) -> None: logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}") config = configuration() config.include(routes) - config.scan('ttfrog.webserver.views') + config.scan("ttfrog.webserver.views") make_server(host, int(port), config.make_wsgi_app()).serve_forever() diff --git a/ttfrog/webserver/controllers/__init__.py b/src/ttfrog/webserver/controllers/__init__.py similarity index 100% rename from ttfrog/webserver/controllers/__init__.py rename to src/ttfrog/webserver/controllers/__init__.py diff --git a/ttfrog/webserver/controllers/ancestry.py b/src/ttfrog/webserver/controllers/ancestry.py similarity index 89% rename from ttfrog/webserver/controllers/ancestry.py rename to src/ttfrog/webserver/controllers/ancestry.py index a608e1b..3d58c9a 100644 --- a/ttfrog/webserver/controllers/ancestry.py +++ b/src/ttfrog/webserver/controllers/ancestry.py @@ -1,12 +1,13 @@ -from ttfrog.db.schema import Ancestry -from ttfrog.db.manager import db from wtforms_alchemy import ModelForm +from ttfrog.db.manager import db +from ttfrog.db.schema import Ancestry + class AncestryForm(ModelForm): class Meta: model = Ancestry - exclude = ['slug'] + exclude = ["slug"] def get_session(): return db.session diff --git a/ttfrog/webserver/controllers/base.py b/src/ttfrog/webserver/controllers/base.py similarity index 86% rename from ttfrog/webserver/controllers/base.py rename to src/ttfrog/webserver/controllers/base.py index c060610..25b694e 100644 --- a/ttfrog/webserver/controllers/base.py +++ b/src/ttfrog/webserver/controllers/base.py @@ -1,27 +1,25 @@ import logging import re - from collections import defaultdict from pyramid.httpexceptions import HTTPFound from pyramid.interfaces import IRoutesMapper from ttfrog.db.manager import db -from ttfrog.db import transaction_log def get_all_routes(request): routes = { - 'static': '/static', + "static": "/static", } uri_pattern = re.compile(r"^([^\{\*]+)") mapper = request.registry.queryUtility(IRoutesMapper) for route in mapper.get_routes(): - if route.name.startswith('__'): + if route.name.startswith("__"): continue m = uri_pattern.search(route.pattern) if m: - routes[route.name] = m .group(0) + routes[route.name] = m.group(0) return routes @@ -36,17 +34,14 @@ class BaseController: self._record = None self._form = None - self.config = { - 'static_url': '/static', - 'project_name': 'TTFROG' - } + self.config = {"static_url": "/static", "project_name": "TTFROG"} self.configure_for_model() @property def slug(self): if not self._slug: - parts = self.request.matchdict.get('uri', '').split('-') - self._slug = parts[0].replace('/', '') + parts = self.request.matchdict.get("uri", "").split("-") + self._slug = parts[0].replace("/", "") return self._slug @property @@ -78,12 +73,12 @@ class BaseController: @property def resources(self): return [ - {'type': 'style', 'uri': 'css/styles.css'}, + {"type": "style", "uri": "css/styles.css"}, ] def configure_for_model(self): - if 'all_records' not in self.attrs: - self.attrs['all_records'] = db.query(self.model).all() + if "all_records" not in self.attrs: + self.attrs["all_records"] = db.query(self.model).all() def template_context(self, **kwargs) -> dict: return dict( @@ -103,14 +98,14 @@ class BaseController: def populate_association(self, key, formdata): populated = [] for field in formdata: - map_id = field.pop('id') + map_id = field.pop("id") map_id = int(map_id) if map_id else 0 if not field[key]: continue elif not map_id: populated.append(field) else: - field['id'] = map_id + field["id"] = map_id populated.append(field) return populated diff --git a/ttfrog/webserver/controllers/character_sheet.py b/src/ttfrog/webserver/controllers/character_sheet.py similarity index 74% rename from ttfrog/webserver/controllers/character_sheet.py rename to src/ttfrog/webserver/controllers/character_sheet.py index 575e19a..9d9e0ee 100644 --- a/ttfrog/webserver/controllers/character_sheet.py +++ b/src/ttfrog/webserver/controllers/character_sheet.py @@ -1,29 +1,25 @@ import logging -from ttfrog.webserver.controllers.base import BaseController -from ttfrog.webserver.forms import DeferredSelectField -from ttfrog.webserver.forms import NullableDeferredSelectField -from ttfrog.db.schema import ( - Character, - Ancestry, - CharacterClass, - CharacterClassMap, - ClassAttributeOption, - CharacterClassAttributeMap -) +from markupsafe import Markup +from wtforms import ValidationError +from wtforms.fields import FieldList, FormField, HiddenField, SelectField, SelectMultipleField, SubmitField +from wtforms.validators import Optional +from wtforms.widgets import ListWidget, Select +from wtforms.widgets.core import html_params +from wtforms_alchemy import ModelForm from ttfrog.db.base import STATS from ttfrog.db.manager import db - -from wtforms_alchemy import ModelForm -from wtforms.fields import SubmitField, SelectField, SelectMultipleField, FieldList, FormField, HiddenField -from wtforms.widgets import Select, ListWidget -from wtforms import ValidationError -from wtforms.validators import Optional -from wtforms.widgets.core import html_params - -from markupsafe import Markup - +from ttfrog.db.schema import ( + Ancestry, + Character, + CharacterClass, + CharacterClassAttributeMap, + CharacterClassMap, + ClassAttributeOption, +) +from ttfrog.webserver.controllers.base import BaseController +from ttfrog.webserver.forms import DeferredSelectField, NullableDeferredSelectField VALID_LEVELS = range(1, 21) @@ -48,7 +44,7 @@ class ClassAttributesFormField(FormField): def process(self, *args, **kwargs): super().process(*args, **kwargs) - self.character_class_map = db.query(CharacterClassAttributeMap).get(self.data['id']) + self.character_class_map = db.query(CharacterClassAttributeMap).get(self.data["id"]) self.label.text = self.character_class_map.character_class[0].name @@ -56,12 +52,7 @@ class ClassAttributesForm(ModelForm): id = HiddenField() class_attribute_id = HiddenField() - option_id = SelectField( - widget=Select(), - choices=[], - validators=[Optional()], - coerce=int - ) + option_id = SelectField(widget=Select(), choices=[], validators=[Optional()], coerce=int) def __init__(self, formdata=None, obj=None, prefix=None): if obj: @@ -74,13 +65,9 @@ class ClassAttributesForm(ModelForm): class MulticlassForm(ModelForm): - id = HiddenField() character_class_id = NullableDeferredSelectField( - model=CharacterClass, - validate_choice=True, - widget=Select(), - coerce=int + model=CharacterClass, validate_choice=True, widget=Select(), coerce=int ) level = SelectField(choices=VALID_LEVELS, default=1, coerce=int, validate_choice=True, widget=Select()) @@ -98,20 +85,19 @@ class MulticlassForm(ModelForm): class CharacterForm(ModelForm): class Meta: model = Character - exclude = ['slug'] + exclude = ["slug"] save = SubmitField() delete = SubmitField() - ancestry_id = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select()) + ancestry_id = DeferredSelectField("Ancestry", model=Ancestry, default=1, validate_choice=True, widget=Select()) classes = FieldList(FormField(MulticlassForm, label=None, widget=ListWidget()), min_entries=0) newclass = FormField(MulticlassForm, widget=ListWidget()) class_attributes = FieldList( - ClassAttributesFormField(ClassAttributesForm, widget=ClassAttributeWidget()), - min_entries=1 + ClassAttributesFormField(ClassAttributesForm, widget=ClassAttributeWidget()), min_entries=1 ) - saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS) + saving_throws = SelectMultipleField("Saving Throws", validate_choice=True, choices=STATS) class CharacterSheet(BaseController): @@ -121,7 +107,7 @@ class CharacterSheet(BaseController): @property def resources(self): return super().resources + [ - {'type': 'script', 'uri': 'js/character_sheet.js'}, + {"type": "script", "uri": "js/character_sheet.js"}, ] def validate_callback(self): @@ -129,13 +115,13 @@ class CharacterSheet(BaseController): Validate multiclass fields in form data. """ ret = super().validate() - if not self.form.data['classes']: + if not self.form.data["classes"]: return ret err = "" total_level = 0 - for field in self.form.data['classes']: - level = field.get('level') + for field in self.form.data["classes"]: + level = field.get("level") total_level += level if level not in VALID_LEVELS: err = f"Multiclass form field {field = } level is outside possible range." @@ -150,9 +136,10 @@ class CharacterSheet(BaseController): def add_class_attributes(self): # prefetch the records for each of the character's classes classes_by_id = { - c.id: c for c in db.query(CharacterClass).filter(CharacterClass.id.in_( - c.character_class_id for c in self.record.class_map - )).all() + c.id: c + for c in db.query(CharacterClass) + .filter(CharacterClass.id.in_(c.character_class_id for c in self.record.class_map)) + .all() } assigned = [int(m.class_attribute_id) for m in self.record.character_class_attribute_map] @@ -165,18 +152,19 @@ class CharacterSheet(BaseController): # assign each class attribute available at the character's current # level to the list of the character's class attributes for attr_map in [a for a in thisclass.attributes if a.level <= class_map.level]: - # when creating a record, assign the first of the available # options to the character's class attribute. - default_option = db.query(ClassAttributeOption).filter_by( - attribute_id=attr_map.class_attribute_id - ).first() + default_option = ( + db.query(ClassAttributeOption).filter_by(attribute_id=attr_map.class_attribute_id).first() + ) if attr_map.class_attribute_id not in assigned: - self.record.class_attributes.append({ - 'class_attribute_id': attr_map.class_attribute_id, - 'option_id': default_option.id, - }) + self.record.class_attributes.append( + { + "class_attribute_id": attr_map.class_attribute_id, + "option_id": default_option.id, + } + ) def save_callback(self): self.add_class_attributes() @@ -188,16 +176,16 @@ class CharacterSheet(BaseController): """ # multiclass form - classes_formdata = self.form.data['classes'] - classes_formdata.append(self.form.data['newclass']) + classes_formdata = self.form.data["classes"] + classes_formdata.append(self.form.data["newclass"]) del self.form.classes del self.form.newclass # class attributes - attrs_formdata = self.form.data['class_attributes'] + attrs_formdata = self.form.data["class_attributes"] del self.form.class_attributes super().populate() - self.record.classes = self.populate_association('character_class_id', classes_formdata) - self.record.class_attributes = self.populate_association('class_attribute_id', attrs_formdata) + self.record.classes = self.populate_association("character_class_id", classes_formdata) + self.record.class_attributes = self.populate_association("class_attribute_id", attrs_formdata) diff --git a/ttfrog/webserver/controllers/json_data.py b/src/ttfrog/webserver/controllers/json_data.py similarity index 77% rename from ttfrog/webserver/controllers/json_data.py rename to src/ttfrog/webserver/controllers/json_data.py index 44dc05e..82beb1f 100644 --- a/ttfrog/webserver/controllers/json_data.py +++ b/src/ttfrog/webserver/controllers/json_data.py @@ -1,10 +1,9 @@ -import logging +from pyramid.httpexceptions import exception_response from ttfrog.db import schema from ttfrog.db.manager import db -from .base import BaseController -from pyramid.httpexceptions import exception_response +from .base import BaseController class JsonData(BaseController): @@ -13,13 +12,10 @@ class JsonData(BaseController): def configure_for_model(self): try: - self.model = getattr(schema, self.request.matchdict.get('table_name')) + self.model = getattr(schema, self.request.matchdict.get("table_name")) except AttributeError: raise exception_response(404) def response(self): query = db.query(self.model).filter_by(**self.request.params) - return { - 'table_name': self.model.__tablename__, - 'records': query.all() - } + return {"table_name": self.model.__tablename__, "records": query.all()} diff --git a/ttfrog/webserver/forms.py b/src/ttfrog/webserver/forms.py similarity index 76% rename from ttfrog/webserver/forms.py rename to src/ttfrog/webserver/forms.py index 4584a17..98878ff 100644 --- a/ttfrog/webserver/forms.py +++ b/src/ttfrog/webserver/forms.py @@ -1,6 +1,7 @@ -from ttfrog.db.manager import db from wtforms.fields import SelectField, SelectMultipleField +from ttfrog.db.manager import db + class DeferredSelectMultipleField(SelectMultipleField): def __init__(self, *args, model=None, **kwargs): @@ -11,10 +12,10 @@ class DeferredSelectMultipleField(SelectMultipleField): class DeferredSelectField(SelectField): def __init__(self, *args, model=None, **kwargs): super().__init__(*args, **kwargs) - self.choices = [(rec.id, getattr(rec, 'name', str(rec))) for rec in db.query(model).all()] + self.choices = [(rec.id, getattr(rec, "name", str(rec))) for rec in db.query(model).all()] class NullableDeferredSelectField(DeferredSelectField): - def __init__(self, *args, model=None, label='---', **kwargs): - super().__init__(*args, model=model, **kwargs) + def __init__(self, *args, model=None, label="---", **kwargs): + super().__init__(*args, model=model, **kwargs) self.choices = [(0, label)] + self.choices diff --git a/src/ttfrog/webserver/routes.py b/src/ttfrog/webserver/routes.py new file mode 100644 index 0000000..55fd558 --- /dev/null +++ b/src/ttfrog/webserver/routes.py @@ -0,0 +1,4 @@ +def routes(config): + config.add_route("index", "/") + config.add_route("sheet", "/c{uri:.*}", factory="ttfrog.webserver.controllers.CharacterSheet") + config.add_route("data", "/_/{table_name}{uri:.*}", factory="ttfrog.webserver.controllers.JsonData") diff --git a/ttfrog/webserver/views.py b/src/ttfrog/webserver/views.py similarity index 65% rename from ttfrog/webserver/views.py rename to src/ttfrog/webserver/views.py index 6803aa7..2efd0f2 100644 --- a/ttfrog/webserver/views.py +++ b/src/ttfrog/webserver/views.py @@ -1,23 +1,26 @@ from pyramid.response import Response from pyramid.view import view_config + +from ttfrog.attribute_map import AttributeMap from ttfrog.db.manager import db from ttfrog.db.schema import Ancestry -from ttfrog.attribute_map import AttributeMap + def response_from(controller): - return controller.response() or AttributeMap.from_dict({'c': controller.template_context()}) + return controller.response() or AttributeMap.from_dict({"c": controller.template_context()}) -@view_config(route_name='index') +@view_config(route_name="index") def index(request): ancestries = [a.name for a in db.session.query(Ancestry).all()] - return Response(','.join(ancestries)) + return Response(",".join(ancestries)) -@view_config(route_name='sheet', renderer='character_sheet.html') +@view_config(route_name="sheet", renderer="character_sheet.html") def sheet(request): return response_from(request.context) -@view_config(route_name='data', renderer='json') + +@view_config(route_name="data", renderer="json") def data(request): return response_from(request.context) diff --git a/test/conftest.py b/test/conftest.py index d3c8d3a..508e9fb 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -7,8 +7,7 @@ import pytest from ttfrog.db import schema from ttfrog.db.manager import db as _db - -FIXTURE_PATH = Path(__file__).parent / 'fixtures' +FIXTURE_PATH = Path(__file__).parent / "fixtures" def load_fixture(db, fixture_name): @@ -23,20 +22,20 @@ def load_fixture(db, fixture_name): @pytest.fixture(autouse=True) def db(monkeypatch): - monkeypatch.setattr('ttfrog.db.manager.database', MagicMock(return_value="")) - monkeypatch.setenv('DATABASE_URL', "sqlite:///:memory:") - monkeypatch.setenv('DEBUG', '1') + monkeypatch.setattr("ttfrog.db.manager.database", MagicMock(return_value="")) + monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:") + monkeypatch.setenv("DEBUG", "1") _db.init() return _db @pytest.fixture def classes(db): - load_fixture(db, 'classes') + load_fixture(db, "classes") return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all()) @pytest.fixture def ancestries(db): - load_fixture(db, 'ancestry') + load_fixture(db, "ancestry") return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all()) diff --git a/test/test_schema.py b/test/test_schema.py index 77092a3..08c36ea 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -3,54 +3,54 @@ from ttfrog.db import schema def test_create_character(db, classes, ancestries): with db.transaction(): - darkvision = db.session.query(schema.AncestryTrait).filter_by(name='Darkvision')[0] + darkvision = db.session.query(schema.AncestryTrait).filter_by(name="Darkvision")[0] # create a human character (the default) - char = schema.Character(name='Test Character') + char = schema.Character(name="Test Character") db.add(char) assert char.id == 1 assert char.armor_class == 10 - assert char.name == 'Test Character' - assert char.ancestry.name == 'human' + assert char.name == "Test Character" + assert char.ancestry.name == "human" assert darkvision not in char.traits # switch ancestry to tiefling - char.ancestry = ancestries['tiefling'] + char.ancestry = ancestries["tiefling"] db.add(char) char = db.session.get(schema.Character, 1) - assert char.ancestry.name == 'tiefling' + assert char.ancestry.name == "tiefling" assert darkvision in char.traits # assign a class and level - char.add_class(classes['fighter'], level=1) + char.add_class(classes["fighter"], level=1) db.add(char) - assert char.levels == {'fighter': 1} + assert char.levels == {"fighter": 1} assert char.level == 1 assert char.class_attributes == [] # level up - char.add_class(classes['fighter'], level=2) + char.add_class(classes["fighter"], level=2) db.add(char) - assert char.levels == {'fighter': 2} + assert char.levels == {"fighter": 2} assert char.level == 2 assert char.class_attributes == [] # multiclass - char.add_class(classes['rogue'], level=1) + char.add_class(classes["rogue"], level=1) db.add(char) assert char.level == 3 - assert char.levels == {'fighter': 2, 'rogue': 1} + assert char.levels == {"fighter": 2, "rogue": 1} # remove a class - char.remove_class(classes['rogue']) + char.remove_class(classes["rogue"]) db.add(char) - assert char.levels == {'fighter': 2} + assert char.levels == {"fighter": 2} assert char.level == 2 # remove all remaining classes - char.remove_class(classes['fighter']) + char.remove_class(classes["fighter"]) db.add(char) # ensure we're not persisting any orphan records in the map table dump = db.dump() - assert dump['class_map'] == [] + assert dump["class_map"] == [] diff --git a/ttfrog/db/bootstrap.py b/ttfrog/db/bootstrap.py deleted file mode 100644 index 5432962..0000000 --- a/ttfrog/db/bootstrap.py +++ /dev/null @@ -1,166 +0,0 @@ -import logging - -from ttfrog.db.manager import db -from ttfrog.db import schema - -from sqlalchemy.exc import IntegrityError - -# move this to json or whatever -data = { - 'CharacterClass': [ - { - 'id': 1, - 'name': 'fighter', - 'hit_dice': '1d10', - 'hit_dice_stat': 'CON', - 'proficiencies': 'all armor, all shields, simple weapons, martial weapons', - 'saving_throws': ['STR, CON'], - 'skills': ['Acrobatics', 'Animal Handling', 'Athletics', 'History', 'Insight', 'Intimidation', 'Perception', 'Survival'], - }, - { - 'id': 2, - 'name': 'rogue', - 'hit_dice': '1d8', - 'hit_dice_stat': 'DEX', - 'proficiencies': 'simple weapons, hand crossbows, longswords, rapiers, shortswords', - 'saving_throws': ['DEX', 'INT'], - 'skills': ['Acrobatics', 'Athletics', 'Deception', 'Insight', 'Intimidation', 'Investigation', 'Perception', 'Performance', 'Persuasion', 'Sleight of Hand', 'Stealth'], - }, - ], - - 'Skill': [ - {'name': 'Acrobatics'}, - {'name': 'Animal Handling'}, - {'name': 'Athletics'}, - {'name': 'Deception'}, - {'name': 'History'}, - {'name': 'Insight'}, - {'name': 'Intimidation'}, - {'name': 'Investigation'}, - {'name': 'Perception'}, - {'name': 'Performance'}, - {'name': 'Persuasion'}, - {'name': 'Sleight of Hand'}, - {'name': 'Stealth'}, - {'name': 'Survival'}, - ], - - 'Ancestry': [ - {'id': 1, 'name': 'human', 'creature_type': 'humanoid'}, - {'id': 2, 'name': 'dragonborn', 'creature_type': 'humanoid'}, - {'id': 3, 'name': 'tiefling', 'creature_type': 'humanoid'}, - {'id': 4, 'name': 'elf', 'creature_type': 'humanoid'}, - ], - - 'AncestryTrait': [ - {'id': 1, 'name': '+1 to All Ability Scores', }, - {'id': 2, 'name': 'Breath Weapon', }, - {'id': 3, 'name': 'Darkvision', }, - ], - - 'AncestryTraitMap': [ - {'ancestry_id': 1, 'ancestry_trait_id': 1, 'level': 1}, # human +1 to scores - {'ancestry_id': 2, 'ancestry_trait_id': 2, 'level': 1}, # dragonborn breath weapon - {'ancestry_id': 3, 'ancestry_trait_id': 3, 'level': 1}, # tiefling darkvision - {'ancestry_id': 2, 'ancestry_trait_id': 2, 'level': 1}, # elf darkvision - ], - - 'CharacterClassMap': [ - { - 'character_id': 1, - 'character_class_id': 1, - 'level': 2, - }, - { - 'character_id': 1, - 'character_class_id': 2, - 'level': 3, - }, - ], - - 'Character': [ - { - 'id': 1, - 'name': 'Sabetha', - 'ancestry_id': 1, - 'armor_class': 10, - 'max_hit_points': 14, - 'hit_points': 14, - 'temp_hit_points': 0, - 'speed': 30, - 'str': 16, - 'dex': 12, - 'con': 18, - 'int': 11, - 'wis': 12, - 'cha': 8, - 'proficiencies': 'all armor, all shields, simple weapons, martial weapons', - 'saving_throws': ['STR', 'CON'], - 'skills': ['Acrobatics', 'Animal Handling'], - }, - ], - - 'ClassAttribute': [ - {'id': 1, 'name': 'Fighting Style'}, - {'id': 2, 'name': 'Another Attribute'}, - ], - - 'ClassAttributeOption': [ - {'id': 1, 'attribute_id': 1, 'name': 'Archery'}, - {'id': 2, 'attribute_id': 1, 'name': 'Battlemaster'}, - {'id': 3, 'attribute_id': 2, 'name': 'Another Option 1'}, - {'id': 4, 'attribute_id': 2, 'name': 'Another Option 2'}, - ], - - - 'ClassAttributeMap': [ - {'class_attribute_id': 1, 'character_class_id': 1, 'level': 2}, # Fighter: Fighting Style - {'class_attribute_id': 2, 'character_class_id': 1, 'level': 1}, # Fighter: Another Attr - ], - - 'CharacterClassAttributeMap': [ - {'character_id': 1, 'class_attribute_id': 2, 'option_id': 4}, # Sabetha, another option, option 2 - {'character_id': 1, 'class_attribute_id': 1, 'option_id': 1}, # Sabetha, fighting style, archery - ], - - - 'Modifier': [ - # Humans - {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'str'}, - {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'dex'}, - {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'con'}, - {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'int'}, - {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'wis'}, - {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'cha'}, - - # Dragonborn - {'source_table_name': 'ancestry_trait', 'source_table_id': 2, 'value': '60', 'type': 'attribute ', 'target': 'Darkvision'}, - {'source_table_name': 'ancestry_trait', 'source_table_id': 2, 'value': '+1', 'type': 'stat', 'target': ''}, - {'source_table_name': 'ancestry_trait', 'source_table_id': 2, 'value': '+1', 'type': 'stat', 'target': ''}, - - # Fighting Style: Archery - {'source_table_name': 'class_attribute', 'source_table_id': 1, 'value': '+2', 'type': 'weapon ', 'target': 'ranged'}, - ], - -} - - -def bootstrap(): - """ - Initialize the database with source data. Idempotent; will skip anything that already exists. - """ - db.init() - for table, records in data.items(): - model = getattr(schema, table) - - for rec in records: - obj = model(**rec) - try: - with db.transaction(): - db.session.add(obj) - logging.info(f"Created {table} {obj}") - except IntegrityError as e: - if 'UNIQUE constraint failed' in str(e): - logging.info(f"Skipping existing {table} {obj}") - continue - raise diff --git a/ttfrog/path.py b/ttfrog/path.py deleted file mode 100644 index 251a64f..0000000 --- a/ttfrog/path.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -from pathlib import Path - -_setup_hint = "You may be able to solve this error by running 'ttfrog setup' or specifying the --root parameter." - - -def database(): - path = Path(os.environ['DATA_PATH']).expanduser() - if not path.exists() or not path.is_dir(): - raise RuntimeError( - f"DATA_PATH {path} doesn't exist or isn't a directory.\n\n{_setup_hint}" - ) - return path / Path('tabletop-frog.db') - - -def assets(): - return Path(__file__).parent / 'assets' - - -def templates(): - try: - return Path(os.environ['TEMPLATES_PATH']) - except KeyError: - return assets() / 'templates' - - -def static_files(): - try: - return Path(os.environ['STATIC_FILES_PATH']) - except KeyError: - return assets() / 'public' diff --git a/ttfrog/webserver/routes.py b/ttfrog/webserver/routes.py deleted file mode 100644 index 4b03c8b..0000000 --- a/ttfrog/webserver/routes.py +++ /dev/null @@ -1,4 +0,0 @@ -def routes(config): - config.add_route('index', '/') - config.add_route('sheet', '/c{uri:.*}', factory='ttfrog.webserver.controllers.CharacterSheet') - config.add_route('data', '/_/{table_name}{uri:.*}', factory='ttfrog.webserver.controllers.JsonData')