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
|
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()
|
||||||
|
|
|
@ -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
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):
|
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"),
|
||||||
|
]
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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