From 8ec9f41b6ce912036c1f49c9152a80c90328fb57 Mon Sep 17 00:00:00 2001 From: evilchili Date: Thu, 25 Sep 2025 22:31:37 -0700 Subject: [PATCH] Fix CLI, add bootstrapping, config handling --- src/ttfrog/app.py | 95 +++++++++++++++++++++++++++++++++++++--- src/ttfrog/cli.py | 37 ++++++++-------- src/ttfrog/exceptions.py | 5 +++ src/ttfrog/schema.py | 9 ++++ test/test_db.py | 4 +- test/test_ttfrog.py | 7 --- 6 files changed, 122 insertions(+), 35 deletions(-) create mode 100644 src/ttfrog/exceptions.py delete mode 100644 test/test_ttfrog.py diff --git a/src/ttfrog/app.py b/src/ttfrog/app.py index b9cb9a7..96976f9 100644 --- a/src/ttfrog/app.py +++ b/src/ttfrog/app.py @@ -1,27 +1,108 @@ -import os +import io import sys +from pathlib import Path +from types import SimpleNamespace +from dotenv import dotenv_values from flask import Flask from grung.db import GrungDB from tinydb.storages import MemoryStorage from ttfrog import schema +from ttfrog.exceptions import ApplicationNotInitializedError 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): - self.web: Flask = Flask("ttfrog") + self.config: SimpleNamespace = None + self.web: Flask = None self.db: GrungDB = None self._initialized = False - def initialize(self, db: GrungDB = None): - if not self._initialized: - self.web.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "secret string") - if os.environ.get("TTFROG_IN_MEMORY_DB"): + def load_config(self, defaults: Path | None = Path("~/.dnd/ttfrog/defaults"), **overrides) -> None: + """ + Load the user configuration from the following in sources, in order: + + 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) 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 + 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() diff --git a/src/ttfrog/cli.py b/src/ttfrog/cli.py index 368fbbc..1bdfd34 100644 --- a/src/ttfrog/cli.py +++ b/src/ttfrog/cli.py @@ -1,28 +1,17 @@ -import io import logging from pathlib import Path from typing import Optional import click import typer -from dotenv import load_dotenv from flask.cli import FlaskGroup +from rich import print from rich.logging import RichHandler import ttfrog.app -CONFIG_DEFAULTS = """ -# ttfrog Defaults - -LOG_LEVEL=INFO -""" - main_app = typer.Typer() -app_state = dict( - config_file=Path("~/.config/ttfrog.conf").expanduser(), -) - logger = logging.getLogger("ttfrog.cli") @@ -32,31 +21,42 @@ def callback( verbose: bool = typer.Option(False, help="Enable verbose output."), log_level: str = typer.Option("error", help=" Set the log level."), config_file: Optional[Path] = typer.Option( - app_state["config_file"], + "~/.dnd/ttfrog/defaults", help="Path to the ttfrog configuration file", ), ): """ 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( format="%(message)s", level=getattr(logging, log_level.upper()), handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])], ) - app_state["verbose"] = verbose + ttfrog.app.load_config(config_file) ttfrog.app.initialize() + ttfrog.app.web.shell_context_processors.append(make_shell_context) if context.invoked_subcommand is None: logger.debug("No command specified; invoking default handler.") 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): """ The default CLI entrypoint is ttfrog.cli.run(). @@ -64,7 +64,6 @@ def run(context: typer.Context): ttfrog.app.web.run() -@ttfrog.app.web.shell_context_processor def make_shell_context(): return ttfrog.app diff --git a/src/ttfrog/exceptions.py b/src/ttfrog/exceptions.py new file mode 100644 index 0000000..5a07048 --- /dev/null +++ b/src/ttfrog/exceptions.py @@ -0,0 +1,5 @@ +class ApplicationNotInitializedError(Exception): + """ + Thrown when attempting to access methods on the + ApplicationContext before it has been initialized. + """ diff --git a/src/ttfrog/schema.py b/src/ttfrog/schema.py index 16f7f14..eeea97e 100644 --- a/src/ttfrog/schema.py +++ b/src/ttfrog/schema.py @@ -9,3 +9,12 @@ class User(Record): class Group(Record): _fields = [Field("name", unique=True), Field("users", List[User])] + + +class Page(Record): + _fields = [ + Field("parent_id"), + Field("stub"), + Field("title"), + Field("body"), + ] diff --git a/test/test_db.py b/test/test_db.py index fea1f96..18d036f 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -5,8 +5,8 @@ from ttfrog import schema @pytest.fixture -def app(monkeypatch): - monkeypatch.setenv("TTFROG_IN_MEMORY_DB", "1") +def app(): + ttfrog.app.load_config(defaults=None, IN_MEMORY_DB=1) ttfrog.app.initialize() yield ttfrog.app ttfrog.app.db.close() diff --git a/test/test_ttfrog.py b/test/test_ttfrog.py deleted file mode 100644 index e3c0b4e..0000000 --- a/test/test_ttfrog.py +++ /dev/null @@ -1,7 +0,0 @@ -import pytest - - -@pytest.mark.xfail -def test_tests_are_implemented(): - print("Yyou have not implemented any tests yet.") - assert False