commit
17da4a73ee
35
pyproject.toml
Normal file
35
pyproject.toml
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
[tool.poetry]
|
||||||
|
name = "tabletop-frog"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = ["evilchili <evilchili@gmail.com>"]
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
0
ttfrog/__init__.py
Normal file
0
ttfrog/__init__.py
Normal file
0
ttfrog/assets/__init__.py
Normal file
0
ttfrog/assets/__init__.py
Normal file
0
ttfrog/assets/public/static/styles.css
Normal file
0
ttfrog/assets/public/static/styles.css
Normal file
0
ttfrog/assets/templates/__init__.py
Normal file
0
ttfrog/assets/templates/__init__.py
Normal file
33
ttfrog/assets/templates/index.html
Normal file
33
ttfrog/assets/templates/index.html
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{{ tmpl_context.project_name }}</title>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="{{ tmpl_context.project_name }}">
|
||||||
|
|
||||||
|
<meta name="og:title" content="{{ tmpl_context.project_name }}">
|
||||||
|
<meta name="og:description" content="{{ tmpl_context.project_name }}">
|
||||||
|
<meta name="og:url" content="">
|
||||||
|
<meta name="og:type" content="text">
|
||||||
|
<meta name="og:provider_name" content="{{ tmpl_context.project_name }}">
|
||||||
|
<!--
|
||||||
|
<meta name="og:image" content="/static/45.svg">
|
||||||
|
-->
|
||||||
|
|
||||||
|
<link rel='stylesheet' href='/static/styles.css' />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||||
|
-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ tmpl_context.project_name }}: {{ page }}</h1>
|
||||||
|
<pre>
|
||||||
|
{{ content }}
|
||||||
|
</pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
107
ttfrog/cli.py
Normal file
107
ttfrog/cli.py
Normal file
|
@ -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()
|
4
ttfrog/db/__init__.py
Normal file
4
ttfrog/db/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from .manager import db, session
|
||||||
|
|
||||||
|
|
||||||
|
__ALL__ = [db, session]
|
47
ttfrog/db/bootstrap.py
Normal file
47
ttfrog/db/bootstrap.py
Normal file
|
@ -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}")
|
54
ttfrog/db/manager.py
Normal file
54
ttfrog/db/manager.py
Normal file
|
@ -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
|
30
ttfrog/db/schema.py
Normal file
30
ttfrog/db/schema.py
Normal file
|
@ -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")),
|
||||||
|
)
|
31
ttfrog/path.py
Normal file
31
ttfrog/path.py
Normal file
|
@ -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'
|
0
ttfrog/webserver/__init__.py
Normal file
0
ttfrog/webserver/__init__.py
Normal file
59
ttfrog/webserver/application.py
Normal file
59
ttfrog/webserver/application.py
Normal file
|
@ -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()
|
16
ttfrog/webserver/controllers.py
Normal file
16
ttfrog/webserver/controllers.py
Normal file
|
@ -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))
|
Loading…
Reference in New Issue
Block a user