diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8060659 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "tabletop-frog" +version = "0.1.0" +description = "" +authors = ["evilchili "] +readme = "README.md" +packages = [ + { include = 'ttfrog' }, +] + +[tool.poetry.dependencies] +python = "^3.10" +TurboGears2 = "^2.4.3" +sqlalchemy = "^2.0.25" +tgext-admin = "^0.7.4" +webhelpers2 = "^2.0" +typer = "^0.9.0" +python-dotenv = "^0.21.0" +rich = "^13.7.0" +jinja2 = "^3.1.3" + +#"tg.devtools" = "^2.4.3" +#repoze-who = "^3.0.0" +# tw2-forms = "^2.2.6" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + + +[tool.poetry.scripts] +ttfrog = "ttfrog.cli:app" + + diff --git a/ttfrog/__init__.py b/ttfrog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ttfrog/assets/__init__.py b/ttfrog/assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ttfrog/assets/public/static/styles.css b/ttfrog/assets/public/static/styles.css new file mode 100644 index 0000000..e69de29 diff --git a/ttfrog/assets/templates/__init__.py b/ttfrog/assets/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ttfrog/assets/templates/index.html b/ttfrog/assets/templates/index.html new file mode 100644 index 0000000..d701506 --- /dev/null +++ b/ttfrog/assets/templates/index.html @@ -0,0 +1,33 @@ + + + + {{ tmpl_context.project_name }} + + + + + + + + + + + + + + + + + +

{{ tmpl_context.project_name }}: {{ page }}

