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 logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
from time import sleep
|
||||||
|
from typing import final
|
||||||
|
|
||||||
from starfall.config import Config
|
from starfall.config import Config
|
||||||
from starfall.log import Log
|
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__":
|
if __name__ == "__main__":
|
||||||
Log.setup()
|
Application().start()
|
||||||
|
|
||||||
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())]
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
# == StarfallBot configuration file ==
|
# == 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]
|
[web]
|
||||||
# What IP the web server is bound to.
|
# What IP the web server is bound to.
|
||||||
# Use "localhost" until the bot is production ready.
|
# Use "localhost" until the bot is production ready.
|
||||||
|
|
@ -12,10 +26,3 @@ port = 5000
|
||||||
database_url = "sqlite:///starfallbot.db"
|
database_url = "sqlite:///starfallbot.db"
|
||||||
# Secret key, used to sign the session cookie.
|
# Secret key, used to sign the session cookie.
|
||||||
secret_key = "PRIVATE SECRET KEY CHANGEME"
|
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
|
import os
|
||||||
from shutil import copy
|
from shutil import copy
|
||||||
from typing import Any
|
from typing import Any, final
|
||||||
|
|
||||||
import tomllib
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
@final
|
||||||
class Config:
|
class Config:
|
||||||
data: dict[str, Any] = dict()
|
"""Provides a wrapper for toml config files."""
|
||||||
"""Configuration imported from config.toml."""
|
|
||||||
|
|
||||||
@classmethod
|
def __init__(self, file_name: str, sample_file_name: str):
|
||||||
def create(cls) -> str:
|
"""Read in a config file and provide it as a :obj:`Config` object.
|
||||||
"""Copy config-sample.toml file to config.toml.
|
|
||||||
|
|
||||||
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
|
self.file_name = file_name
|
||||||
def load(cls) -> None:
|
"""str: Path to a TOML config file."""
|
||||||
"""Loads the contents of the config.toml file into the class.
|
|
||||||
|
|
||||||
Raises:
|
self.sample_file_name = sample_file_name
|
||||||
FileNotFoundError: If the file does not exist.
|
"""str: Path to a sample TOML config file. Used if :attr:`file_name` 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)
|
|
||||||
|
|
||||||
@classmethod
|
self._toml_data = {}
|
||||||
def file_exists(cls) -> bool:
|
"""dict: TOML data, parsed to a nested dict."""
|
||||||
"""Whether the config.toml file exists or needs to be created.
|
|
||||||
|
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:
|
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
|
# Traverse the dict for every depth of the key the user provided.
|
||||||
def get(cls, key: str, default: Any):
|
# If any part of the key does not exist, bail and return default.
|
||||||
"""Get config key, if it exists, or return a default.
|
for part in nested_key:
|
||||||
|
if part not in current_item:
|
||||||
|
return default
|
||||||
|
current_item = current_item[part]
|
||||||
|
|
||||||
Returns:
|
# No parts left; `current_item` now holds the requested value.
|
||||||
Any: Result, or default if key does not exist.
|
return current_item
|
||||||
"""
|
|
||||||
return cls.data.get(key, default)
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,11 @@ from rich.logging import RichHandler
|
||||||
|
|
||||||
|
|
||||||
class Log:
|
class Log:
|
||||||
@classmethod
|
def __init__(self) -> None:
|
||||||
def setup(cls) -> None:
|
|
||||||
handler = RichHandler(level=logging.DEBUG, rich_tracebacks=True)
|
handler = RichHandler(level=logging.DEBUG, rich_tracebacks=True)
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG,
|
level=logging.DEBUG,
|
||||||
format="%(message)s",
|
format="%(message)s [%(name)s]",
|
||||||
datefmt="[%Y-%m-%d %H:%M:%S]",
|
datefmt="[%Y-%m-%d %H:%M:%S]",
|
||||||
handlers=[handler],
|
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
|
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.config import Config
|
||||||
from starfall.db import db
|
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:
|
@final
|
||||||
app: Flask = Flask(__name__)
|
class WebUI:
|
||||||
config: dict[str, Any] = dict() # pyright: ignore[reportExplicitAny]
|
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 run(self, config: Config, queue: SnapshotQueue):
|
||||||
def __init__(cls):
|
self.config = config
|
||||||
cls.load_config()
|
self.queue = queue
|
||||||
cls.app.config.update( # pyright: ignore[reportUnknownMemberType]
|
|
||||||
SECRET_KEY=cls.config.get("secret_key"),
|
logging.getLogger("web").debug("Hello from %r", type(self))
|
||||||
SQLALCHEMY_DATABASE_URI=cls.config.get("database_url"),
|
|
||||||
|
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(".")
|
self.app.root_path = os.path.realpath(".")
|
||||||
cls.app.static_folder = os.path.realpath("./web/static")
|
self.app.static_folder = os.path.realpath("./web/static")
|
||||||
cls.app.template_folder = "web"
|
self.app.template_folder = "web"
|
||||||
|
|
||||||
db.init_app(cls.app)
|
db.init_app(self.app)
|
||||||
cls.app.register_blueprint(home_blueprint)
|
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 import_blueprints(self):
|
||||||
def load_config(cls):
|
path = os.path.realpath(os.path.dirname(__file__) + os.sep + "blueprints")
|
||||||
cls.config = Config.get("web", {})
|
for _, module_name, _ in iter_modules([path], f"{__name__}.blueprints."):
|
||||||
|
if module_name.endswith("base"):
|
||||||
|
continue
|
||||||
|
|
||||||
@classmethod
|
module = importlib.import_module(module_name)
|
||||||
def run(cls):
|
|
||||||
cls.app.run(host=cls.config.get("host"), port=cls.config.get("port"))
|
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 http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
<meta name="description" content="StarfallBot administation interface" />
|
<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/fonts.css') }}" as="style" />
|
||||||
<link rel="preload" href="{{ url_for('static', filename='style/main.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/fonts.css') }}" />
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style/main.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', filename='style/main.css') }}" />
|
||||||
|
|
||||||
|
|
@ -22,6 +24,20 @@
|
||||||
{% block beforeMain %}
|
{% block beforeMain %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="container">
|
||||||
|
<nav>
|
||||||
|
{% block beforeNav %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<a href="/">Home</a>
|
||||||
|
|
||||||
|
{% block afterNav %}
|
||||||
|
{% endblock %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{% block beforeMainContainer %}
|
{% block beforeMainContainer %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue