diff --git a/DOCS.md b/DOCS.md index bb1e0e9..f64e6fe 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,4 +1,4 @@ -# Flask++ v0.3.x Documentation +# Flask++ v0.4.x Documentation ## Core @@ -35,7 +35,7 @@ SERVER_NAME = localhost SECRET_KEY = supersecret [database] -DATABASE_URL = sqlite:///appdata.db +DATABASE_URI = sqlite:///appdata.db [redis] REDIS_URL = redis://localhost:6379 @@ -109,7 +109,7 @@ class DefaultConfig: # ------------------------------------------------- # Flask-SQLAlchemy & Flask-Migrate # ------------------------------------------------- - SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///database.db") + SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URI", "sqlite:///database.db") SQLALCHEMY_TRACK_MODIFICATIONS = False # ------------------------------------------------- @@ -249,7 +249,7 @@ At first, you would create a new python package inside **project_root/modules**. #### Manifest -For your module to be recognized by Flask++ you need to create a **manifest.json** file inside your module package. It has to contain at least one field named "version". The following version string formats are supported (they are not case-sensitive): +For your module to be recognized by Flask++ you need to create a **manifest.json** file inside your module package. It has to contain at least two fields: "version" and "type". The following version string formats are supported (they are not case-sensitive): ``` x @@ -276,11 +276,22 @@ A fully qualified manifest file would then look like this: "name": "Module Name", "description": "This module does ...", "version": "0.1", + "type": "default", // can be either "default" or "base" + // -> If you set the module type to "base", your module can be used to extend other modules. + // Base modules are not allowed to be registered. Do not enable them in your app config. "requires": { "fpp": ">=0.3.5", // the minimum required version of Flask++ + "packages": [ // PyPI packages that are required by your module + // e.g. "numpy", "pandas", + // ... + + // packages will be installed when your module gets installed + ], "modules": { // other modules that are required by your module "module_id_01": "==0.2", - "module_id_02": "<0.7" + "module_id_02": "<0.7", + // -> Base module ids are also allowed here. + // In this case the module loader will check if a parent module that extends it is enabled. } } } @@ -293,14 +304,16 @@ Inside your **\_\_init__.py** file, you create a `module: Module` variable and o ```python from flaskpp import Module from flaskpp.utils import enabled +# from flaskpp.modules import import_base from flaskpp.exceptions import ModuleError module = Module( __file__, __name__, - - # Optional a list of required_extensions: - [ + # extends=import_base("base_module_id"), + # -> You can use base modules as extensions for your module. + # import_base will return None if the module is not installed or not a base module. + # required_extensions=[ # "sqlalchemy", # "socket", # "babel", @@ -310,14 +323,19 @@ module = Module( # "cache", # "api", # "jwt_extended" - ], - - # And you can optionally turn off init_routes_on_enable (default is True): - False + # ], + # -> You can set Flask++'s pre-wired extensions as required. + # init_routes_on_enable=False, + # -> You can optionally turn off automated route initialization when the module gets enabled. + # This could be especially useful, if you are working with socket i18n. (More in the later chapters.) + # allowed_for_home=False, + # -> You can disable the ability for your module to be the home module of the app. + # allow_frontend_engine=False, + # -> You can disable the frontend engine of your module if FRONTEND_ENGINE is set to 1. ) # Now if you need to do stuff when your module gets enabled: -@module.on_enable +@module.on_enable # This hook cannot be used by base modules. def on_enable(app: FlaskPP): # Check for required features, for example: if not enabled("FPP_PROCESSING"): @@ -354,11 +372,15 @@ def init_handling(mod: Module): for file in _package.rglob("*.py"): if file.stem == "__init__" or file.stem.startswith("noinit"): continue - handler_name = file.stem + + rel = file.relative_to(_package).with_suffix("") + handler_name = ".".join(rel.parts) + handler = import_module(f"{mod.import_name}.handling.{handler_name}") handle_request = getattr(handler, "handle_request", None) if not handle_request: continue + mod.handler(handler_name)(handle_request) ``` @@ -397,7 +419,7 @@ def init_routes(mod: Module): #### Config -You can optionally create a **config.py** file inside your module package. There you can create your modules config class (like mentioned earlier in the ["Configuration" chapter](#configuration)) if you need: +You can optionally create a **config.py** file inside your module package. There you can create your modules config class (like mentioned earlier in the ["Configuration" chapter](#configuration)) and add a config function for the `fpp setup` command if you need: ```python from flaskpp.app.config import register_config @@ -409,6 +431,37 @@ from flaskpp.app.config import register_config class ModuleConfig: # TODO: Overwrite default config values or provide your own pass + +# Or if you are working with base and extending modules, you might do something like this: +# base_module_package/config.py + +class BaseModuleConfig: + # ... + pass + +# extended_module_package/config.py +from modules.base_module_package.config import BaseModuleConfig + +@register_config() +class ExtendingModuleConfig(BaseModuleConfig): + # ... + pass + +def module_config(): + # return { + # TODO: Write required config data (will be prompted by the setup if module is set to 1) + # -> Base module configs will be prompted together with their extending modules (parent), when it gets enabled. + + # "protected_MY_SECRET": token_hex(32), + # -> protected keys won't be prompted to the user + + # "default_FEATURE_KEY": "Hello World!", + # -> default keys will be prompted with their default value shown (and written with if input left empty) + + # "ADDITIONAL_DATA": "", + # -> simple config prompt without default value + # } + pass ``` #### Data Package @@ -427,9 +480,16 @@ def init_models(mod: Module): for file in _package.rglob("*.py"): if file.stem == "__init__" or file.stem.startswith("noinit"): continue - import_module(f"{mod.import_name}.data.{file.stem}") + rel = file.relative_to(_package).with_suffix("") + import_module(f"{mod.import_name}.data.{".".join(rel.parts)}") ``` +This init file does not apply to base modules. Base modules can provide their own models, mixins or whatever data you would like to put into their data folder, but their data is meant to be used by their parents. + +#### Extracting + +The Module class provides a `module.extract()` function. This function is meant to be used by the `fpp modules install` command to extract the modules globals to the app's static / templates when the module gets installed. This is especially useful if your module needs to install global templates if it is not meant to be installed as a home module. To use this feature, you need to create an **extract** folder containing a **templates** and/or **static** folder inside your module package. Their contents will then be extracted to the app's static and templates folder. + ### Working with Modules To simplify the work with modules, they provide their own render_template (like mentioned above) and url_for functions. So when you are handling a request inside a module, we recommend using these functions instead of the global ones. Modules are sensitive to the **HOME_MODULE** configuration. Defining a home module is optional, but if you do so, the module will be registered as if it was the main app when it gets enabled. @@ -520,12 +580,13 @@ The default features of the FppSocket class are its event_context (inspired by F ```python from flaskpp.app.extensions import socket -from flaskpp.utils.debugger import log +from flaskpp.utils.logging import log + @socket.on("my_event") async def event( - sid: str, # if you did not set sid_passing to False - payload: Any + sid: str, # if you did not set sid_passing to False + payload: Any ): # here you can access: ctx = socket.event_context @@ -606,7 +667,7 @@ Be aware that default events should not contain any "@" inside their names, beca These two switches come together. The **FPP_I18N_FALLBACK** switch only takes effect if the **EXT_BABEL** switch is enabled too. This is because Flask++ also provides its own Babel class called FppBabel, which extends `flask_babelplus.Babel`. Besides that, Flask++ also changes the internationalization process to fit into the Flask++ environment. That's why **EXT_BABEL** requires the **EXT_SQLALCHEMY** switch to be enabled. The Flask++ i18n system primarily stores translations inside the database and only uses the message catalogs as fallback. -It also provides its own domain resolving system, which matches with the Flask++ module system. This is also where the **FPP_I18N_FALLBACK** switch comes into play, because it adds a fallback domain called "flaskpp" which contains default translation keys providing German and English translations, that are used by the Flask++ [app utility](#further-utilities). +It also provides its own domain resolving system, which matches with the Flask++ module system. This is also where the **FPP_I18N_FALLBACK** switch comes into play, because it adds a fallback domain called "flaskpp" which contains default translation keys providing German and English translations that are used by the Flask++ [app utility](#further-utilities). Let's take a look at how you set up Babel for a specific module and how the fallback escalation works: @@ -624,7 +685,10 @@ module = Module( "sqlalchemy", "socket", "babel" - ] + ], + False + # -> False to install translations before route setup. + # This is especially useful if you are using translations inside your route setup. ) @module.on_enable @@ -639,6 +703,9 @@ def enable(app: FlaskPP): # -> default is module.name ) # This will register the domain_name as the modules translation domain, pass it as a variable called "DOMAIN" to the # context processor and automatically cause _ and ngettext to primarily resolve translation keys from that domain. + + # Now you can enable routes after you registered your translations + module.init_routes() # If you now do something like this, for example: from flask import render_template_string @@ -658,8 +725,8 @@ def example(): And here is an example of what your **module_package/data/noinit_translations.py** file could look like: ```python -from flaskpp.app.data import commit -from flaskpp.app.data.babel import add_entry, get_entries, get_entry +from flaskpp.app.data import commit, delete_model +from flaskpp.app.data.babel import add_entry, get_entries _msg_keys = [ "EXAMPLE_TITLE", @@ -690,12 +757,15 @@ def setup_db(mod: Module): if key not in keys: _add_entries(key, domain) + from .. import data for entry in entries: key = entry.key - if _translations_en[key] != entry.text: - entry.text = _translations_en[key] - entry_de = get_entry(key, "de", domain) - entry_de.text = _translations_de[key] + translations = getattr(data.noinit_translations, f"_translations_{entry.locale}", _translations_en) + try: + if translations[key] != entry.text: + entry.text = translations[key] + except KeyError: + delete_model(entry, False) else: for key in _msg_keys: _add_entries(key, domain) @@ -759,11 +829,29 @@ from flaskpp.app.extensions import db # priority=2 # -> Like config priority, it should be a value inclusively between 1 and 10 and defaults to 1. ) -class MyUserMixin(db.Model): +class MyUserMixin: + full_name = db.Column(db.String(64), nullable=False) bio = db.Column(db.String(512)) # ... ``` +You can also create your own forms for FST. For that create a **forms.py** file inside your module package. If EXT_FST is set to 1, the FlaskPP class will load it automatically and like with the FST mixins create a combined form class that is passed to the `security.init_app()` function. So you can plug in FST forms like that: + +```python +# module_package/forms.py +from flaskpp.app.utils.fst import register_form #, login_form +from flaskpp.app.utils.translating import t # this function is a promise that exists as dummy if EXT_BABEL = 0 +from wtforms import StringField, validators + +@register_form( + # priority=2 + # -> you know the concept :) +) +class MyRegisterForm: + full_name = StringField(t("FULL_NAME_LABEL"), validators=validators.DataRequired()) + # ... +``` + ## Further Utilities To make your life even easier, Flask++ provides some additional utilities and templates. You can use them to play around with the framework or integrate them into your own projects. In this chapter we will show you how to use them and how they work, so you can abstract parts of them into your own code base. diff --git a/pyproject.toml b/pyproject.toml index 195003d..7700edb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flaskpp" -version = "0.3.8" +version = "0.4.8" description = "A Flask based framework for fast and easy app creation. Experience the real power of Flask without boilerplate, but therefore a well balanced mix of magic and the default Flask framework." authors = [ { name = "Pierre", email = "pierre@growv-mail.org" } @@ -24,9 +24,11 @@ dependencies = [ "pymysql", "python-dotenv", "python-socketio[asgi]", + "argon2_cffi", "authlib", "uvicorn", "asgiref", + "immutables", "requests", "redis", "pytz", diff --git a/src/flaskpp/_init.py b/src/flaskpp/_init.py index 2ce78e3..a045edf 100644 --- a/src/flaskpp/_init.py +++ b/src/flaskpp/_init.py @@ -1,4 +1,3 @@ -from pathlib import Path import typer, subprocess, sys, os from flaskpp import _fpp_root @@ -10,12 +9,15 @@ def initialize(skip_defaults: bool, skip_babel: bool, skip_tailwind: bool, skip_node: bool, skip_vite: bool): typer.echo(typer.style("Creating default structure...", bold=True)) - root = Path.cwd() + from flaskpp.cli import cwd if not skip_defaults: from flaskpp.utils.setup import conf_path conf_path.mkdir(exist_ok=True) + from flaskpp.modules import setup_globals + setup_globals() + from flaskpp.modules import module_home module_home.mkdir(exist_ok=True) @@ -23,48 +25,49 @@ def initialize(skip_defaults: bool, skip_babel: bool, skip_tailwind: bool, skip_ service_path.mkdir(exist_ok=True) - templates = root / "templates" + templates = cwd / "templates" templates.mkdir(exist_ok=True) - static = root / "static" + static = cwd / "static" static.mkdir(exist_ok=True) css = static / "css" css.mkdir(exist_ok=True) (static / "js").mkdir(exist_ok=True) (static / "img").mkdir(exist_ok=True) - with open(root / "main.py", "w") as f: - f.write(""" -from flaskpp import FlaskPP + with open(cwd / "main.py", "w") as f: + f.write("""from flaskpp import FlaskPP +from flask import render_template def create_app(): app = FlaskPP(__name__) - + + app.add_app_url_rule( + "/", endpoint="index", + view_func=lambda: render_template("app/index.html") + ) + # TODO: Extend the Flask++ default setup with your own factory return app if __name__ == "__main__": app = create_app() - app.start() - """) + app.start()""") - (templates / "index.html").write_text(""" -{% extends "base_example.html" %} + (templates / "index.html").write_text("""{% extends "base_example.html" %} {# The base template is natively provided by Flask++. #} {% block title %}{{ _('Home') }}{% endblock %} + +{% block head %}{{ tailwind_main }}{% endblock %} + {% block content %} -
-

