Fix CLI, add bootstrapping, config handling
This commit is contained in:
parent
fc1f121853
commit
8ec9f41b6c
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
5
src/ttfrog/exceptions.py
Normal file
5
src/ttfrog/exceptions.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
class ApplicationNotInitializedError(Exception):
|
||||
"""
|
||||
Thrown when attempting to access methods on the
|
||||
ApplicationContext before it has been initialized.
|
||||
"""
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_tests_are_implemented():
|
||||
print("Yyou have not implemented any tests yet.")
|
||||
assert False
|
Loading…
Reference in New Issue
Block a user