Parallelize base app with a Queue to transmit messages between clients; anonymize Blueprints akin to discord-py-interactions Extension modules

This commit is contained in:
Flare Starfall 2025-11-27 01:29:09 +01:00
parent cf0b64e5c7
commit a9a418f301
11 changed files with 271 additions and 115 deletions

19
.vscode/settings.json vendored
View File

@ -1,19 +0,0 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "always",
"source.organizeImports.ruff": "always",
"source.sort.json": "always"
},
"editor.formatOnSave": true,
"python.languageServer": "None",
"scss-to-css-compile.browsers": [
"ie > 9",
"iOS > 8",
"Android >= 4.4",
"ff > 38",
"Chrome > 38"
],
"scss-to-css-compile.compileOnSave": false,
"scss-to-css-compile.outDir": "./",
"scss-to-css-compile.output": "compressed"
}

88
app.py
View File

@ -1,32 +1,72 @@
import logging
import os
import sys
import threading
from threading import Thread
from time import sleep
from typing import final
from starfall.config import Config
from starfall.log import Log
from starfall.web import App
from starfall.types import SnapshotQueue
from starfall.web import WebUI
CURRENT_VERSION = "0.1.0-alpha.1"
@final
class Application:
app: WebUI
config: Config
log: Log
queue: SnapshotQueue
threads: dict[str, Thread]
def __init__(self):
self.log = Log()
logging.getLogger("app").debug(
"OS Type: %s\nWorking Directory: %s\nApplication version: %s",
os.name,
os.path.realpath(os.curdir),
CURRENT_VERSION,
)
self.config = Config("config.toml", "config-sample.toml")
if not self.config.get("app.run", False):
logging.getLogger("app").critical(
"Config file disallows execution.\n"
+ "Please make the necessary changes to the configuration"
+ " file and relaunch the program:\n\t%s",
os.path.realpath(self.config.file_name),
)
sys.exit()
self.threads = {}
self.queue = SnapshotQueue()
self.app = WebUI()
def start(self):
self.threads["flask"] = Thread(
target=self.app.run,
args=(
self.config,
self.queue,
),
daemon=True,
)
self.threads["flask"].start()
while threading.active_count() > 0:
sleep(0.25)
self.monitor_queue()
def monitor_queue(self):
for entry in self.queue.get_all_for_receiver("app"):
logging.getLogger("app").info("Received queue message: %r", entry)
if __name__ == "__main__":
Log.setup()
try:
Config.load()
except FileNotFoundError:
dst = Config.create()
logging.getLogger().critical(
msg="\n".join(
[
"*** Config file was not found ***",
"",
"Sample config.toml file has been created in the project's root directory:",
"[b]" + os.path.realpath("./%s" % dst) + "[/b]",
"Edit it and run the program again.",
]
),
extra={"markup": True},
)
exit()
App.__init__()
threads = [Thread(App.run())]
Application().start()

View File

@ -1,5 +1,19 @@
# == StarfallBot configuration file ==
[app]
# Whether the application is allowed to start at all.
run = false
[discord]
# Discord bot token.
token = "YOUR BOT TOKEN HERE"
[twitch]
# Twitch OAuth token.
oauth_token = "oauth:TWITCH OAUTH TOKEN"
# Twitch channel.
channel = "TWITCH CHANNEL NAME"
[web]
# What IP the web server is bound to.
# Use "localhost" until the bot is production ready.
@ -12,10 +26,3 @@ port = 5000
database_url = "sqlite:///starfallbot.db"
# Secret key, used to sign the session cookie.
secret_key = "PRIVATE SECRET KEY CHANGEME"
[discord]
token = "YOUR BOT TOKEN HERE"
[twitch]
oauth_token = "oauth:TWITCH OAUTH TOKEN"
channel = "TWITCH CHANNEL NAME"

View File