{{ _('My new Flask++ Project') }}

-

- {{ _('This is my brand new, super cool project.') }} - -

+
+

{{ _('My new Flask++ Project') }}

+

{{ _('This is my brand new, super cool project.') }}

-{% endblock %} - """) +{% endblock %}""") - (css / "tailwind_raw.css").write_text(""" -@import "tailwindcss" source("../../"); + (css / "tailwind_raw.css").write_text("""@import "tailwindcss" source("../../"); @source not "../../.venv"; @source not "../../venv"; @@ -74,13 +77,27 @@ def create_app(): @theme { /* ... */ -} - """) +}""") + + (cwd / ".gitignore").write_text("""[folders] +__pycache__/ +app_configs/ +services/ +modules/ +instance/ +migrations/ +translations/ +dist/ +logs/ + +[files] +messages.pot +tailwind.css""") if not skip_babel: typer.echo(typer.style("Generating default translations...", bold=True)) - translations = root / "translations" + translations = cwd / "translations" translations.mkdir(exist_ok=True) pot = "messages.pot" diff --git a/src/flaskpp/app/__init__.py b/src/flaskpp/app/__init__.py new file mode 100644 index 0000000..1b17fa5 --- /dev/null +++ b/src/flaskpp/app/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + + +class App(Blueprint): + def __init__(self, import_name: str): + super().__init__("app", import_name) + self.name = "" diff --git a/src/flaskpp/app/config/__init__.py b/src/flaskpp/app/config/__init__.py index e37a707..d264200 100644 --- a/src/flaskpp/app/config/__init__.py +++ b/src/flaskpp/app/config/__init__.py @@ -2,8 +2,9 @@ from importlib import import_module from typing import Callable, TYPE_CHECKING +from flaskpp.modules import installed_modules from flaskpp.app.config.default import DefaultConfig -from flaskpp.utils import check_priority, build_sorted_tuple +from flaskpp.utils import check_priority, build_sorted_tuple, enabled if TYPE_CHECKING: from flaskpp import FlaskPP @@ -20,13 +21,15 @@ def init_configs(app: "FlaskPP"): if not modules.exists() or not modules.is_dir(): return - for module in modules.iterdir(): - if not module.is_dir(): + for module_info in installed_modules(modules): + m, _, p = module_info + if not enabled(m): continue - config = module / "config.py" - if config.exists(): - import_module(f"modules.{module.name}.config") + try: + import_module(f"modules.{p}.config") + except ModuleNotFoundError: + pass def register_config(priority: int = 1) -> Callable: diff --git a/src/flaskpp/app/config/default.py b/src/flaskpp/app/config/default.py index b70db4e..92a7893 100644 --- a/src/flaskpp/app/config/default.py +++ b/src/flaskpp/app/config/default.py @@ -1,5 +1,7 @@ import os +from flaskpp.utils import enabled + class DefaultConfig: SERVER_NAME = os.getenv("SERVER_NAME") @@ -11,7 +13,7 @@ class DefaultConfig: PROXY_FIX = False PROXY_COUNT = 1 - SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///database.db") + SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URI", "sqlite:///database.db") SQLALCHEMY_TRACK_MODIFICATIONS = False RATELIMIT_ENABLED = True @@ -37,8 +39,8 @@ class DefaultConfig: MAIL_SERVER = os.getenv("MAIL_SERVER", "localhost") MAIL_PORT = int(os.getenv("MAIL_PORT", 25)) - MAIL_USE_TLS = True - MAIL_USE_SSL = False + MAIL_USE_TLS = enabled("MAIL_USE_TLS") + MAIL_USE_SSL = enabled("MAIL_USE_SSL") MAIL_USERNAME = os.getenv("MAIL_USERNAME") MAIL_PASSWORD = os.getenv("MAIL_PASSWORD") MAIL_DEFAULT_SENDER = os.getenv("MAIL_DEFAULT_SENDER", "noreply@example.com") diff --git a/src/flaskpp/app/data/__init__.py b/src/flaskpp/app/data/__init__.py index 4ae6cb7..6ba1f24 100644 --- a/src/flaskpp/app/data/__init__.py +++ b/src/flaskpp/app/data/__init__.py @@ -4,7 +4,9 @@ from typing import TYPE_CHECKING import os -from flaskpp.utils.debugger import log +from flaskpp.modules import installed_modules +from flaskpp.utils import enabled +from flaskpp.utils.logging import debug if TYPE_CHECKING: from flask import Flask @@ -14,12 +16,29 @@ _package = Path(__file__).parent -def init_models(): +def init_models(app: "FlaskPP"): for file in _package.rglob("*.py"): if file.stem == "__init__" or file.stem.startswith("noinit"): continue import_module(f"flaskpp.app.data.{file.stem}") + modules = Path(app.root_path) / "modules" + if not modules.exists() or not modules.is_dir(): + return + + for module_info in installed_modules(modules, False): + m, _, p = module_info + if not enabled(m): + continue + + try: + mod = import_module(f"modules.{p}") + module = getattr(mod, "module", None) + if module and not module.is_base: + module.init_models() + except ModuleNotFoundError: + pass + def commit(): from ..extensions import db @@ -70,4 +89,4 @@ def _fix_missing(migrations: str): content = f"{import_str}\n{content}" with open(latest_file, "w", encoding="utf-8") as f: f.write(content) - log("migrate", f"Fixed missing flask_security import in {latest_file}") + debug(f"[MIGRATE] Fixed missing flask_security import in {latest_file}") diff --git a/src/flaskpp/app/data/fst_base.py b/src/flaskpp/app/data/fst.py similarity index 75% rename from src/flaskpp/app/data/fst_base.py rename to src/flaskpp/app/data/fst.py index 3eee996..4704f29 100644 --- a/src/flaskpp/app/data/fst_base.py +++ b/src/flaskpp/app/data/fst.py @@ -4,7 +4,8 @@ from typing import Callable, TYPE_CHECKING import inspect -from flaskpp.utils import check_priority, build_sorted_tuple +from flaskpp.modules import installed_modules +from flaskpp.utils import check_priority, build_sorted_tuple, enabled from flaskpp.app.extensions import db if TYPE_CHECKING: @@ -27,13 +28,15 @@ def init_mixins(app: "FlaskPP"): if not modules.exists() or not modules.is_dir(): return - for module in modules.iterdir(): - if not module.is_dir(): + for module_info in installed_modules(modules, False): + m, _, p = module_info + if not enabled(m): continue - fst_data = module / "data" / "noinit_fst.py" - if fst_data.exists(): - import_module(f"modules.{module.name}.data.noinit_fst") + try: + import_module(f"modules.{p}.data.noinit_fst") + except ModuleNotFoundError: + pass def user_mixin(priority: int = 1) -> Callable: @@ -79,15 +82,9 @@ def build_role_model() -> type: return type( "Role", - bases + (db.Model, fsqla.FsRoleMixin), + bases + (db.Model, fsqla.FsRoleMixinV2), {} ) - -user_roles = db.Table( - "user_roles", - db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True), - db.Column("role_id", db.Integer, db.ForeignKey("role.id"), primary_key=True) -) - -fsqla.FsModels.set_db_info(db) +if enabled("EXT_FST"): + fsqla.FsModels.set_db_info(db) diff --git a/src/flaskpp/app/data/noinit_translations.py b/src/flaskpp/app/data/noinit_translations.py index e077f17..cb0d501 100644 --- a/src/flaskpp/app/data/noinit_translations.py +++ b/src/flaskpp/app/data/noinit_translations.py @@ -1,12 +1,11 @@ -import json - -from flaskpp.app.data import commit, _package -from flaskpp.app.data.babel import add_entry, get_entries, get_entry from flaskpp.babel import valid_state from flaskpp.utils import enabled -from flaskpp.utils.debugger import log +from flaskpp.app.utils.i18n import update_translations from flaskpp.exceptions import I18nError + +translations: dict[str, dict[str, str]] = {} + _msg_keys = [ "NAV_BRAND", "NOT_FOUND_TITLE", @@ -20,10 +19,12 @@ "NO", "HINT", "UNDERSTOOD", + "FORBIDDEN_TITLE", + "FORBIDDEN_MSG", ] -_translations_en = { +translations["en"] = { _msg_keys[0]: "My Flask++ App", _msg_keys[1]: "Not Found", _msg_keys[2]: "We are sorry, but the requested page doesn't exist.", @@ -36,10 +37,12 @@ _msg_keys[9]: "No", _msg_keys[10]: "Hint", _msg_keys[11]: "Understood", + _msg_keys[12]: "Access Denied", + _msg_keys[13]: "You are not authorized to access this page.", } -_translations_de = { +translations["de"] = { _msg_keys[0]: "Meine Flask++ App", _msg_keys[1]: "Nicht Gefunden", _msg_keys[2]: "Wir konnten die angefragte Seite leider nicht finden.", @@ -52,58 +55,16 @@ _msg_keys[9]: "Nein", _msg_keys[10]: "Hinweis", _msg_keys[11]: "Verstanden", + _msg_keys[12]: "Zugriff Verweigert", + _msg_keys[13]: "Du bist nicht berechtigt auf diese Seite zuzugreifen." } -def _add_entries(key: str, domain: str): - add_entry("en", key, _translations_en[key], domain, False) - add_entry("de", key, _translations_de[key], domain, False) - - def setup_db(domain: str = "flaskpp"): if not (enabled("EXT_BABEL") and enabled("EXT_SQLALCHEMY")): raise I18nError("To setup Flask++ base translations, you must enable EXT_BABEL and EXT_SQLALCHEMY.") state = valid_state() state.fpp_fallback_domain = domain - entries = get_entries(domain=domain, locale="en") - - if entries: - log("info", f"Updating Flask++ base translations...") - - keys = [e.key for e in entries] - for key in _msg_keys: - if key not in keys: - _add_entries(key, domain) - - for entry in entries: - key = entry.key - if _translations_en[key] != entry.text: - entry.text = _translations_en[key] - entry_de = get_entry(key, "de", domain) - entry_de.text = _translations_de[key] - else: - log("info", f"Setting up Flask++ translations...") - - for key in _msg_keys: - _add_entries(key, domain) - - commit() - - -def get_locale_data(locale: str) -> tuple[str, str]: - if len(locale) != 2 and len(locale) != 5 or len(locale) == 5 and "_" not in locale: - raise I18nError(f"Invalid locale code: {locale}") - - if "_" in locale: - locale = locale.split("_")[0] - - try: - locale_data = json.loads((_package / "locales.json").read_text()) - except json.JSONDecodeError: - raise I18nError("Failed to parse locales.json") - - flags = locale_data.get("flags", {}) - names = locale_data.get("names", {}) - return flags.get(locale, "🇬🇧"), names.get(locale, "English") + update_translations("Flask++", __name__, domain) diff --git a/src/flaskpp/app/static/css/tailwind_raw.css b/src/flaskpp/app/static/css/tailwind_raw.css index f76c0ef..0cd70f0 100644 --- a/src/flaskpp/app/static/css/tailwind_raw.css +++ b/src/flaskpp/app/static/css/tailwind_raw.css @@ -44,7 +44,7 @@ } .nav-link { - @apply block rounded-md px-3 max-md:py-2 transition w-full; + @apply block rounded-md px-3 max-md:py-2 transition w-full md:w-fit whitespace-nowrap; } .nav-link.active { diff --git a/src/flaskpp/app/static/js/base.js b/src/flaskpp/app/static/js/base.js index 8d4bf00..961e01c 100644 --- a/src/flaskpp/app/static/js/base.js +++ b/src/flaskpp/app/static/js/base.js @@ -215,6 +215,8 @@ socket.on('error', async (message) => { }); +export const initializedEvent = new Event("FPPBaseInitialized"); + window.FPP = { showModal: showModal, hideModal: hideModal, @@ -234,8 +236,9 @@ window.FPP = { socket: socket, emit: emit, emitAsync: emitAsync, -} + initializedEvent: initializedEvent +} document.addEventListener("DOMContentLoaded", () => { document.querySelectorAll(".modal").forEach(modal => { @@ -243,4 +246,6 @@ document.addEventListener("DOMContentLoaded", () => { hideModal(modal); bindModalCloseEvents(modal); }); -}); \ No newline at end of file + + document.dispatchEvent(initializedEvent); +}); diff --git a/src/flaskpp/app/templates/403.html b/src/flaskpp/app/templates/403.html new file mode 100644 index 0000000..9ad1d60 --- /dev/null +++ b/src/flaskpp/app/templates/403.html @@ -0,0 +1,18 @@ +{% extends "base_error.html" %} + +{% block error %} +

+ 🚫 {{ _('FORBIDDEN_TITLE') }} +

+ +

+ {{ _("FORBIDDEN_MSG") }} +

+ + + {{ _("BACK_HOME") }} + +{% endblock %} \ No newline at end of file diff --git a/src/flaskpp/app/templates/404.html b/src/flaskpp/app/templates/404.html index d106d90..54937ee 100644 --- a/src/flaskpp/app/templates/404.html +++ b/src/flaskpp/app/templates/404.html @@ -1,19 +1,18 @@ -{% extends "base_example.html" %} -{% block title %}{{ _('NOT_FOUND_TITLE') }}{% endblock %} -{% block content %} -
-
-

404

-

- {{ _("NOT_FOUND_MSG") }} -

+{% extends "base_error.html" %} - - {{ _("BACK_HOME") }} - -
-
+{% block error %} +

+ 👀 {{ _('NOT_FOUND_TITLE') }} +

+ +

+ {{ _("NOT_FOUND_MSG") }} +

+ + + {{ _("BACK_HOME") }} + {% endblock %} diff --git a/src/flaskpp/app/templates/501.html b/src/flaskpp/app/templates/501.html new file mode 100644 index 0000000..6ccf104 --- /dev/null +++ b/src/flaskpp/app/templates/501.html @@ -0,0 +1,18 @@ +{% extends "base_error.html" %} + +{% block error %} +

+ ⚠️ {{ _('ERROR_TITLE') }} +

+ +

+ {{ _('ERROR_MSG') }} +

+ + + {{ _('BACK_HOME') }} + +{% endblock %} diff --git a/src/flaskpp/app/templates/base_email.html b/src/flaskpp/app/templates/base_email.html new file mode 100644 index 0000000..151361e --- /dev/null +++ b/src/flaskpp/app/templates/base_email.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/flaskpp/app/templates/base_error.html b/src/flaskpp/app/templates/base_error.html new file mode 100644 index 0000000..67e43d2 --- /dev/null +++ b/src/flaskpp/app/templates/base_error.html @@ -0,0 +1,11 @@ +{% extends "base_example.html" %} + +{% block title %}{{ _('ERROR') }}{% endblock %} + +{% block content %} +
+
+ {% block error %}{% endblock %} +
+
+{% endblock %} \ No newline at end of file diff --git a/src/flaskpp/app/templates/base_example.html b/src/flaskpp/app/templates/base_example.html index 4ff7662..e558d42 100644 --- a/src/flaskpp/app/templates/base_example.html +++ b/src/flaskpp/app/templates/base_example.html @@ -7,8 +7,6 @@ - {{ fpp_tailwind }} - {% block title %}Flask++ App{% endblock %} {% if enabled("EXT_SOCKET") %} @@ -29,6 +27,8 @@ {% endif %} + {{ fpp_tailwind }} + {% block head %}{% endblock %} diff --git a/src/flaskpp/app/templates/error.html b/src/flaskpp/app/templates/error.html deleted file mode 100644 index c3dee64..0000000 --- a/src/flaskpp/app/templates/error.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "base_example.html" %} -{% block title %}{{ _('ERROR') }}{% endblock %} -{% block content %} -
-
-

- ⚠️ {{ _('ERROR_TITLE') }} -

- -

- {{ _('ERROR_MSG') }} -

- - - {{ _('BACK_HOME') }} - -
-
-{% endblock %} diff --git a/src/flaskpp/app/utils/auto_nav.py b/src/flaskpp/app/utils/auto_nav.py index 9331b77..22f2af0 100644 --- a/src/flaskpp/app/utils/auto_nav.py +++ b/src/flaskpp/app/utils/auto_nav.py @@ -1,14 +1,22 @@ from typing import Callable, TYPE_CHECKING if TYPE_CHECKING: - from flask import Blueprint + from flaskpp import FlaskPP, Module class Link: - def __init__(self, label: str, href: str, additional_classes: str = ""): + def __init__(self, label: str, href: str, additional_classes: str = "", + target: "Module" = None): self.label = label - self.href = href + self._href = href self.classes = additional_classes + self.target = target + + @property + def href(self): + if self.target and not self.target.ref.home: + return f"{self.target.ref.url_prefix}{self._href}" + return self._href class Dropdown: @@ -35,7 +43,7 @@ def __init__( self.saved = False def dropdown_route( - self, blueprint: "Blueprint", rule: str, + self, target: "FlaskPP | Module", rule: str, label: str, priority: int = 1, additional_classes: str = "", **route_kwargs @@ -47,12 +55,13 @@ def dropdown_route( if not priority in self.dropdown_links: self.dropdown_links[priority] = [] self.dropdown_links[priority].append(Link( - label, _path(blueprint, rule), - additional_classes + label, _path(target, rule), + additional_classes, + target if getattr(target, "is_base", False) else None )) def decorator(func): - blueprint.add_url_rule(rule, view_func=func, **route_kwargs) + target.add_url_rule(rule, view_func=func, **route_kwargs) return func return decorator @@ -81,13 +90,13 @@ def _check(priority: int): check_priority(priority) -def _path(bp: "Blueprint", rule: str) -> str: - prefix = bp.url_prefix or "" +def _path(t: "FlaskPP | Module", rule: str) -> str: + prefix = t.url_prefix or "" return f"{prefix}{rule}" def autonav_route( - blueprint: "Blueprint", rule: str, + target: "FlaskPP | Module", rule: str, label: str, priority: int = 1, additional_classes: str = "", **route_kwargs @@ -97,12 +106,13 @@ def autonav_route( if not priority in _nav_links: _nav_links[priority] = [] _nav_links[priority].append(Link( - label, _path(blueprint, rule), - additional_classes + label, _path(target, rule), + additional_classes, + target if getattr(target, "is_base", False) else None )) def decorator(func): - blueprint.add_url_rule(rule, view_func=func, **route_kwargs) + target.add_url_rule(rule, view_func=func, **route_kwargs) return func return decorator diff --git a/src/flaskpp/app/utils/fst.py b/src/flaskpp/app/utils/fst.py new file mode 100644 index 0000000..4ec50a7 --- /dev/null +++ b/src/flaskpp/app/utils/fst.py @@ -0,0 +1,92 @@ +from pathlib import Path +from importlib import import_module +from flask_security.forms import LoginForm, RegisterFormV2 +from flask_mailman import EmailMessage, EmailMultiAlternatives +from threading import Thread +from typing import Callable, TYPE_CHECKING + +from flaskpp.modules import installed_modules +from flaskpp.utils import check_priority, build_sorted_tuple, enabled + +if TYPE_CHECKING: + from flaskpp import FlaskPP + +_login_forms: dict[int, list[type]] = {} +_register_forms: dict[int, list[type]] = {} + + +def init_forms(app: "FlaskPP"): + modules = Path(app.root_path) / "modules" + + if not modules.exists() or not modules.is_dir(): + return + + for module_info in installed_modules(modules, False): + m, _, p = module_info + if not enabled(m): + continue + + try: + import_module(f"modules.{p}.fst_forms") + except ModuleNotFoundError: + pass + + +def login_form(priority: int = 1) -> Callable: + check_priority(priority) + + def decorator(cls): + if not priority in _login_forms: + _login_forms[priority] = [] + _login_forms[priority].append(cls) + return cls + + return decorator + + +def register_form(priority: int = 1) -> Callable: + check_priority(priority) + + def decorator(cls): + if not priority in _register_forms: + _register_forms[priority] = [] + _register_forms[priority].append(cls) + return cls + + return decorator + + +def build_login_form() -> type: + bases = tuple() + for configs in build_sorted_tuple(_login_forms): + bases += tuple(configs) + + return type( + "ExtendedLoginForm", + bases + (LoginForm, ), + {} + ) + + +def build_register_form() -> type: + bases = tuple() + for configs in build_sorted_tuple(_register_forms): + bases += tuple(configs) + + return type( + "ExtendedRegisterForm", + bases + (RegisterFormV2, ), + {} + ) + + +def send_security_mail(msg: dict): + message = EmailMultiAlternatives( + subject=msg["subject"], + body=msg["body"], + from_email=msg["sender"], + to=[msg["recipient"]], + ) + if "html" in msg: + message.attach_alternative(msg["html"], "text/html") + Thread(target=message.send).start() diff --git a/src/flaskpp/app/utils/i18n.py b/src/flaskpp/app/utils/i18n.py new file mode 100644 index 0000000..9e20e3e --- /dev/null +++ b/src/flaskpp/app/utils/i18n.py @@ -0,0 +1,82 @@ +from pathlib import Path +from importlib import import_module +import json + +from flaskpp.app.data.babel import add_entry, get_entries +from flaskpp.app.data import commit, delete_model +from flaskpp.babel import valid_state +from flaskpp.utils import enabled +from flaskpp.utils.logging import log +from flaskpp.exceptions import I18nError + + +def _add_entries(translations: dict, key: str, domain: str): + for locale, t in translations.items(): + if key not in t: + continue + add_entry(locale, key, t[key], domain, False) + + +def get_locale_data(locale: str) -> tuple[str, str]: + if len(locale) != 2 and len(locale) != 5 or len(locale) == 5 and "_" not in locale: + raise I18nError(f"Invalid locale code: {locale}") + + if "_" in locale: + locale = locale.split("_")[0] + + try: + locale_data = json.loads( + (Path(__file__).parent.parent / "data" / "locales.json").read_text(encoding="utf-8") + ) + except json.JSONDecodeError: + raise I18nError("Failed to parse locales.json") + + flags = locale_data.get("flags", {}) + names = locale_data.get("names", {}) + return flags.get(locale, "🇬🇧"), names.get(locale, "English") + + +def update_translations(executor: str, translations_import_name: str, domain: str = None): + default_domain = valid_state().domain.domain + if not domain: + domain = default_domain + entries = get_entries(domain=domain) + + translations_module = import_module(translations_import_name) + translations_dict = getattr(translations_module, "translations", {}) + + fb_translations = {} + for t in translations_dict.values(): + fb_translations = t + break + msg_keys = [k for k in fb_translations] + + if entries and enabled("I18N_AUTOUPDATE"): + log(f"[{executor}] Updating translations...") + + keys = [e.key for e in entries] + for key in msg_keys: + if key not in keys: + _add_entries(translations_dict, key, domain) + + + for entry in entries: + key = entry.key + translations = translations_dict.get(entry.locale, translations_dict.get("en")) + if not translations: + translations = fb_translations + + try: + if translations[key] != entry.text: + entry.text = translations[key] + except KeyError: + if domain == default_domain: + continue + delete_model(entry, False) + else: + log(f"[{executor}] Setting up translations...") + + for key in msg_keys: + _add_entries(translations_dict, key, domain) + + commit() \ No newline at end of file diff --git a/src/flaskpp/app/utils/mailing.py b/src/flaskpp/app/utils/mailing.py new file mode 100644 index 0000000..53f1fe0 --- /dev/null +++ b/src/flaskpp/app/utils/mailing.py @@ -0,0 +1,40 @@ +from flask import render_template, current_app +from jinja2 import TemplateNotFound +from flask_mailman import EmailMultiAlternatives +from threading import Thread +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from flaskpp import Module + + +def _safe_render(template: str, module: "Module", context: dict) -> str: + try: + return render_mail_template(template, module, **context) + except TemplateNotFound: + return "" + + +def render_mail_template(template: str, module: "Module" = None, **context) -> str: + if module is None: + return render_template(f"app/email/{template}", **context) + return module.render_template(f"email/{template}", **context) + + +def send_email(subject: str, recipient: str, email_template: str, + sender_name: str = None, module: "Module" = None, **context): + body = _safe_render(f"{email_template}.txt", module, context) + html = _safe_render(f"{email_template}.html", module, context) + + msg = EmailMultiAlternatives( + subject=subject, + body=body, + to=[recipient] + ) + msg.attach_alternative(html, "text/html") + + if sender_name: + user = current_app.config.get("MAIL_USERNAME", "noreply@example.com") + msg.from_email = f"{sender_name} <{user}>" + + Thread(target=msg.send).start() diff --git a/src/flaskpp/app/utils/processing.py b/src/flaskpp/app/utils/processing.py index 092dee4..0cd315a 100644 --- a/src/flaskpp/app/utils/processing.py +++ b/src/flaskpp/app/utils/processing.py @@ -1,13 +1,13 @@ from flask import Flask, request, render_template, url_for, Response -from werkzeug.exceptions import NotFound +from werkzeug.exceptions import Forbidden, NotFound from markupsafe import Markup from typing import Callable -from flaskpp.app.data.noinit_translations import get_locale_data from flaskpp.app.utils.translating import get_locale from flaskpp.app.utils.auto_nav import build_nav +from flaskpp.app.utils.i18n import get_locale_data from flaskpp.utils import random_code, enabled -from flaskpp.utils.debugger import log, exception +from flaskpp.utils.logging import log, exception _handlers = {} @@ -39,7 +39,7 @@ def _before_request(): agent = request.headers.get("User-Agent") agent = agent if agent else "no-agent" - log("request", f"{method:4} '{path:48}' from {ip:15} via ({agent}).") + log(f"[REQUEST] {method:4} '{path:50}'\t\tfrom {ip:15} via ({agent}).") def after_request(fn: Callable) -> Callable: @@ -55,12 +55,15 @@ def handle_app_error(fn: Callable) -> Callable: return fn def _handle_app_error(error: Exception): + if isinstance(error, Forbidden): + return render_template("403.html"), 403 + if isinstance(error, NotFound): return render_template("404.html"), 404 eid = random_code() exception(error, f"Handling app request failed ({eid}).") - return render_template("error.html"), 501 + return render_template("501.html"), 501 def get_handler(name: str) -> Callable: diff --git a/src/flaskpp/app/utils/translating.py b/src/flaskpp/app/utils/translating.py index 4157ce9..551cc21 100644 --- a/src/flaskpp/app/utils/translating.py +++ b/src/flaskpp/app/utils/translating.py @@ -9,12 +9,13 @@ from flaskpp.exceptions import I18nError -def _t(s: str, wrap: bool = None) -> str: - return s +def _t(s: str, wrap: bool = None, **vars) -> str: + return s if not vars else s % vars -def _tn(s: str, p: str, n: int, wrap: bool = None) -> str: - return p if (n != 1) else s +def _tn(s: str, p: str, n: int, wrap: bool = None, **vars) -> str: + vars.setdefault("n", n) + return (s if n == 1 else s) % vars def _wrapped_message(msg: str) -> str: @@ -67,7 +68,7 @@ def _get_fallbacks() -> list[str]: return fallbacks -def _fallback_escalated_text(msg: str, *args) -> str: +def _fallback_escalated_text(msg: str, vars: dict, *args) -> str: domain, msg, domain_str = _get_domain_data(msg) translations = domain.get_translations(domain_str) text = _gettext(translations, msg, *args) @@ -80,7 +81,7 @@ def _fallback_escalated_text(msg: str, *args) -> str: text = _gettext(translations, msg, *args) index += 1 - return text + return _t(text, None, **vars) def supported_locales() -> list[str]: @@ -130,19 +131,19 @@ def set_locale(locale: str) -> Response: if enabled("EXT_BABEL"): - def t(message: str, wrap: bool = True) -> str: + def t(message: str, wrap: bool = True, **vars) -> str: if not has_app_context(): - return _t(message) + return _t(message, wrap, **vars) if wrap: message = _wrapped_message(message) - return _fallback_escalated_text(message) + return _fallback_escalated_text(message, vars) - def tn(singular: str, plural: str, n: int, wrap: bool = True) -> str: + def tn(singular: str, plural: str, n: int, wrap: bool = True, **vars) -> str: if not has_app_context(): - return _tn(singular, plural, n) + return _tn(singular, plural, n, wrap, **vars) if wrap: singular = _wrapped_message(singular) - return _fallback_escalated_text(singular, plural, n) + return _fallback_escalated_text(singular, vars, plural, n) else: t = _t tn = _tn diff --git a/src/flaskpp/babel.py b/src/flaskpp/babel.py index 167bc1f..54f99f8 100644 --- a/src/flaskpp/babel.py +++ b/src/flaskpp/babel.py @@ -8,6 +8,7 @@ from flask import Flask from werkzeug.datastructures import ImmutableDict from flaskpp import FlaskPP, Module + from flaskpp.i18n import DBDomain class FppBabel(Babel): @@ -20,7 +21,7 @@ def __init__(self, app: "FlaskPP | Flask" = None, **kwargs): def init_app(self, app: "FlaskPP | Flask", default_locale: str = None, default_timezone: str = None, date_formats: "ImmutableDict[str, str | None]" = None, - configure_jinja: bool = True, default_domain: str = None): + configure_jinja: bool = True, default_domain: "DBDomain" = None): if default_domain is None: from flaskpp.i18n import DBDomain default_domain = DBDomain() @@ -36,7 +37,7 @@ def init_app(self, app: "FlaskPP | Flask", default_locale: str = None, app.config.setdefault('BABEL_DEFAULT_TIMEZONE', default_timezone) app.config.setdefault('BABEL_CONFIGURE_JINJA', configure_jinja) - app.config.setdefault('BABEL_DOMAIN', default_domain) + app.config.setdefault('BABEL_DOMAIN', default_domain.domain) app.extensions['babel'] = _FppBabelState( babel=self, app=app, domain=default_domain @@ -66,7 +67,7 @@ def init_app(self, app: "FlaskPP | Flask", default_locale: str = None, class _FppBabelState(object): - def __init__(self, babel: FppBabel, app: "FlaskPP | Flask", domain: str): + def __init__(self, babel: FppBabel, app: "FlaskPP | Flask", domain: "DBDomain"): self.babel = babel self.app = app self.domain = domain diff --git a/src/flaskpp/cli.py b/src/flaskpp/cli.py index fdd1ac8..fa0a2d5 100644 --- a/src/flaskpp/cli.py +++ b/src/flaskpp/cli.py @@ -1,5 +1,6 @@ from importlib.metadata import version -import typer +from pathlib import Path +import typer, sys from flaskpp._help import help_message from flaskpp._init import initialize @@ -11,6 +12,7 @@ from flaskpp.tailwind.cli import tailwind_entry app = typer.Typer(help="Flask++ CLI") +cwd = Path.cwd() @app.callback(invoke_without_command=True) @@ -61,6 +63,7 @@ def main(): node_entry(app) tailwind_entry(app) + sys.path.append(cwd.as_posix()) app() diff --git a/src/flaskpp/flaskpp.py b/src/flaskpp/flaskpp.py index da1876f..7a41de3 100644 --- a/src/flaskpp/flaskpp.py +++ b/src/flaskpp/flaskpp.py @@ -5,14 +5,15 @@ from socketio import ASGIApp from pathlib import Path from importlib.metadata import version as _version -from typing import Callable, TYPE_CHECKING +from typing import Callable, Any, TYPE_CHECKING import os, signal from flaskpp.i18n import init_i18n from flaskpp.tailwind import generate_tailwind_css from flaskpp.modules import register_modules from flaskpp.utils import enabled, required_arg_count, safe_string -from flaskpp.utils.debugger import start_session, log +from flaskpp.utils.logging import start_session, log +from flaskpp.app import App from flaskpp.app.config import init_configs, build_config from flaskpp.app.data import db_autoupdate from flaskpp.app.utils.processing import set_default_handlers @@ -31,11 +32,12 @@ def __str__(self): class FlaskPP(Flask): - def __init__(self, import_name: str): + def __init__(self, import_name: str, allow_frontend_engine: bool = True, **kwargs): super().__init__( import_name, static_folder=None, - static_url_path=None + static_url_path=None, + **kwargs ) self.name = safe_string(os.getenv("APP_NAME", self.import_name)).lower() @@ -61,6 +63,11 @@ def __init__(self, import_name: str): limiter.init_app(self) if enabled("FPP_PROCESSING"): + self.context = {} + self.context_processor(lambda: dict( + **self.context, + main_context=self.context + )) set_default_handlers(self) ext_database = enabled("EXT_SQLALCHEMY") @@ -70,7 +77,7 @@ def __init__(self, import_name: str): from flaskpp.app.data import init_models db.init_app(self) migrate.init_app(self, db) - init_models() + init_models(self) if enabled("DB_AUTOUPDATE"): db_updater = Thread(target=db_autoupdate, args=(self,)) @@ -86,7 +93,10 @@ def __init__(self, import_name: str): from flaskpp.app.extensions import babel from flaskpp.app.utils.translating import set_locale babel.init_app(self) - self.route("/lang/")(set_locale) + self.add_url_rule( + "/lang/", + view_func=set_locale + ) if enabled("FPP_I18N_FALLBACK") and ext_database: from flaskpp.app.data.noinit_translations import setup_db @@ -98,15 +108,19 @@ def __init__(self, import_name: str): from flask_security import SQLAlchemyUserDatastore from flaskpp.app.extensions import security, db - from flaskpp.app.data.fst_base import init_mixins, build_user_model, build_role_model + from flaskpp.app.data.fst import init_mixins, build_user_model, build_role_model + from flaskpp.app.utils.fst import init_forms, build_login_form, build_register_form, send_security_mail init_mixins(self) + init_forms(self) + security.init_app( self, SQLAlchemyUserDatastore( - db, - build_user_model(), - build_role_model() - ) + db, build_user_model(), build_role_model() + ), + login_form=build_login_form(), + register_form=build_register_form(), + send_mail=send_security_mail, ) if enabled("EXT_AUTHLIB"): @@ -130,7 +144,9 @@ def __init__(self, import_name: str): if enabled("EXT_API"): from flaskpp.app.extensions import api - api.init_app(self) + api.init_app( + self, prefix=f"/api/{self.config.get('API_VERSION', 'v1')}" + ) if enabled("EXT_JWT_EXTENDED"): from flaskpp.app.extensions import jwt @@ -144,35 +160,61 @@ def __init__(self, import_name: str): if db_updater: db_updater.start() + self._app = App(import_name) self._asgi_app = None self._server = Thread(target=self._run_server, daemon=True) self._shutdown_flag = Event() + self._allow_vite = allow_frontend_engine def _startup(self): with self.app_context(): - log("info", "Running startup hooks...") + log("Running startup hooks...") [hook() for hook in self._startup_hooks] def _shutdown(self): with self.app_context(): - log("info", "Running shutdown hooks...") - [hook() for hook in self._shutdown_hooks] + log("Running shutdown hooks...") + [hook() for hook in reversed(self._shutdown_hooks)] def _run_server(self): import uvicorn + + fpp_processing = enabled("FPP_PROCESSING") + if enabled("DEBUG_MODE") and not fpp_processing: + log_level = "debug" + elif not fpp_processing: + log_level = "info" + else: + log_level = self.config.get("UVICORN_LOGLEVEL", "warning") + + log(f"[{__name__}] Uvicorn loglevel: {log_level}") + uvicorn.run( self.to_asgi(), host="0.0.0.0", port=int(os.getenv("SERVER_PORT", "5000")), - log_level="debug" if enabled("DEBUG_MODE") else "info", + log_level="info" ) def _handle_shutdown(self, signum: int, frame: "FrameType"): - log("info", f"Handling signal {'SIGINT' if signum == signal.SIGINT else 'SIGTERM'}: Shutting down...") + log(f"Handling signal {'SIGINT' if signum == signal.SIGINT else 'SIGTERM'}: Shutting down...") if self._shutdown_flag.is_set(): return self._shutdown_flag.set() + def route(self, rule: str, **options: Any) -> Callable: + def decorator(fn): + endpoint = options.pop("endpoint", None) + self.add_app_url_rule(rule, endpoint, fn, **options) + return fn + return decorator + + def add_app_url_rule( + self, rule: str, endpoint: str | None = None, view_func: Any = None, + **options: Any + ): + self._app.add_url_rule(rule, endpoint, view_func, **options) + def to_asgi(self) -> WsgiToAsgi | ASGIApp: if self._asgi_app is not None: return self._asgi_app @@ -204,29 +246,26 @@ def start(self): signal.signal(signal.SIGTERM, self._handle_shutdown) signal.signal(signal.SIGINT, self._handle_shutdown) - start_session(enabled("DEBUG_MODE")) + log_level = self.config.get( + "LOGLEVEL", "debug" if enabled("DEBUG_MODE") else "info" + ) + start_session(log_level) if enabled("AUTOGENERATE_TAILWIND_CSS"): generate_tailwind_css(self) - from flaskpp import _fpp_root - _fpp_default = Blueprint( - "fpp_default", __name__, - static_folder=_fpp_root / "app" / "static", - static_url_path="/fpp-static" - ) - self.register_blueprint(_fpp_default) - self.url_prefix = "" register_modules(self) - self.static_url_path = f"{self.url_prefix}/static" + self.static_url_path = f"{self.url_prefix.rstrip('/')}/static" self.add_url_rule( f"{self.static_url_path}/", endpoint="static", view_func=lambda filename: send_from_directory(Path(self.root_path) / "static", filename) ) - if enabled("FRONTEND_ENGINE"): + log(f"[{__name__}] Modules registered.") + + if enabled("FRONTEND_ENGINE") and self._allow_vite: from flaskpp.fpp_node.fpp_vite import Frontend engine = Frontend(self) self.context_processor(lambda: { @@ -235,6 +274,19 @@ def start(self): self.frontend_engine = engine self.on_shutdown(engine.shutdown) + from flaskpp import _fpp_root + fpp_default = Blueprint( + "fpp_default", __name__, + static_folder=_fpp_root / "app" / "static", + static_url_path="/fpp-static" + ) + self.register_blueprint(fpp_default) + self.register_blueprint( + self._app, url_prefix=self.url_prefix if self.url_prefix else "/" + ) + + log(f"[{__name__}] Finished loading.") + self._startup() self._server.start() self._shutdown_flag.wait() diff --git a/src/flaskpp/fpp_node/fpp_vite.py b/src/flaskpp/fpp_node/fpp_vite.py index b964174..390c300 100644 --- a/src/flaskpp/fpp_node/fpp_vite.py +++ b/src/flaskpp/fpp_node/fpp_vite.py @@ -9,7 +9,7 @@ from flaskpp.fpp_node import home, _node_cmd, _node_env from flaskpp.tailwind import generate_asset from flaskpp.utils import enabled, is_port_free -from flaskpp.utils.debugger import exception +from flaskpp.utils.logging import exception from flaskpp.exceptions import ViteError diff --git a/src/flaskpp/i18n.py b/src/flaskpp/i18n.py index 4094934..4a8618f 100644 --- a/src/flaskpp/i18n.py +++ b/src/flaskpp/i18n.py @@ -1,6 +1,7 @@ from flask_babelplus import Domain from babel.support import Translations from flask import Flask, current_app +from pathlib import Path from typing import TYPE_CHECKING from flaskpp.utils import enabled @@ -58,8 +59,15 @@ def get_translations(self, domain: str = None) -> DBMergedTranslations: key = f"{locale}@{domain}" translations = cache.get(key) if translations is None: + + if domain == "flask_security": + from flask_security import __path__ as fs_path + translations_dir = Path(fs_path[0]) / "translations" + else: + translations_dir = current_app.config.get("BABEL_TRANSLATION_DIRECTORIES", "translations") + wrapped = Translations.load( - dirname=current_app.config.get("BABEL_TRANSLATION_DIRECTORIES", "translations"), + dirname=translations_dir, locales=locale, domain=domain ) diff --git a/src/flaskpp/module.py b/src/flaskpp/module.py index b5f9fe1..e1eb772 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -1,48 +1,54 @@ -from flask import Blueprint, render_template, url_for +from flask import Blueprint, Response, render_template, url_for, request, send_from_directory, redirect +from werkzeug.exceptions import NotFound from markupsafe import Markup from importlib import import_module from pathlib import Path from typing import Callable, TYPE_CHECKING -import json +import json, typer, subprocess, sys -from flaskpp.utils import (takes_arg, required_arg_count, require_extensions, +from flaskpp.utils import (required_arg_count, require_extensions, enabled, check_required_version) -from flaskpp.utils.debugger import log +from flaskpp.utils.logging import log, warn, debug from flaskpp.exceptions import ModuleError, ManifestError, EventHookException if TYPE_CHECKING: - from flaskpp import FlaskPP + from flaskpp import FlaskPP, Module + from configparser import ConfigParser class ModuleVersion(tuple): - def __new__(cls, major: int, minor: int = None, patch: int = None): - cls.length = 3 - - if patch is None: - cls.length -= 1 - patch = 0 - - if minor is None: - cls.length -= 1 - minor = 0 - + def __new__(cls, major: int, minor: int | None = None, patch: int | None = None): + minor = 0 if minor is None else minor + patch = 0 if patch is None else patch return super().__new__(cls, (major, minor, patch)) + @property + def length(self) -> int: + if self[2] != 0: + return 3 + if self[1] != 0: + return 2 + return 1 + def __str__(self) -> str: return f"v{'.'.join(map(str, self[:self.length]))}" class Module(Blueprint): - def __init__(self, file: str, import_name: str, required_extensions: list = None, - init_routes_on_enable: bool = True): + def __init__(self, file: str, import_name: str, + extends: "Module" = None, required_extensions: list = None, + init_routes_on_enable: bool = True, allowed_for_home: bool = True, + allow_frontend_engine: bool = True): + if not "modules." in import_name: - raise ModuleError("Modules have to be created in the modules package.") + raise ModuleError("Modules have to be created inside the 'modules' package.") self.module_name = import_name.split(".")[-1] self.import_name = import_name self.root_path = Path(file).parent manifest = self.root_path / "manifest.json" self.info = self._load_manifest(manifest) + self.is_base = self.info["type"] == "base" self.required_extensions = required_extensions or [] self.context = { "NAME": self.info["id"], @@ -50,21 +56,42 @@ def __init__(self, file: str, import_name: str, required_extensions: list = None self._handlers = {} self.home = False + if extends and self.is_base: + raise ModuleError("Base modules cannot extend other modules.") + elif extends and not extends.is_base: + raise ModuleError("Modules can only extend base modules.") + + self.base = extends + self.parent = None + + if extends: + self.required_extensions.extend(extends.required_extensions or []) + self.enable = require_extensions(*self.required_extensions)(self._enable) self._on_enable = None self._init_routes = init_routes_on_enable + self._allow_home = allowed_for_home + self._allow_vite = allow_frontend_engine super().__init__( self.info["id"], import_name, - static_folder=(Path(self.root_path) / "static") + static_folder=(Path(self.root_path) / "static"), + url_prefix="/base" if self.is_base else None ) def __repr__(self): return f"<{self.module_name} {self.version}> {self.info.get('description', '')}" def _enable(self, app: "FlaskPP", home: bool): - if home: + if self.is_base: + raise ModuleError(f"[{self.module_name}] Base modules are not allowed to be registered.") + + self.check_module_requirements() + + if home and not self._allow_home: + raise ModuleError(f"[{self.module_name}] Module is not allowed to be registered as home module.") + elif home: self.static_url_path = "/static" app.url_prefix = "/app" self.home = True @@ -72,41 +99,64 @@ def _enable(self, app: "FlaskPP", home: bool): self.url_prefix = f"/{self.name}" self.static_url_path = f"/{self.name}/static" - try: - handling = import_module(f"{self.import_name}.handling") - init_handling = getattr(handling, "init_handling", None) - if not init_handling: - raise ImportError("Missing init function in handling.") - init_handling(self) - except (ModuleNotFoundError, ImportError, TypeError) as e: - log("warn", f"Failed to initialize handling for {self.module_name}: {e}") + if self.base and self.base.parent: + raise ModuleError( + f"[{self.module_name}] Base module '{self.base.module_name}' is already extended by " + f"module '{self.base.parent.module_name}'." + ) + elif self.base: + self.base.check_module_requirements() + self.base.init_handling() + self.base.init_routes() + self.register_blueprint(self.base) + + if self.home: + app.errorhandler(NotFound)(self._not_found) + else: + self.errorhandler(NotFound)(self._not_found) + + app.context[self.base.name.upper()] = self.name + self.base.parent = self + + self.init_handling() if self._init_routes: self.init_routes() - if "sqlalchemy" in self.required_extensions: - try: - data = import_module(f"{self.import_name}.data") - init_data = getattr(data, "init_models", None) - if not init_data: - raise ImportError("Missing init function in data.") - init_data(self) - except (ModuleNotFoundError, ImportError, TypeError) as e: - log("warn", f"Failed to initialize database models for '{self.module_name}': {e}") - - if enabled("FRONTEND_ENGINE"): + if enabled("FRONTEND_ENGINE") and self._allow_vite: from flaskpp.fpp_node.fpp_vite import Frontend engine = Frontend(self) self.context["vite"] = engine.vite self.frontend_engine = engine app.on_shutdown(engine.shutdown) - self.context_processor(lambda: dict( - **self.context, - tailwind=Markup(f"") - )) + if self.base: + mod_tailwind = lambda: Markup( + f"\n" + f"" + ) + context_processor = lambda: dict( + **(self.base.context | self.context), + tailwind=mod_tailwind(), + context=(self.base.context | self.context) + ) + else: + mod_tailwind = lambda: Markup( + f"" + ) + context_processor = lambda: dict( + **self.context, + tailwind=mod_tailwind(), + context=self.context + ) + + if self.home: + app.context_processor(context_processor) + else: + self.context_processor(context_processor) + + if enabled("FPP_PROCESSING") and not self.home: + app.context[f"{self.name}_tailwind"] = mod_tailwind if self._on_enable is not None: self._on_enable(app) @@ -117,79 +167,254 @@ def _load_manifest(self, manifest: Path) -> dict: try: module_data = basic_checked_data(manifest) except (FileNotFoundError, ManifestError, json.JSONDecodeError) as e: - raise ModuleError(f"Failed to load manifest for '{self.module_name}': {e}") + raise ModuleError(f"[{self.module_name}] Failed to load manifest: {e}") if not "id" in module_data: - log("warn", f"Missing id of '{self.module_name}', using package name as id instead.") + warn(f"[{self.module_name}] Missing id; Using package name as id instead.") module_data["id"] = self.module_name if not "name" in module_data: - log("warn", f"Module name of '{self.module_name}' not defined, leaving empty.") + warn(f"[{self.module_name}] Module name not defined, leaving empty.") else: self.module_name = module_data["name"] if not "description" in module_data: - log("warn", f"Missing description of '{self.module_name}'.") + warn(f"[{self.module_name}] Missing description.") if not "author" in module_data: - log("warn", f"Author of '{self.module_name}' not defined.") + warn(f"[{self.module_name}] Author not defined.") if not "requires" in module_data: - log("warn", f"Requirements of '{self.module_name}' not defined.") + warn(f"[{self.module_name}] Requirements not defined.") else: + if not enabled("IN_EXECUTION"): + return module_data + requirements = module_data["requires"] + if not "fpp" in requirements: - log("warn", f"Required Flask++ version of '{self.module_name}' not defined.") + warn(f"[{self.module_name}] Required Flask++ version of not defined.") else: fulfilled = check_required_version(requirements["fpp"]) if not fulfilled: raise ModuleError( - f"Module '{self.module_name}' requires Flask++ version {requirements['fpp']}." - ) - if "modules" in requirements: - from flaskpp.modules import installed_modules - modules = installed_modules(Path(self.root_path).parent) - requirement = requirements["modules"] - - if isinstance(requirement, str): - requirement = [requirement] - - if isinstance(requirement, list): - new = {} - for r in requirement: - if not isinstance(r, str): - raise ManifestError(f"Invalid module requirement '{r}' for '{self.module_name}'.") - r = r.split("@") - if len(r) == 2: - m, v = r - else: - m = r[0] - v = "*" - new[m] = v - requirement = new - - if not isinstance(requirement, dict): - raise ManifestError(f"Invalid modules requirement type '{requirement}' for '{self.module_name}'.") - - required_modules = [m for m in requirement] - fulfilled_modules = [] - - for module in modules: - m, v = module - if not m in required_modules: - continue - if check_required_version(requirement[m], "module", v): - fulfilled_modules.append(m) - - if len(required_modules) != len(fulfilled_modules): - missing = [m for m in required_modules if m not in fulfilled_modules] - raise ModuleError( - f"Missing or mismatching module requirements for '{self.module_name}': {missing}" + f"[{self.module_name}] Module requires Flask++ version {requirements['fpp']}." ) return module_data + def check_module_requirements(self): + if not "requires" in self.info: + return + + requirements = self.info["requires"] + if "modules" in requirements: + from flaskpp.modules import installed_modules, import_base + modules = installed_modules(Path(self.root_path).parent, False) + requirement = requirements["modules"] + + if isinstance(requirement, list): + new = {} + for r in requirement: + if not isinstance(r, str): + raise ManifestError(f"[{self.module_name}] Invalid module requirement '{r}'.") + r = r.split("@") + if len(r) == 2: + m, v = r + else: + m = r[0] + v = "*" + new[m] = v + requirement = new + + if not isinstance(requirement, dict): + raise ManifestError(f"[{self.module_name}] Invalid modules requirement type: {type(requirement)}") + + requirement_copy = requirement.copy() + + for module in modules: + m, v, _ = module + if m not in requirement: + continue + + if not enabled(m): + continue + + if check_required_version(requirement.get(m, "*"), "module", v): + requirement_copy.pop(m) + + requirement = requirement_copy.copy() + + for m, v in requirement.items(): + base = import_base(m) + if base and check_required_version(v, "module", base.version): + requirement_copy.pop(m) + + if len(requirement_copy) > 0: + raise ModuleError( + f"[{self.module_name}] Missing or mismatching module requirements: {[m for m in requirement_copy]}" + ) + + def _not_found(self, error: NotFound) -> Response: + if "static" in request.path: + filename = request.path.replace("/static/", "::").split("::")[-1] + return send_from_directory(Path(self.base.root_path) / "static", filename) + + if request.path == "/" or "/base/base/base" in request.path: + from flaskpp.app.utils.processing import get_handler + return get_handler("handle_app_error")(error) + + prefix = self.url_prefix if self.url_prefix else "" + return redirect(f"{prefix}/base{request.path}") + + def extract(self): + from flaskpp.cli import cwd + extract_path = Path(self.root_path) / "extract" + base_extract_path = Path(self.base.root_path) / "extract" if self.base else None + + def for_dir(path, name): + module_name = self.base.module_name if path == base_extract_path else self.module_name + + for file in (path / name).rglob("*"): + if not file.is_file(): + continue + + rel = file.relative_to(path) + dst = cwd / rel + dst.parent.mkdir(parents=True, exist_ok=True) + if dst.exists(): + typer.echo(typer.style( + f"[{module_name}] Could not extract '{'/'.join(rel.parts)}': " + "File already exists.", fg=typer.colors.YELLOW + )) + continue + + typer.echo(f"[{module_name}] Extracting '{'/'.join(rel.parts)}'.") + + dst.write_bytes( + file.read_bytes() + ) + + for_dir(extract_path, "static") + for_dir(extract_path, "templates") + + if base_extract_path: + for_dir(base_extract_path, "static") + for_dir(base_extract_path, "templates") + + typer.echo(typer.style( + f"[{self.module_name}] Finished extracting modules extract files to main app.", + fg=typer.colors.GREEN, bold=True + )) + + def install_packages(self): + if not "requires" in self.info: + return + + requirements = self.info["requires"] + if not "packages" in requirements: + return + + packages = requirements["packages"] + if not isinstance(packages, list): + typer.echo(typer.style( + f"[{self.module_name}] Invalid packages requirement type: {type(packages)}", + fg=typer.colors.YELLOW, bold=True + )) + return + + base_packages = [] + if self.base and "requires" in self.base.info and "packages" in self.base.info["requires"]: + base_packages = self.base.info["requires"]["packages"] + if isinstance(base_packages, list): + packages = list(set(base_packages) | set(packages)) + else: + typer.echo(typer.style( + f"[{self.base.module_name}] Invalid packages requirement type: {type(base_packages)}", + fg=typer.colors.YELLOW, bold=True + )) + + for package in packages: + module_name = self.base.module_name if package in base_packages else self.module_name + + typer.echo(f"[{module_name}] Installing required package '{package}'...") + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "--upgrade", package], + capture_output=True, + text=True + ) + if result.returncode != 0: + typer.echo(typer.style( + f"[{module_name}] Failed to install package '{package}': {result.stderr}", + fg=typer.colors.RED, bold=True + )) + + typer.echo(typer.style( + f"[{self.module_name}] Finished installing required packages.", + fg=typer.colors.GREEN, bold=True + )) + + def setup_config(self, config: "ConfigParser", config_file_exists: bool = False): + if self.is_base: + return + + try: + cfg = import_module(f"{self.import_name}.config") + module_config = getattr(cfg, "module_config", None) + if not module_config: + raise ImportError() + + if self.base: + try: + base_cfg = import_module(f"{self.base.import_name}.config") + base_module_config = getattr(base_cfg, "module_config", None) + if not base_module_config: + raise ImportError() + extend = base_module_config() or {} + except (ModuleNotFoundError, ImportError, TypeError): + extend = {} + else: + extend = {} + + try: + main = module_config() or {} + except TypeError: + main = {} + + combined = {**extend, **main} + if not combined: + return + + from flaskpp.utils.setup import setup_config + setup_config(config, combined, config_file_exists) + + except (ModuleNotFoundError, ImportError): + return + + def init_models(self): + if self.is_base or "sqlalchemy" not in self.required_extensions: + return + + try: + data = import_module(f"{self.import_name}.data") + init_models = getattr(data, "init_models", None) + if not init_models: + raise ImportError("Missing init function in data.") + init_models(self) + except (ModuleNotFoundError, ImportError, TypeError) as e: + debug(f"[{self.module_name}] Failed to initialize database models: {e}") + + def init_handling(self): + try: + handling = import_module(f"{self.import_name}.handling") + init_handling = getattr(handling, "init_handling", None) + if not init_handling: + raise ImportError("Missing init function in handling.") + init_handling(self) + except (ModuleNotFoundError, ImportError, TypeError) as e: + debug(f"[{self.module_name}] Failed to initialize handling: {e}") + def init_routes(self): try: routes = import_module(f"{self.import_name}.routes") @@ -198,7 +423,7 @@ def init_routes(self): raise ImportError("Missing init function in routes.") init(self) except (ModuleNotFoundError, ImportError, TypeError) as e: - log("warn", f"Failed to register routes for {self.module_name}: {e}") + debug(f"[{self.module_name}] Failed to register routes: {e}") def wrap_message(self, message: str) -> str: domain = self.context.get("DOMAIN") @@ -216,34 +441,64 @@ def tn(self, singular: str, plural: str, n: int) -> str: def handler(self, name: str) -> Callable: def decorator(func): - def wrapper(*args): - return func(self, *args) + def wrapper(*args, **kwargs): + return func(self, *args, **kwargs) self._handlers[name] = wrapper return wrapper return decorator def handle_request(self, handler_name: str) -> Callable: def no_handler(*_, **__): - raise NotImplementedError(f"Module '{self.module_name}' does not have a handler called '{handler_name}'.") + raise NotImplementedError(f"[{self.module_name}] Module does not have a handler called '{handler_name}'.") return self._handlers.get(handler_name, no_handler) def render_template(self, template: str, **context) -> str: - render_name = template if self.home else f"{self.name}/{template}" + if self.is_base and not self.parent: + raise ModuleError(f"[{self.module_name}] Base modules require a parent module to render templates.") + render_name = template if self.ref.home else f"{self.ref.name}/{template}" return render_template(render_name, **context) def url_for(self, endpoint: str, **kwargs) -> str: + if self.is_base and not self.parent: + raise ModuleError(f"[{self.module_name}] Base modules require a parent module to calculate urls.") + elif self.is_base: + return url_for(f"{self.ref.name}.{self.name}.{endpoint}", **kwargs) return url_for(f"{self.name}.{endpoint}", **kwargs) def on_enable(self, fn: Callable) -> Callable: - if not takes_arg(fn, "app") or required_arg_count(fn) != 1: - raise EventHookException(f"{self.import_name}.on_enable must take exactly one non optional argument: 'app'.") + if self.is_base: + raise ModuleError(f"[{self.module_name}] Base modules cannot have on_enable hooks.") + + if required_arg_count(fn) != 1: + raise EventHookException( + f"[{self.module_name}] on_enable hooks must take exactly one non optional argument: 'app'." + ) + self._on_enable = fn return fn + @property + def base_config(self) -> object: + if not self.base: + return object + + try: + base_config_module = import_module(f"{self.base.import_name}.config") + return getattr(base_config_module, "config_class", object) + except (ModuleNotFoundError, ImportError): + return object + @property def version(self) -> ModuleVersion: return valid_version(self.info.get("version", "")) + @property + def ref(self) -> "Module": + ref = self + if self.is_base and self.parent: + ref = self.parent + return ref + def version_check(v: str) -> tuple[bool, str]: version_str = v.lower().strip() @@ -252,14 +507,16 @@ def version_check(v: str) -> tuple[bool, str]: first_char_invalid = False try: - if version_str.startswith("v"): - version_str = version_str[1:] int(version_str[0]) except ValueError: first_char_invalid = True if first_char_invalid \ - or (" " in version_str and not (version_str.endswith("alpha") or version_str.endswith("beta"))): + or (" " in version_str and not ( + version_str.endswith("alpha") + or version_str.endswith("beta") + or version_str.endswith("rc") + )): return False, "Invalid version string format." try: @@ -287,6 +544,11 @@ def basic_checked_data(manifest: Path) -> dict: if not "version" in module_data: raise ManifestError("Module version not defined.") + if not "type" in module_data: + raise ManifestError("Module type not defined.") + elif not module_data["type"] in ["base", "default"]: + raise ManifestError(f"Invalid module type: {module_data['type']}") + return module_data diff --git a/src/flaskpp/modules/__init__.py b/src/flaskpp/modules/__init__.py index 39e7590..cb3ba15 100644 --- a/src/flaskpp/modules/__init__.py +++ b/src/flaskpp/modules/__init__.py @@ -7,25 +7,37 @@ from flaskpp.module import basic_checked_data, valid_version from flaskpp.utils import enabled -from flaskpp.utils.debugger import log, exception +from flaskpp.utils.logging import log, error, warn, exception from flaskpp.exceptions import ManifestError if TYPE_CHECKING: from flask import Flask - from flaskpp import FlaskPP + from flaskpp import FlaskPP, Module -home = Path.cwd() -module_home = home / "modules" -conf_path = home / "app_configs" +module_home = None +conf_path = None _modules = {} +def setup_globals() -> tuple[Path, Path]: + global module_home, conf_path + + from flaskpp.cli import cwd + module_home = cwd / "modules" + conf_path = cwd / "app_configs" + + return module_home, conf_path + + def generate_modlib(app_name: str): + setup_globals() + conf = conf_path / f"{app_name}.conf" config = ConfigParser() config.optionxform = str - if conf.exists(): + conf_exists = conf.exists() + if conf_exists: config.read(conf) if "modules" not in config: config["modules"] = {} @@ -34,13 +46,23 @@ def generate_modlib(app_name: str): typer.style("Okay, now you can activate your installed modules.\n", fg=typer.colors.YELLOW, bold=True) + typer.style("Default is '0' (deactivated)!", fg=typer.colors.MAGENTA)) - for module in installed_modules(module_home, False): - mod_id = module[0] - val = input(f"<{mod_id} {module[1]}>: ").strip() + for module_info in installed_modules(module_home, False): + mod_id = module_info[0] + val = input(f"<{mod_id} {module_info[1]}>: ").strip() if not val: val = "0" config["modules"][mod_id] = val + if val.lower() in ("1", "y", "yes"): + try: + mod = import_module(f"modules.{module_info[2]}") + module = getattr(mod, "module", None) + if not module: + raise ImportError("Failed to import 'module: Module' from module.") + module.setup_config(config, conf_exists) + except (ModuleNotFoundError, ImportError) as e: + typer.echo(typer.style(f"[{mod_id}] Failed to load module: {e}", fg=typer.colors.YELLOW)) + set_home = input( "\n" + typer.style("Do you want to define a home module?", fg=typer.colors.YELLOW, bold=True) + @@ -88,13 +110,13 @@ def register_modules(app: "FlaskPP | Flask"): def register(): nonlocal loader_context, primary_loader - for module in installed_modules(Path(app.root_path) / "modules", False): - mod_id = module[0] + for module_info in installed_modules(Path(app.root_path) / "modules", False): + mod_id = module_info[0] if not enabled(mod_id): continue try: - mod = import_module(f"modules.{mod_id}") + mod = import_module(f"modules.{module_info[2]}") except ModuleNotFoundError as e: exception(e, f"Could not import module '{mod_id}' for app '{app_name}'.") continue @@ -102,40 +124,65 @@ def register(): from flaskpp import Module module = getattr(mod, "module", None) if not isinstance(module, Module): - log("error", f"Missing 'module: Module' in module '{mod_id}'.") + error(f"Missing 'module: Module' in module '{mod_id}'.") continue try: - log("info", f"Registering: {module}") + log(f"Registering: {module}") except ManifestError as e: - exception(e, f"Failed to log {mod_id}.module") + exception(e) continue try: + if enabled("DEBUG_MODE"): + module.extract() + module.install_packages() + is_home = os.getenv("HOME_MODULE", "").lower() == mod_id module.enable(app, is_home) - loader_context[module.name] = FileSystemLoader(f"modules/{mod_id}/templates") + + loader_path = module.import_name.replace('.', '/') + if module.base: + module_loader = ChoiceLoader([ + FileSystemLoader(f"{loader_path}/templates"), + FileSystemLoader(f"{module.base.import_name.replace('.', '/')}/templates") + ]) + else: + module_loader = FileSystemLoader(f"{loader_path}/templates") + + loader_context[module.name] = module_loader if is_home: primary_loader = loader_context[module.name] - log("info", f"Registered module '{module.module_name}' as {'home' if is_home else 'path'}.") + + log(f"[{module.module_name}] Registered module as {'home' if is_home else 'path'}.") + except Exception as e: - exception(e, f"Failed registering module '{module.module_name}'.") + exception(e, f"[{module.module_name}] Failed registering module.") if enabled("FPP_MODULES"): register() loaders = [] + app_loader = FileSystemLoader("templates") + fpp_loader = FileSystemLoader(str((Path(__file__).parent.parent / "app" / "templates").resolve())) + if primary_loader: loaders.append(primary_loader) - loaders.append(FileSystemLoader("templates")) + + loaders.append(ChoiceLoader([ + app_loader, PrefixLoader({ "app": app_loader }) + ])) + loaders.append(PrefixLoader(loader_context)) - loaders.append( - FileSystemLoader(str((Path(__file__).parent.parent / "app" / "templates").resolve())) - ) + + loaders.append(ChoiceLoader([ + fpp_loader, PrefixLoader({ "flaskpp": fpp_loader }) + ])) app.jinja_loader = ChoiceLoader(loaders) + log(f"[{__name__}] Modules registered.") -def installed_modules(package: Path, do_log: bool = True) -> list[tuple[str, str]]: +def installed_modules(package: Path, do_log: bool = True) -> list[tuple[str, str, str]]: if _modules.get(package): return _modules[package] @@ -152,11 +199,26 @@ def installed_modules(package: Path, do_log: bool = True) -> list[tuple[str, str manifest = module / "manifest.json" module_data = basic_checked_data(manifest) version = valid_version(module_data["version"]) + + if "type" in module_data and module_data["type"] == "base": + continue + _modules[package].append( - (module_data.get("id", module.name), version) + (module_data.get("id", module.name), version, module.name) ) except (ModuleNotFoundError, FileNotFoundError, AttributeError, ManifestError, json.JSONDecodeError) as e: - if do_log: log("warn", f"Invalid module package '{module.name}' in {package}: {e}.") + if do_log: warn(f"Invalid module package '{module.name}' in {package}: {e}.") continue return _modules[package] + + +def import_base(module_id: str) -> "Module | None": + try: + mod = import_module(f"modules.{module_id}") + module = getattr(mod, "module", None) + if not module or not module.is_base: + return None + return module + except ModuleNotFoundError: + return None diff --git a/src/flaskpp/modules/_create.py b/src/flaskpp/modules/_create.py index 3ce1519..91e1eed 100644 --- a/src/flaskpp/modules/_create.py +++ b/src/flaskpp/modules/_create.py @@ -2,11 +2,20 @@ from flaskpp.flaskpp import version from flaskpp.module import version_check -from flaskpp.modules import creator_templates, module_home +from flaskpp.modules import creator_templates, setup_globals, import_base from flaskpp.utils import prompt_yes_no, sanitize_text, safe_string +def _config_name(mod_id: str) -> str: + config_name = "" + for s in mod_id.split("_"): + config_name += s.capitalize() + return config_name + + def create_module(module_name: str): + module_home, _ = setup_globals() + tmp_id = safe_string(module_name).lower() mod_id = sanitize_text(input(f"Enter your module id ({tmp_id}): ")) if not mod_id.strip(): @@ -20,19 +29,58 @@ def create_module(module_name: str): f"There is already a folder named '{mod_id}' in modules.", fg=typer.colors.YELLOW, bold=True )) - if not prompt_yes_no("Do you want to overwrite it? (y/N)"): + if not prompt_yes_no("Do you want to overwrite it? (y/N) "): return shutil.rmtree(module_dst) module_dst.mkdir(exist_ok=True) + base_module = prompt_yes_no("Do you want to create a base module? (y/N) ") + extends = False if base_module else prompt_yes_no("Do you want to extend another module? (y/N) ") + + extend_import = "" + extend_id = "" + extend = "" + base = None + if extends: + extend_import = f"from flaskpp.modules import import_base\n" + + extend_id = sanitize_text(input("Enter the id of the module you want to extend [required]: ")) + while not extend_id.strip(): + typer.echo(typer.style( + "If you want to extend another module, you must provide its id.", + fg=typer.colors.RED, bold=True + )) + extend_id = sanitize_text(input("Enter a module id: ")) + + new_extend_id = safe_string(extend_id) + if new_extend_id != extend_id: + typer.echo(typer.style( + f"Module ids must be safe strings! Changed '{extend_id}' to '{new_extend_id}'.", + fg=typer.colors.YELLOW + )) + + extend_id = new_extend_id + base = import_base(extend_id) + if not base: + typer.echo(typer.style( + f"There is no module with id '{extend_id}' installed inside this project.", + fg=typer.colors.RED, bold=True + )) + if not prompt_yes_no("Do you want to continue anyway? (y/N)"): + return + + extend = f'import_base("{extend_id}")' + manifest = { "id": mod_id, "name": sanitize_text(input(f"Enter the name of your module ({module_name}): ")), "description": sanitize_text(input("Describe your module briefly: ")), "version": sanitize_text(input("Enter the version of your module [required]: ")), + "type": "base" if base_module else "default", "author": sanitize_text(input("Enter your name or nickname: ")), "requires": { - "fpp": f">={str(version()).strip("v")}", + "fpp": f">={str(version()).strip('v')}", + "packages": [], "modules": {} } } @@ -47,7 +95,7 @@ def create_module(module_name: str): for info in ["description", "author"]: if not manifest[info].strip(): - typer.echo(typer.style(f"Missing {info}... Ignoring manifest entry.", fg=typer.colors.YELLOW, bold=True)) + typer.echo(typer.style(f"Missing {info}... Ignoring manifest entry.", fg=typer.colors.YELLOW)) manifest.pop(info) typer.echo(typer.style(f"Writing manifest...", bold=True)) @@ -75,14 +123,21 @@ def create_module(module_name: str): templates = module_dst / "templates" templates.mkdir(exist_ok=True) + register_config = "@register_config()" + if base_module: + config_import = "" + register_config = "" - config_name = "" - for s in mod_id.split("_"): - config_name += s.capitalize() + else: + config_import = "from flaskpp.app.config import register_config\n" + config_name = _config_name(mod_id) (module_dst / "config.py").write_text( creator_templates.module_config.format( - name=config_name + config_import=config_import, + register=register_config, + name=config_name, + config_export=f"\n\nconfig_class = {config_name}Config" if base_module else "" )) (module_dst / "routes.py").write_text( creator_templates.module_routes @@ -95,22 +150,40 @@ def create_module(module_name: str): ) (css / "tailwind_raw.css").write_text(creator_templates.tailwind_raw) + extract = module_dst / "extract" + (extract / "static").mkdir(parents=True, exist_ok=True) + (extract / "templates").mkdir(exist_ok=True) + typer.echo(typer.style(f"Setting up requirements...", bold=True)) required = [] + base_extensions = base.required_extensions if base else [] for extension in creator_templates.extensions: require = prompt_yes_no(f"Do you want to use {extension} in this module? (y/N) ") + + if extension == "sqlalchemy" and (require or extension in base_extensions): + data = module_dst / "data" + data.mkdir(exist_ok=True) + if not base_module: + (data / "__init__.py").write_text(creator_templates.module_data_init) + if not require: continue + required.append(f'"{extension}"') - if extension == "sqlalchemy": - data = module_dst / "data" - data.mkdir(exist_ok=True) - (data / "__init__.py").write_text(creator_templates.module_data_init) + + requires = "" + if required: + requires = f"""\n\trequired_extensions=[ + {",\n\t\t".join(required)} + ],""" (module_dst / "__init__.py").write_text( creator_templates.module_init.format( - requirements=",\n\t\t".join(required) + extend_import=extend_import, + extend=f"\n\textends={extend}," if extends else "", + requires=requires, + is_base="\n\tis_base=True" if base_module else "" ) ) diff --git a/src/flaskpp/modules/_install.py b/src/flaskpp/modules/_install.py index d200ff5..fcb1d0f 100644 --- a/src/flaskpp/modules/_install.py +++ b/src/flaskpp/modules/_install.py @@ -1,13 +1,40 @@ from pathlib import Path from git import Repo, exc +from importlib import import_module import typer, shutil -from flaskpp.modules import module_home +from flaskpp.modules import setup_globals def install_module(module_id: str, src: str): + module_home, _ = setup_globals() + if not src: raise NotImplementedError("Module hub is not ready yet.") + def finalize(): + try: + mod = import_module(f"modules.{module_id}") + module = getattr(mod, "module", None) + + from flaskpp import Module + if not isinstance(module, Module): + raise ImportError("Failed to import 'module: Module'.") + + if not module.is_base: + module.extract() + module.install_packages() + + typer.echo(typer.style( + f"Module '{module}' has been successfully installed.", + fg=typer.colors.GREEN, bold=True + )) + + except (ModuleNotFoundError, ImportError, TypeError) as e: + typer.echo(typer.style( + f"Failed to load module: {e}", + fg=typer.colors.RED, bold=True + )) + typer.echo(f"Installing {module_id}...") mod_src = Path(src) mod_dst = module_home / module_id @@ -22,6 +49,7 @@ def install_module(module_id: str, src: str): )) return shutil.copytree(mod_src, mod_dst, dirs_exist_ok=True) + finalize() return if not src.startswith("http"): @@ -37,8 +65,6 @@ def install_module(module_id: str, src: str): "Failed to clone from source.", fg=typer.colors.YELLOW, bold=True )) + return - typer.echo(typer.style( - f"Module '{module_id}' has been successfully installed.", - fg=typer.colors.GREEN, bold=True - )) + finalize() diff --git a/src/flaskpp/modules/creator_templates.py b/src/flaskpp/modules/creator_templates.py index 9ef4979..a91cbf2 100644 --- a/src/flaskpp/modules/creator_templates.py +++ b/src/flaskpp/modules/creator_templates.py @@ -11,20 +11,17 @@ "jwt_extended" ] -module_init = """ -from flaskpp import Module + +module_init = """from flaskpp import Module +{extend_import} module = Module( __file__, - __name__, - [ - {requirements} - ] -) -""" - -module_handling = """ -from pathlib import Path + __name__,{extend}{requires}{is_base} +)""" + + +module_handling = """from pathlib import Path from importlib import import_module from flaskpp import Module @@ -36,16 +33,19 @@ def init_handling(mod: Module): for file in _package.rglob("*.py"): if file.stem == "__init__" or file.stem.startswith("noinit"): continue - handler_name = file.stem + + rel = file.relative_to(_package).with_suffix("") + handler_name = ".".join(rel.parts) + handler = import_module(f"{mod.import_name}.handling.{handler_name}") handle_request = getattr(handler, "handle_request", None) if not handle_request: continue - mod.handler(handler_name)(handle_request) -""" -handling_example = """ -from flask import flash, redirect + mod.handler(handler_name)(handle_request)""" + + +handling_example = """from flask import flash, redirect from flaskpp import Module from flaskpp.utils import enabled @@ -55,11 +55,10 @@ def handle_request(mod: Module, *args): if not enabled("FRONTEND_ENGINE"): flash("Vite is not enabled for this app.", "warning") return redirect("/") - return mod.render_template("vite_index.html") -""" + return mod.render_template("vite_index.html")""" -module_routes = """ -from flaskpp import Module + +module_routes = """from flaskpp import Module from flaskpp.app.utils.auto_nav import autonav_route @@ -70,21 +69,36 @@ def index(): autonav_route(mod, "/vite-index", mod.t("Vite Test"))( mod.handle_request("vite_index") - ) -""" + )""" -module_config = """ -from flaskpp.app.config import register_config +module_config = """# from security import token_hex +{config_import} +from . import module -@register_config() -class {name}Config: +{register} +class {name}Config(module.base_config): # TODO: Write your modules required config data here pass -""" + + +def module_config(): + # return {{ + # TODO: Write required config data (will be prompted by the setup if module is set to 1) + + # "protected_MY_SECRET": token_hex(32), + # -> protected keys won't be prompted to the user + + # "default_FEATURE_KEY": "Hello World!", + # -> default keys will be prompted with their default value shown (and written with if input left empty) + + # "ADDITIONAL_DATA": "", + # -> simple config prompt without default value + # }} + pass{config_export}""" -module_index = """ -{% extends "base_example.html" %} + +module_index = """{% extends "base_example.html" %} {# The base template is natively provided by Flask++. #} {% block title %}{{ _('My Module') }}{% endblock %} @@ -95,18 +109,16 @@ class {name}Config:

{{ _('Welcome!') }}

{{ _('This is my wonderful new module.') }}

-{% endblock %} -""" +{% endblock %}""" + -module_vite_index = """ -{% extends "base_example.html" %} +module_vite_index = """{% extends "base_example.html" %} {% block title %}{{ _('Home') }}{% endblock %} -{% block head %}{{ vite('main.js') }}{% endblock %} -""" +{% block head %}{{ vite('main.js') }}{% endblock %}""" -module_data_init = """ -from pathlib import Path + +module_data_init = """from pathlib import Path from importlib import import_module from flaskpp import Module @@ -118,15 +130,14 @@ def init_models(mod: Module): for file in _package.rglob("*.py"): if file.stem == "__init__" or file.stem.startswith("noinit"): continue - import_module(f"{mod.import_name}.data.{file.stem}") -""" + rel = file.relative_to(_package).with_suffix("") + import_module(f"{mod.import_name}.data.{".".join(rel.parts)}")""" + -tailwind_raw = """ -@import "tailwindcss" source("../../"); +tailwind_raw = """@import "tailwindcss" source("../../"); @source not "../../vite"; @theme { /* ... */ -} -""" +}""" diff --git a/src/flaskpp/socket.py b/src/flaskpp/socket.py index 1f8c6bf..77a8091 100644 --- a/src/flaskpp/socket.py +++ b/src/flaskpp/socket.py @@ -2,20 +2,22 @@ from werkzeug.http import parse_accept_header from werkzeug.datastructures import LanguageAccept from contextvars import ContextVar +from immutables import Map from http.cookies import SimpleCookie from typing import Callable, Any, TYPE_CHECKING from flaskpp.utils import enabled, random_code, async_result, decorate -from flaskpp.utils.debugger import log, exception +from flaskpp.utils.logging import log, exception if TYPE_CHECKING: from flaskpp import FlaskPP class _EventContext: - def __init__(self, ctx: ContextVar, session: dict): + def __init__(self, ctx: ContextVar, session: dict, namespace: str): self.ctx = ctx - self.session = session + self.session = Map(session) + self.namespace = namespace def __enter__(self): self.token = self.ctx.set(self) @@ -46,13 +48,16 @@ def __init__(self, app: "FlaskPP" = None, default_processing: bool = False, if app is not None: self.init_app(app) - def _event_context(self, session: dict) -> _EventContext: - return _EventContext(self._context, session) + def _event_context(self, session: dict, namespace: str) -> _EventContext: + return _EventContext(self._context, session, namespace) async def _on_connect(self, sid: str, environ: dict): if self.app is None: RuntimeError("Cannot establish connection: 'app' is None. Did you run init_app(app)?") + ip = environ.get("HTTP_X_FORWARDED_FOR", environ.get("REMOTE_ADDR")) + log(f"[SOCKET] Connection from {ip} with session id: {sid}") + cookies = _get_cookies(environ) accept_lang = _get_accept_languages(environ) @@ -73,7 +78,7 @@ async def _on_default(self, sid: str, data: dict) -> Any: payload = data.get("payload") event, namespace = resolve_namespace(event) - log("request", f"Socket event from {sid}: {event}@{namespace} - With data: {payload}") + log(f"[EVENT] Socket event from {sid}: {event}@{namespace} - With data: {payload}") def no_handler(*_): raise NotImplementedError(f"Socket event handler {event}@{namespace} not found.") handler = self.get_handler(event, namespace) or no_handler @@ -130,8 +135,7 @@ async def wrapper(*args): sid, payload = args async with self.session(sid) as s: - s["__namespace__"] = ns - with self._event_context(s): + with self._event_context(s, ns): try: result = fn(sid, payload) if pass_sid else fn(payload) result = await async_result(result) @@ -255,13 +259,21 @@ def event_context(self) -> _EventContext | None: return self._context.get() @property - def current_session(self) -> dict | None: + def current_session(self) -> Map | None: try: ctx = self.event_context return ctx.session if ctx else None except LookupError: return None + @property + def current_namespace(self) -> str | None: + try: + ctx = self.event_context + return ctx.namespace if ctx else None + except LookupError: + return None + def resolve_namespace(name: str) -> tuple[str, str]: if "@" in name: diff --git a/src/flaskpp/utils/__init__.py b/src/flaskpp/utils/__init__.py index 31c05bb..24a8760 100644 --- a/src/flaskpp/utils/__init__.py +++ b/src/flaskpp/utils/__init__.py @@ -2,7 +2,7 @@ from typing import Callable, Any, TYPE_CHECKING import os, string, random, socket, inspect, re -from flaskpp.utils.debugger import log +from flaskpp.utils.logging import warn if TYPE_CHECKING: from flaskpp import FppVersion, ModuleVersion @@ -134,7 +134,7 @@ def decorator(func): def wrapper(*args, **kwargs): for ext in extensions: if not isinstance(ext, str): - log("warn", f"Invalid extension '{ext}'.") + warn(f"Invalid extension '{ext}'.") continue if not enabled(f"EXT_{ext.upper()}"): diff --git a/src/flaskpp/utils/debugger.py b/src/flaskpp/utils/debugger.py deleted file mode 100644 index 23d2a7e..0000000 --- a/src/flaskpp/utils/debugger.py +++ /dev/null @@ -1,41 +0,0 @@ -from datetime import datetime -import traceback, sys - -_debug = False - - -def get_time() -> str: - offset = datetime.now().astimezone().utcoffset() - hours = int(offset.total_seconds() // 3600) - minutes = int((offset.total_seconds() % 3600) // 60) - - sign = '+' if offset.total_seconds() >= 0 else '-' - offset_str = f"{sign}{abs(hours):02d}{abs(minutes):02d}" - - return f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {offset_str}" - - -def log(category: str, message: str): - log_str = f"[FLASK]\t[{get_time()}] [{category.upper()}] {message}" - print(log_str) - - -def exception(error: Exception, message: str = None): - if _debug: - tb_str = "".join(traceback.format_exception(*sys.exc_info())) - else: - tb_str = "".join(traceback.format_exc(limit=3)) - msg = f"{message.strip()}\n" if message else "" - log("exception", f"{msg}{type(error).__name__}: {error}\n{tb_str.strip()}") - - -def debug_msg(message: str): - if _debug: - log("debug", message) - - -def start_session(debug: bool): - global _debug - _debug = debug - log("info", "Flask plug & play module server running.") - log("info", f"Loglevel {'debug' if debug else 'info'}.") diff --git a/src/flaskpp/utils/logging.py b/src/flaskpp/utils/logging.py new file mode 100644 index 0000000..c2ac057 --- /dev/null +++ b/src/flaskpp/utils/logging.py @@ -0,0 +1,70 @@ +import traceback, sys, logging + +_execution = False +_initialized = False + +DEBUG = logging.DEBUG +INFO = logging.INFO +WARNING = logging.WARNING +ERROR = logging.ERROR +CRITICAL = logging.CRITICAL + + +def _get_logger(level: int = INFO) -> logging.Logger: + global _initialized + if _initialized: + return logging.getLogger("Flask++") + + logger = logging.getLogger("Flask++") + logger.setLevel(level) + logger.propagate = False + + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.NOTSET) + + formatter = logging.Formatter( + "[%(name)s]\t[%(asctime)s] [%(levelname)s]\t%(message)s", + datefmt="%Y-%m-%d %H:%M:%S %z" + ) + handler.setFormatter(formatter) + + logger.addHandler(handler) + _initialized = True + return logger + + +def log(message: str, category: int = INFO): + if not _execution: + return + + if not _logger: + raise RuntimeError("You must call start_session before you start logging.") + + _get_logger().log(category, message) + print(category, message) + + +def exception(exc: Exception, message: str = None): + msg = f"{message.strip()}\n" if message else "" + tb_str = "".join(traceback.format_exception(*sys.exc_info())) + error(f"{msg}{type(exc).__name__}: {exc}\n{tb_str.strip()}") + + +def start_session(level: str = "info"): + global _execution + from flaskpp.utils import enabled + _execution = enabled("IN_EXECUTION") + if not _execution: + return + + global _logger + level = level.upper() + _logger = _get_logger(getattr(logging, level, INFO)) + + log("Starting Flask++ application.") + + +def debug(msg): log(msg, DEBUG) +def warn(msg): log(msg, WARNING) +def error(msg): log(msg, ERROR) +def critical(msg): log(msg, CRITICAL) diff --git a/src/flaskpp/utils/run.py b/src/flaskpp/utils/run.py index b50b43f..be317d5 100755 --- a/src/flaskpp/utils/run.py +++ b/src/flaskpp/utils/run.py @@ -21,7 +21,8 @@ def prepare(): conf_path.mkdir(parents=True, exist_ok=True) logs_path.mkdir(parents=True, exist_ok=True) if not any(conf_path.glob("*.conf")): - subprocess.run([sys.executable, str(root_path / "setup.py")], check=True) + subprocess.run([sys.executable, "-m", "flaskpp", "setup"], check=True) + typer.echo("\n\n") def _env_from_conf(conf_file: Path) -> dict: @@ -63,6 +64,7 @@ def start_app(conf_file: Path, default_port: int, reload: bool = False) -> int: )) base_env = _env_from_conf(conf_file) + base_env["IN_EXECUTION"] = "1" base_env["APP_NAME"] = app_name if reload and app_name in apps: diff --git a/src/flaskpp/utils/setup.py b/src/flaskpp/utils/setup.py index e00d4d3..0a9ef4a 100644 --- a/src/flaskpp/utils/setup.py +++ b/src/flaskpp/utils/setup.py @@ -3,7 +3,7 @@ from configparser import ConfigParser import typer -from flaskpp.utils import prompt_yes_no +from flaskpp.utils import prompt_yes_no, sanitize_text from flaskpp.modules import generate_modlib counting_map = { @@ -23,7 +23,7 @@ def base_config(): }, "database": { - "default_DATABASE_URL": "sqlite:///appdata.db", + "default_DATABASE_URI": "sqlite:///appdata.db", }, "redis": { @@ -40,9 +40,9 @@ def base_config(): "mail": { "MAIL_SERVER": "", - "default_MAIL_PORT": 25, - "default_MAIL_USE_TLS": True, - "default_MAIL_USE_SSL": False, + "default_MAIL_PORT": 587, + "default_MAIL_USE_TLS": 1, + "MAIL_USE_SSL": "", "MAIL_USERNAME": "", "MAIL_PASSWORD": "", "default_MAIL_DEFAULT_SENDER": "noreply@example.com", @@ -74,17 +74,51 @@ def base_config(): "dev": { "DB_AUTOUPDATE": 0, + "I18N_AUTOUPDATE": 0, } } +def setup_config(config: ConfigParser, base: dict, config_file_exists: bool = False) -> ConfigParser: + for k, v in base.items(): + print_key = False + + if k not in config: + print_key = True + config[k] = {} + + for key, value in v.items(): + if key.startswith("protected_"): + key = key.removeprefix("protected_") + if not (config_file_exists and config[k].get(key)): + config[k][key] = str(value) + continue + + if print_key: + typer.echo(typer.style(f"\n{k.upper()}", bold=True)) + print_key = False + + if key.startswith("default_"): + key = key.removeprefix("default_") + input_prompt = f"{key} ({value}): " + else: + input_prompt = f"{key}: " + + val = sanitize_text(input(input_prompt)).strip() + if not val: + val = str(value) + config[k][key] = val + + return config + + def welcome(): typer.echo("\n------------------ " + typer.style("Flask++ Setup", bold=True) + " ------------------\n") typer.echo("Thank you for using our framework to build your own") typer.echo("Flask++ apps! We will try our best to get you ready") - typer.echo("within the next two minutes. 💚 Start a timer! 😉\n") + typer.echo("within the next two minutes. 💚 Start a timer! 😉\n") typer.echo(" " + typer.style( "~ GrowVolution 2025 - MIT License ~", @@ -109,6 +143,7 @@ def setup_app(app_number: int): app = app_name(app_number) conf = conf_path / f"{app}.conf" + conf_exists = conf.exists() if conf_exists: config.read(conf) @@ -116,26 +151,7 @@ def setup_app(app_number: int): typer.echo(typer.style("Okay, let's setup your app config.\n", fg=typer.colors.YELLOW, bold=True) + typer.style("Leave blank to stick with the defaults.", fg=typer.colors.MAGENTA)) - for k, v in base_config().items(): - if k not in config: - config[k] = {} - for key, value in v.items(): - if key.startswith("protected_"): - key = key.removeprefix("protected_") - if not (conf_exists and config[k].get(key)): - config[k][key] = str(value) - continue - - if key.startswith("default_"): - key = key.removeprefix("default_") - input_prompt = f"{key} ({value}): " - else: - input_prompt = f"{key}: " - - val = input(input_prompt).strip() - if not val: - val = str(value) - config[k][key] = val + config = setup_config(config, base_config(), conf_exists) with open(conf, "w") as f: config.write(f) @@ -148,9 +164,9 @@ def setup_app(app_number: int): ) + "\n") register_app = prompt_yes_no(typer.style( - f"Do you want to register {app} as a service? (y/N): ", - fg=typer.colors.MAGENTA, bold=True - ) + "\n") + f"Do you want to register {app} as a service?", + fg=typer.colors.YELLOW, bold=True + ) + " (y/N): ") if register_app: from .service_registry import register