+
+        {{ content }}
+        
+ + diff --git a/ttfrog/cli.py b/ttfrog/cli.py new file mode 100644 index 0000000..4d3e750 --- /dev/null +++ b/ttfrog/cli.py @@ -0,0 +1,107 @@ +import io +import logging +import os +from pathlib import Path +from typing import Optional +from textwrap import dedent + +import typer +from dotenv import load_dotenv +from rich import print +from rich.logging import RichHandler + +from ttfrog.path import assets + + +default_data_path = Path("~/.dnd/ttfrog") +default_host = '127.0.0.1' +default_port = 2323 + +SETUP_HELP = f""" +# Please make sure you set the SECRET_KEY in your environment. By default, +# TableTop Frog will attempt to load these variables from: +# {default_data_path}/defaults +# +# which may contain the following variables as well. +# +# See also the --root paramter. + +DATA_PATH={default_data_path} + +# Uncomment one or both of these to replace the packaged static assets and templates: +# +# STATIC_FILES_PATH={assets()}/public +# TEMPLATES_PATH={assets()}/templates + +HOST={default_host} +PORT={default_port} +""" + +app = typer.Typer() +app_state = dict() + + +@app.callback() +def main( + context: typer.Context, + root: Optional[Path] = typer.Option( + default_data_path, + help="Path to the TableTop Frog environment", + ) +): + app_state['env'] = root.expanduser() / Path('defaults') + load_dotenv(stream=io.StringIO(SETUP_HELP)) + load_dotenv(app_state['env']) + debug = os.getenv('DEBUG', None) + logging.basicConfig( + format='%(message)s', + level=logging.DEBUG if debug else logging.INFO, + handlers=[ + RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer]) + ] + ) + + +@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, + host: str = typer.Argument( + default_host, + help="bind address", + ), + port: int = typer.Argument( + default_port, + help="bind port", + ), + 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 + + print("Starting TableTop Frog server...") + application.start(host=host, port=port, debug=debug) + + +if __name__ == '__main__': + app() diff --git a/ttfrog/db/__init__.py b/ttfrog/db/__init__.py new file mode 100644 index 0000000..aece833 --- /dev/null +++ b/ttfrog/db/__init__.py @@ -0,0 +1,4 @@ +from .manager import db, session + + +__ALL__ = [db, session] diff --git a/ttfrog/db/bootstrap.py b/ttfrog/db/bootstrap.py new file mode 100644 index 0000000..c1c17c5 --- /dev/null +++ b/ttfrog/db/bootstrap.py @@ -0,0 +1,47 @@ +import base64 +import hashlib +import logging + +from ttfrog.db import db, session + + +# move this to json or whatever +data = { + 'ancestry': [ + {'name': 'human'}, + {'name': 'dragonborn'}, + ], +} + + +def slug_from_rec(rec): + """ + Create a uniquish slug from a dictionary. + """ + sha1bytes = hashlib.sha1(str(rec).encode()) + return '-'.join([ + base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10], + rec.get('name', '') # will need to normalize this for URLs + ]) + + +def bootstrap(): + """ + Initialize the database with source data. Idempotent; will skip anything that already exists. + """ + db.init_model() + for table_name, table in db.tables.items(): + if table_name not in data: + logging.debug("No bootstrap data for table {table_name}; skipping.") + continue + for rec in data[table_name]: + if 'slug' in table.columns: + rec['slug'] = slug_from_rec(rec) + stmt = table.insert().values(**rec).prefix_with("OR IGNORE") + result = session.execute(stmt) + session.commit() + last_id = result.inserted_primary_key[0] + if last_id == 0: + logging.info(f"Skipped existing {table_name} {rec}") + else: + logging.info(f"Created {table_name} {result.inserted_primary_key[0]}: {rec}") diff --git a/ttfrog/db/manager.py b/ttfrog/db/manager.py new file mode 100644 index 0000000..cad2fb3 --- /dev/null +++ b/ttfrog/db/manager.py @@ -0,0 +1,54 @@ +from functools import cached_property + +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker + +from ttfrog.path import database +from ttfrog.db.schema import metadata + + +class SQLDatabaseManager: + """ + A context manager for working with sqllite database. + """ + @cached_property + def url(self): + return f"sqlite:///{database()}" + + @cached_property + def engine(self): + return create_engine(self.url, future=True) + + @cached_property + def DBSession(self): + maker = sessionmaker(bind=self.engine, future=True, autoflush=True) + return scoped_session(maker) + + @cached_property + def tables(self): + return dict((t.name, t) for t in metadata.sorted_tables) + + def query(self, *args, **kwargs): + return self.DBSession.query(*args, **kwargs) + + def init_model(self, engine=None): + metadata.create_all(bind=engine or self.engine) + return self.DBSession + + def __getattr__(self, name: str): + try: + return self.tables[name] + except KeyError: + raise AttributeError(f"{self} does not contain the attribute '{name}'.") + + def __enter__(self): + self.init_model(self.engine) + return self + + def __exit__(self, exc_type, exc_value, traceback): + if self.DBSession: + self.DBSession.close() + + +db = SQLDatabaseManager() +session = db.DBSession diff --git a/ttfrog/db/schema.py b/ttfrog/db/schema.py new file mode 100644 index 0000000..df8f28c --- /dev/null +++ b/ttfrog/db/schema.py @@ -0,0 +1,30 @@ +from sqlalchemy import MetaData +from sqlalchemy import Table +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy import UnicodeText +from sqlalchemy import ForeignKey +# from sqlalchemy import PrimaryKeyConstraint +# from sqlalchemy import DateTime + + +metadata = MetaData() + +Ancestry = Table( + "ancestry", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("slug", String, index=True, unique=True), + Column("name", String, index=True, unique=True), + Column("description", UnicodeText), +) + +Character = Table( + "character", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("slug", String, index=True, unique=True), + Column("name", String), + Column("ancestry_id", Integer, ForeignKey("ancestry.id")), +) diff --git a/ttfrog/path.py b/ttfrog/path.py new file mode 100644 index 0000000..251a64f --- /dev/null +++ b/ttfrog/path.py @@ -0,0 +1,31 @@ +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/ttfrog/webserver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ttfrog/webserver/application.py b/ttfrog/webserver/application.py new file mode 100644 index 0000000..10be26c --- /dev/null +++ b/ttfrog/webserver/application.py @@ -0,0 +1,59 @@ +import logging + +from tg import MinimalApplicationConfigurator +from tg.configurator.components.statics import StaticsConfigurationComponent +from tg.configurator.components.sqlalchemy import SQLAlchemyConfigurationComponent +from tg.util.bunch import Bunch + +from wsgiref.simple_server import make_server +import webhelpers2 + +from ttfrog.webserver import controllers +from ttfrog.db import db +import ttfrog.path + + +def app_globals(): + return Bunch + + +def application(): + """ + Create a TurboGears2 application + """ + + config = MinimalApplicationConfigurator() + config.register(StaticsConfigurationComponent) + config.register(SQLAlchemyConfigurationComponent) + config.update_blueprint({ + + # rendering + 'root_controller': controllers.RootController(), + 'default_renderer': 'jinja', + 'renderers': ['jinja'], + 'tg.jinja_filters': {}, + 'auto_reload_templates': True, + + # helpers + 'app_globals': app_globals, + 'helpers': webhelpers2, + 'tw2.enabled': True, + + # assets + 'serve_static': True, + 'paths': { + 'static_files': ttfrog.path.static_files(), + 'templates': [ttfrog.path.templates()], + }, + + # db + 'use_sqlalchemy': True, + 'sqlalchemy.url': db.url, + 'model': db, + }) + return config.make_wsgi_app() + + +def start(host: str, port: int, debug: bool = False) -> None: + logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}") + make_server(host, int(port), application()).serve_forever() diff --git a/ttfrog/webserver/controllers.py b/ttfrog/webserver/controllers.py new file mode 100644 index 0000000..84eef31 --- /dev/null +++ b/ttfrog/webserver/controllers.py @@ -0,0 +1,16 @@ +from tg import expose +from tg import TGController +from tg import tmpl_context +from ttfrog.db import db +from ttfrog.db.schema import Character + + +class RootController(TGController): + + def _before(self, *args, **kwargs): + tmpl_context.project_name = 'TableTop Frog' + + @expose('index.html') + def index(self): + ancestries = [row._mapping for row in db.query(db.ancestry).all()] + return dict(page='index', content=str(ancestries))