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:
parent
cf0b64e5c7
commit
a9a418f301
|
|
@ -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
88
app.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue