diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index e555990..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -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"
-}
\ No newline at end of file
diff --git a/app.py b/app.py
index 50e8fd7..5cf36e8 100644
--- a/app.py
+++ b/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()
diff --git a/config-sample.toml b/config-sample.toml
index 0c028b9..68c429c 100644
--- a/config-sample.toml
+++ b/config-sample.toml
@@ -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"
\ No newline at end of file
diff --git a/starfall/config.py b/starfall/config.py
index 2f812c1..ac9f268 100644
--- a/starfall/config.py
+++ b/starfall/config.py
@@ -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
diff --git a/starfall/log.py b/starfall/log.py
index 02d4d5c..eab4278 100644
--- a/starfall/log.py
+++ b/starfall/log.py
@@ -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],
)
diff --git a/starfall/types.py b/starfall/types.py
new file mode 100644
index 0000000..2b4bb57
--- /dev/null
+++ b/starfall/types.py
@@ -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
diff --git a/starfall/web/__init__.py b/starfall/web/__init__.py
index 3621aba..00106bb 100644
--- a/starfall/web/__init__.py
+++ b/starfall/web/__init__.py
@@ -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)
diff --git a/starfall/web/blueprints/base.py b/starfall/web/blueprints/base.py
new file mode 100644
index 0000000..843ee0d
--- /dev/null
+++ b/starfall/web/blueprints/base.py
@@ -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)
diff --git a/starfall/web/blueprints/home.py b/starfall/web/blueprints/home.py
new file mode 100644
index 0000000..0d65144
--- /dev/null
+++ b/starfall/web/blueprints/home.py
@@ -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")
diff --git a/starfall/web/home.py b/starfall/web/home.py
deleted file mode 100644
index c3a6ff5..0000000
--- a/starfall/web/home.py
+++ /dev/null
@@ -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")
diff --git a/web/index.html b/web/index.html
index a5e4aa0..72ea173 100644
--- a/web/index.html
+++ b/web/index.html
@@ -8,9 +8,11 @@
-
+
+
+
@@ -22,6 +24,20 @@
{% block beforeMain %}
{% endblock %}
+
+
+
+
+
+
{% block beforeMainContainer %}
{% endblock %}