Fix CLI, add bootstrapping, config handling

This commit is contained in:
evilchili 2025-09-25 22:31:37 -07:00
parent fc1f121853
commit 8ec9f41b6c
6 changed files with 122 additions and 35 deletions

View File

@ -1,27 +1,108 @@
import os import io
import sys import sys
from pathlib import Path
from types import SimpleNamespace
from dotenv import dotenv_values
from flask import Flask from flask import Flask
from grung.db import GrungDB from grung.db import GrungDB
from tinydb.storages import MemoryStorage from tinydb.storages import MemoryStorage
from ttfrog import schema from ttfrog import schema
from ttfrog.exceptions import ApplicationNotInitializedError
class ApplicationContext: class ApplicationContext:
"""
The global context for the application, this class provides access to the Flask app instance, the GrungDB instance,
and the loaded configuration.
To prevent multiple contexts from being created, the class is instantiated at import time and replaces the module in
the symbol table. The first time it is imported, callers should call both .load_config() and .initialize(); this is
typically done at program start.
After being intialized, callers can import ttfrog.app and interact with the ApplicationContext instance directly:
>>> from ttfrog import app
>>> print(app.config.NAME)
ttfrog
"""
CONFIG_DEFAULTS = """
# ttfrog Defaults
NAME=ttfrog
LOG_LEVEL=INFO
SECRET_KEY=fnord
IN_MEMORY_DB=
DATA_ROOT=~/.dnd/ttfrog/
ADMIN_USERNAME=admin
ADMIN_EMAIL=
"""
def __init__(self): def __init__(self):
self.web: Flask = Flask("ttfrog") self.config: SimpleNamespace = None
self.web: Flask = None
self.db: GrungDB = None self.db: GrungDB = None
self._initialized = False self._initialized = False
def initialize(self, db: GrungDB = None): def load_config(self, defaults: Path | None = Path("~/.dnd/ttfrog/defaults"), **overrides) -> None:
if not self._initialized: """
self.web.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "secret string") Load the user configuration from the following in sources, in order:
if os.environ.get("TTFROG_IN_MEMORY_DB"):
1. ApplicationContext.CONFIG_DEFAULTS
2. The user's configuration defaults file, if any
3. Overrides specified by the caller, if any
Once the configuration is loaded, the path attribute is also configured.
"""
config_file = defaults.expanduser() if defaults else None
self.config = SimpleNamespace(
**{
**dotenv_values(stream=io.StringIO(ApplicationContext.CONFIG_DEFAULTS)),
**(dotenv_values(config_file) if config_file else {}),
**overrides,
}
)
data_root = Path(self.config.DATA_ROOT).expanduser()
self.path = SimpleNamespace(
config=config_file,
data_root=data_root,
database=data_root / f"{self.config.NAME}.json",
)
def initialize(self, db: GrungDB = None, force: bool = False) -> None:
"""
Instantiate both the database and the flask application.
"""
if force or not self._initialized:
if self.config.IN_MEMORY_DB:
self.db = GrungDB.with_schema(schema, storage=MemoryStorage) self.db = GrungDB.with_schema(schema, storage=MemoryStorage)
else: else:
self.db = GrungDB.with_schema(schema, "ttfrog.db.json") self.db = GrungDB.with_schema(schema, self.path.database)
self.web = Flask(self.config.NAME)
self.web.config["SECRET_KEY"] = self.config.SECRET_KEY
self._initialized = True self._initialized = True
def check_state(self) -> None:
if not self._initialized:
raise ApplicationNotInitializedError("This action requires the application to be initialized.")
def bootstrap(self):
"""
Bootstrap the database entries by populating the first Page, the Admin user and the Admins group.
"""
self.check_state()
self.db.save(schema.Page(parent_id=None, stub="", title="_", body=""))
admin = schema.User(name=self.config.ADMIN_USERNAME, email=self.config.ADMIN_EMAIL)
self.db.save(admin)
self.db.save(schema.Group(name="admins", users=[admin]))
sys.modules[__name__] = ApplicationContext() sys.modules[__name__] = ApplicationContext()

View File

@ -1,28 +1,17 @@
import io
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import click import click
import typer import typer
from dotenv import load_dotenv
from flask.cli import FlaskGroup from flask.cli import FlaskGroup
from rich import print
from rich.logging import RichHandler from rich.logging import RichHandler
import ttfrog.app import ttfrog.app
CONFIG_DEFAULTS = """
# ttfrog Defaults
LOG_LEVEL=INFO
"""
main_app = typer.Typer() main_app = typer.Typer()
app_state = dict(
config_file=Path("~/.config/ttfrog.conf").expanduser(),
)
logger = logging.getLogger("ttfrog.cli") logger = logging.getLogger("ttfrog.cli")
@ -32,31 +21,42 @@ def callback(
verbose: bool = typer.Option(False, help="Enable verbose output."), verbose: bool = typer.Option(False, help="Enable verbose output."),
log_level: str = typer.Option("error", help=" Set the log level."), log_level: str = typer.Option("error", help=" Set the log level."),
config_file: Optional[Path] = typer.Option( config_file: Optional[Path] = typer.Option(
app_state["config_file"], "~/.dnd/ttfrog/defaults",
help="Path to the ttfrog configuration file", help="Path to the ttfrog configuration file",
), ),
): ):
""" """
Configure the execution environment with global parameters. Configure the execution environment with global parameters.
""" """
app_state["config_file"] = config_file
load_dotenv(stream=io.StringIO(CONFIG_DEFAULTS))
load_dotenv(app_state["config_file"])
logging.basicConfig( logging.basicConfig(
format="%(message)s", format="%(message)s",
level=getattr(logging, log_level.upper()), level=getattr(logging, log_level.upper()),
handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])], handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
) )
app_state["verbose"] = verbose
ttfrog.app.load_config(config_file)
ttfrog.app.initialize() ttfrog.app.initialize()
ttfrog.app.web.shell_context_processors.append(make_shell_context)
if context.invoked_subcommand is None: if context.invoked_subcommand is None:
logger.debug("No command specified; invoking default handler.") logger.debug("No command specified; invoking default handler.")
run(context) run(context)
@main_app.command()
def init(context: typer.Context, drop: bool = typer.Option(False, help="Drop tabless before initializing.")):
"""
Initialize the database.
"""
if drop:
ttfrog.app.db.drop_tables()
ttfrog.app.db.close()
ttfrog.app.initialize(force=True)
ttfrog.app.bootstrap()
print(ttfrog.app.db)
@main_app.command()
def run(context: typer.Context): def run(context: typer.Context):
""" """
The default CLI entrypoint is ttfrog.cli.run(). The default CLI entrypoint is ttfrog.cli.run().
@ -64,7 +64,6 @@ def run(context: typer.Context):
ttfrog.app.web.run() ttfrog.app.web.run()
@ttfrog.app.web.shell_context_processor
def make_shell_context(): def make_shell_context():
return ttfrog.app return ttfrog.app

5
src/ttfrog/exceptions.py Normal file
View File

@ -0,0 +1,5 @@
class ApplicationNotInitializedError(Exception):
"""
Thrown when attempting to access methods on the
ApplicationContext before it has been initialized.
"""

View File

@ -9,3 +9,12 @@ class User(Record):
class Group(Record): class Group(Record):
_fields = [Field("name", unique=True), Field("users", List[User])] _fields = [Field("name", unique=True), Field("users", List[User])]
class Page(Record):
_fields = [
Field("parent_id"),
Field("stub"),
Field("title"),
Field("body"),
]

View File

@ -5,8 +5,8 @@ from ttfrog import schema
@pytest.fixture @pytest.fixture
def app(monkeypatch): def app():
monkeypatch.setenv("TTFROG_IN_MEMORY_DB", "1") ttfrog.app.load_config(defaults=None, IN_MEMORY_DB=1)
ttfrog.app.initialize() ttfrog.app.initialize()
yield ttfrog.app yield ttfrog.app
ttfrog.app.db.close() ttfrog.app.db.close()

View File

@ -1,7 +0,0 @@
import pytest
@pytest.mark.xfail
def test_tests_are_implemented():
print("Yyou have not implemented any tests yet.")
assert False