@ -1,48 +1,66 @@
import logging
import os
from shutil import copy
from typing import Any
from typing import Any, final
import tomllib
@final
class Config:
data: dict[str, Any] = dict()
"""Configuration imported from config.toml."""
"""Provides a wrapper for toml config files."""
@classmethod
def create(cls) -> str:
"""Copy config-sample.toml file to config.toml.
def __init__(self, file_name: str, sample_file_name: str):
"""Read in a config file and provide it as a :obj:`Config` object.
Does not alert the user to the modification.
Args:
file_name (str): Path to a TOML config file.
sample_file_name (str): Path to a sample TOML config file.
Used if :param:`file_name` does not exist.
"""
return copy(src="config-sample.toml", dst="config.toml")
@classmethod
def load(cls) -> None:
"""Loads the contents of the config.toml file into the class.
self.file_name = file_name
"""str: Path to a TOML config file."""
Raises:
FileNotFoundError: If the file does not exist.
"""
if not cls.file_exists():
raise FileNotFoundError("config.toml does not exist")
with open("config.toml", "rb") as f:
cls.data = tomllib.load(f)
self.sample_file_name = sample_file_name
"""str: Path to a sample TOML config file. Used if :attr:`file_name` does not exist."""
@classmethod
def file_exists(cls) -> bool:
"""Whether the config.toml file exists or needs to be created.
self._toml_data = {}
"""dict: TOML data, parsed to a nested dict."""
try:
with open(str(self.file_name), "rb") as f:
self._toml_data = tomllib.load(f)
except OSError as error:
file_name = copy(str(self.sample_file_name), str(self.file_name))
logging.getLogger().warning(
"Could not read config file %r, reason: %r",
os.path.realpath(file_name),
error,
)
def get(self, key: str, default: Any = None):
"""Recursively calls `get(key, default)` to search nested dictionary.
Examples:
Fetch a nested configuration value.
>>> config = Config("config.toml", "config-sample.toml")
>>> config.get("web.run", False)
True
Returns:
bool: True if path exists and is a file, False otherwise.
Any: Value at key if found, provided default or None otherwise.
"""
return os.path.exists("config.toml") and os.path.isfile("config.toml")
nested_key: list[str] = key.split(".")
current_item: Any = self._toml_data
@classmethod
def get(cls, key: str, default: Any):
"""Get config key, if it exists, or return a default.
# Traverse the dict for every depth of the key the user provided.
# If any part of the key does not exist, bail and return default.
for part in nested_key:
if part not in current_item:
return default
current_item = current_item[part]
Returns:
Any: Result, or default if key does not exist.
"""
return cls.data.get(key, default)
# No parts left; `current_item` now holds the requested value.
return current_item

View File

@ -5,12 +5,11 @@ from rich.logging import RichHandler
class Log:
@classmethod
def setup(cls) -> None:
def __init__(self) -> None:
handler = RichHandler(level=logging.DEBUG, rich_tracebacks=True)
logging.basicConfig(
level=logging.DEBUG,
format="%(message)s",
format="%(message)s [%(name)s]",
datefmt="[%Y-%m-%d %H:%M:%S]",
handlers=[handler],
)

28
starfall/types.py Normal file
View File

@ -0,0 +1,28 @@
from queue import Queue
from typing import Any, NamedTuple, final
@final
class QueueMessage(NamedTuple):
sender: str
receiver: str
message: Any
variable: str | None = None
@final
class SnapshotQueue(Queue[QueueMessage]):
def snapshot(self) -> list[QueueMessage]:
with self.mutex:
return list(self.queue)
def get_all_for_receiver(self, receiver: str) -> list[QueueMessage]:
res: list[QueueMessage] = []
with self.mutex:
entries = [entry for entry in self.queue if entry.receiver == receiver]
for entry in entries:
index = self.queue.index(entry)
self.queue.rotate(-index)
res.append(self.queue.popleft())
self.queue.rotate(index)
return res

View File

@ -1,36 +1,59 @@
import importlib
import logging
import os
from typing import Any
from inspect import isclass
from pathlib import Path
from pkgutil import iter_modules
from typing import final
from flask import Flask
from flask import Blueprint, Flask
from starfall.config import Config
from starfall.db import db
from starfall.web.home import home_blueprint
from starfall.types import SnapshotQueue
from starfall.web.blueprints.base import BaseBlueprint
class App:
app: Flask = Flask(__name__)
config: dict[str, Any] = dict() # pyright: ignore[reportExplicitAny]
@final
class WebUI:
def __init__(self):
self.config: Config | None = None
self.queue: SnapshotQueue | None = None
self.app: Flask | None = None
self.blueprint: Blueprint = Blueprint("main", __name__)
@classmethod
def __init__(cls):
cls.load_config()
cls.app.config.update( # pyright: ignore[reportUnknownMemberType]
SECRET_KEY=cls.config.get("secret_key"),
SQLALCHEMY_DATABASE_URI=cls.config.get("database_url"),
def run(self, config: Config, queue: SnapshotQueue):
self.config = config
self.queue = queue
logging.getLogger("web").debug("Hello from %r", type(self))
self.app = Flask(__name__)
self.app.config.update(
SECRET_KEY=str(self.config.get("web.secret_key")),
SQLALCHEMY_DATABASE_URI=str(self.config.get("web.database_url")),
)
cls.app.root_path = os.path.realpath(".")
cls.app.static_folder = os.path.realpath("./web/static")
cls.app.template_folder = "web"
self.app.root_path = os.path.realpath(".")
self.app.static_folder = os.path.realpath("./web/static")
self.app.template_folder = "web"
db.init_app(cls.app)
cls.app.register_blueprint(home_blueprint)
db.init_app(self.app)
self.import_blueprints()
self.app.register_blueprint(self.blueprint, options={"queue": self.queue})
self.app.run(host=self.config.get("web.host"), port=self.config.get("web.port"))
@classmethod
def load_config(cls):
cls.config = Config.get("web", {})
def import_blueprints(self):
path = os.path.realpath(os.path.dirname(__file__) + os.sep + "blueprints")
for _, module_name, _ in iter_modules([path], f"{__name__}.blueprints."):
if module_name.endswith("base"):
continue
@classmethod
def run(cls):
cls.app.run(host=cls.config.get("host"), port=cls.config.get("port"))
module = importlib.import_module(module_name)
for attribute_name in dir(module):
attribute = getattr(module, attribute_name)
if isclass(attribute) and issubclass(attribute, BaseBlueprint):
globals()[attribute_name] = attribute
globals()[attribute_name](self.blueprint)

View File

@ -0,0 +1,39 @@
import inspect
import logging
from abc import ABC
from flask import Blueprint
from flask.blueprints import BlueprintSetupState
from starfall.types import SnapshotQueue
class BaseBlueprint(ABC):
def __init__(self, blueprint: Blueprint):
blueprint.record(self.on_blueprint_setup)
if type(self) != BaseBlueprint: # noqa: E721
self._debug("Attaching blueprint of type %r to web service", type(self))
def on_blueprint_setup(self, state: BlueprintSetupState):
self.queue: SnapshotQueue = state.options["options"]["queue"]
if type(self) != BaseBlueprint: # noqa: E721
self._info("Blueprint of type %r successfully set up", type(self))
def _log_access(self):
# There is always a previous frame - This function is called when a
# route is accessed by a user through a method unknown to this one.
self._info("Route access: %r", inspect.currentframe().f_back.f_code.co_name)
def _debug(self, msg: str, *args: object):
logging.getLogger("web").debug(msg, *args)
def _info(self, msg: str, *args: object):
logging.getLogger("web").info(msg, *args)
def _warn(self, msg: str, *args: object):
logging.getLogger("web").warning(msg, *args)
def _crit(self, msg: str, *args: object):
logging.getLogger("web").critical(msg, *args)

View File

@ -0,0 +1,13 @@
from flask import Blueprint, render_template
from starfall.web.blueprints.base import BaseBlueprint
class HomeBlueprint(BaseBlueprint):
def __init__(self, blueprint: Blueprint) -> None:
super().__init__(blueprint)
blueprint.add_url_rule("/", view_func=self.index)
def index(self):
self._log_access()
return render_template("home.html")

View File

@ -1,8 +0,0 @@
from flask import Blueprint, render_template
home_blueprint: Blueprint = Blueprint("main", __name__)
@home_blueprint.route("/")
def index():
return render_template("home.html")

View File

@ -8,9 +8,11 @@
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta name="description" content="StarfallBot administation interface" />
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" />
<link rel="preload" href="{{ url_for('static', filename='img/logo.png') }}" as="image" />
<link rel="preload" href="{{ url_for('static', filename='style/fonts.css') }}" as="style" />
<link rel="preload" href="{{ url_for('static', filename='style/main.css') }}" as="style" />
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='style/fonts.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='style/main.css') }}" />
@ -22,6 +24,20 @@
{% block beforeMain %}
{% endblock %}
<header>
<div class="container">
<nav>
{% block beforeNav %}
{% endblock %}
<a href="/">Home</a>
{% block afterNav %}
{% endblock %}
</nav>
</div>
</header>
<main>
{% block beforeMainContainer %}
{% endblock %}