From f9461ca268f3cc710944240495f40128f94e25ad Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 26 Jan 2026 16:37:11 +0000 Subject: [PATCH 1/9] Improved consistency --- DOCS.md | 27 +++++++++++++++++------- pyproject.toml | 2 +- src/flaskpp/app/config/default.py | 2 +- src/flaskpp/module.py | 8 ++++--- src/flaskpp/modules/creator_templates.py | 9 ++++++-- src/flaskpp/utils/setup.py | 14 ++++++------ 6 files changed, 40 insertions(+), 22 deletions(-) diff --git a/DOCS.md b/DOCS.md index bb1e0e9..43d18c3 100644 --- a/DOCS.md +++ b/DOCS.md @@ -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 # ------------------------------------------------- @@ -311,9 +311,9 @@ module = Module( # "api", # "jwt_extended" ], - - # And you can optionally turn off init_routes_on_enable (default is True): - False + # 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.) ) # Now if you need to do stuff when your module gets enabled: @@ -354,11 +354,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) ``` @@ -427,7 +431,8 @@ 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)}") ``` ### Working with Modules @@ -624,7 +629,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 +647,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 diff --git a/pyproject.toml b/pyproject.toml index 195003d..9c4f165 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flaskpp" -version = "0.3.8" +version = "0.3.10" 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" } diff --git a/src/flaskpp/app/config/default.py b/src/flaskpp/app/config/default.py index b70db4e..bb69f66 100644 --- a/src/flaskpp/app/config/default.py +++ b/src/flaskpp/app/config/default.py @@ -11,7 +11,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 diff --git a/src/flaskpp/module.py b/src/flaskpp/module.py index b5f9fe1..2e2b2d8 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -252,14 +252,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: diff --git a/src/flaskpp/modules/creator_templates.py b/src/flaskpp/modules/creator_templates.py index 9ef4979..3d9c18c 100644 --- a/src/flaskpp/modules/creator_templates.py +++ b/src/flaskpp/modules/creator_templates.py @@ -36,11 +36,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) """ @@ -118,7 +122,8 @@ 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 = """ diff --git a/src/flaskpp/utils/setup.py b/src/flaskpp/utils/setup.py index e00d4d3..5cb62ff 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": { @@ -84,7 +84,7 @@ def welcome(): " ------------------\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 ~", @@ -132,7 +132,7 @@ def setup_app(app_number: int): else: input_prompt = f"{key}: " - val = input(input_prompt).strip() + val = sanitize_text(input(input_prompt)).strip() if not val: val = str(value) config[k][key] = val @@ -148,9 +148,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 From b3df18acdc75c58702a847103081c01cbb33e427 Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 26 Jan 2026 21:15:58 +0000 Subject: [PATCH 2/9] Some further improvements --- DOCS.md | 8 +++ pyproject.toml | 2 +- src/flaskpp/app/static/css/tailwind_raw.css | 2 +- src/flaskpp/cli.py | 2 + src/flaskpp/module.py | 77 +++++++++++++++++++-- src/flaskpp/modules/__init__.py | 6 +- src/flaskpp/modules/_create.py | 5 ++ src/flaskpp/modules/_install.py | 31 +++++++-- 8 files changed, 117 insertions(+), 16 deletions(-) diff --git a/DOCS.md b/DOCS.md index 43d18c3..ad4af08 100644 --- a/DOCS.md +++ b/DOCS.md @@ -278,6 +278,10 @@ A fully qualified manifest file would then look like this: "version": "0.1", "requires": { "fpp": ">=0.3.5", // the minimum required version of Flask++ + "packages": [ // PyPI packages that are required by your module + // e.g. "numpy", "pandas", + // ... + ], "modules": { // other modules that are required by your module "module_id_01": "==0.2", "module_id_02": "<0.7" @@ -435,6 +439,10 @@ def init_models(mod: Module): import_module(f"{mod.import_name}.data.{".".join(rel.parts)}") ``` +#### Extracting + +The Module class provides a `module.extract()` function. This function is meant to be used by the Flask++ CLI 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. diff --git a/pyproject.toml b/pyproject.toml index 9c4f165..10d27d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flaskpp" -version = "0.3.10" +version = "0.3.11" 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" } diff --git a/src/flaskpp/app/static/css/tailwind_raw.css b/src/flaskpp/app/static/css/tailwind_raw.css index f76c0ef..34a2477 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; } .nav-link.active { diff --git a/src/flaskpp/cli.py b/src/flaskpp/cli.py index fdd1ac8..604474b 100644 --- a/src/flaskpp/cli.py +++ b/src/flaskpp/cli.py @@ -1,4 +1,5 @@ from importlib.metadata import version +from pathlib import Path import typer from flaskpp._help import help_message @@ -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) diff --git a/src/flaskpp/module.py b/src/flaskpp/module.py index 2e2b2d8..1ee3355 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -3,8 +3,9 @@ from importlib import import_module from pathlib import Path from typing import Callable, TYPE_CHECKING -import json +import json, typer, subprocess, sys +from flaskpp.cli import cwd from flaskpp.utils import (takes_arg, required_arg_count, require_extensions, enabled, check_required_version) from flaskpp.utils.debugger import log @@ -34,7 +35,7 @@ def __str__(self) -> str: class Module(Blueprint): def __init__(self, file: str, import_name: str, required_extensions: list = None, - init_routes_on_enable: bool = True): + init_routes_on_enable: bool = True, allowed_for_home: bool = True): if not "modules." in import_name: raise ModuleError("Modules have to be created in the modules package.") @@ -53,6 +54,7 @@ def __init__(self, file: str, import_name: str, required_extensions: list = None 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 super().__init__( self.info["id"], @@ -64,7 +66,9 @@ def __repr__(self): return f"<{self.module_name} {self.version}> {self.info.get('description', '')}" def _enable(self, app: "FlaskPP", home: bool): - if home: + if home and not self._allow_home: + raise ModuleError(f"Module '{self.module_name}' is not allowed to be registered as home module.") + elif home: self.static_url_path = "/static" app.url_prefix = "/app" self.home = True @@ -139,6 +143,7 @@ def _load_manifest(self, manifest: Path) -> dict: else: requirements = module_data["requires"] + if not "fpp" in requirements: log("warn", f"Required Flask++ version of '{self.module_name}' not defined.") else: @@ -147,14 +152,12 @@ def _load_manifest(self, manifest: Path) -> dict: 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: @@ -170,7 +173,7 @@ def _load_manifest(self, manifest: Path) -> dict: requirement = new if not isinstance(requirement, dict): - raise ManifestError(f"Invalid modules requirement type '{requirement}' for '{self.module_name}'.") + raise ManifestError(f"Invalid modules requirement type for '{self.module_name}': {type(requirement)}") required_modules = [m for m in requirement] fulfilled_modules = [] @@ -190,6 +193,66 @@ def _load_manifest(self, manifest: Path) -> dict: return module_data + def extract(self): + extract_path = self.root_path / "extract" + + def for_dir(name): + for file in (extract_path / name).rglob("*"): + if not file.is_file(): + continue + + rel = file.relative_to(extract_path) + dst = cwd / rel + dst.parent.mkdir(parents=True, exist_ok=True) + if dst.exists(): + typer.echo(typer.style( + f"Module '{self.module_name}' couldn't extract '{'/'.join(rel.parts)}': " + "File already exists.", fg=typer.colors.YELLOW, bold=True + )) + continue + + dst.write_bytes( + file.read_bytes() + ) + + for_dir("static") + for_dir("templates") + + 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"Invalid packages requirement type for '{self.module_name}': {type(packages)}", + fg=typer.colors.YELLOW, bold=True + )) + return + + for package in packages: + typer.echo(f"Installing required package '{package}'...") + result = subprocess.run( + [sys.executable, "-m", "pip", f"install --upgrade {package}"], + capture_output=True, + text=True + ) + if result.returncode != 0: + typer.echo(typer.style( + f"Failed to install package '{package}' for '{self.module_name}': {result.stderr}", + fg=typer.colors.RED, bold=True + )) + + typer.echo(typer.style( + f"Finished installing required packages for '{self.module_name}'.", + fg=typer.colors.GREEN + )) + + def init_routes(self): try: routes = import_module(f"{self.import_name}.routes") diff --git a/src/flaskpp/modules/__init__.py b/src/flaskpp/modules/__init__.py index 39e7590..8f283b1 100644 --- a/src/flaskpp/modules/__init__.py +++ b/src/flaskpp/modules/__init__.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING import os, typer, json +from flaskpp.cli import cwd from flaskpp.module import basic_checked_data, valid_version from flaskpp.utils import enabled from flaskpp.utils.debugger import log, exception @@ -14,9 +15,8 @@ from flask import Flask from flaskpp import FlaskPP -home = Path.cwd() -module_home = home / "modules" -conf_path = home / "app_configs" +module_home = cwd / "modules" +conf_path = cwd / "app_configs" _modules = {} diff --git a/src/flaskpp/modules/_create.py b/src/flaskpp/modules/_create.py index 3ce1519..ce5a869 100644 --- a/src/flaskpp/modules/_create.py +++ b/src/flaskpp/modules/_create.py @@ -33,6 +33,7 @@ def create_module(module_name: str): "author": sanitize_text(input("Enter your name or nickname: ")), "requires": { "fpp": f">={str(version()).strip("v")}", + "packages": [], "modules": {} } } @@ -95,6 +96,10 @@ 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 = [] diff --git a/src/flaskpp/modules/_install.py b/src/flaskpp/modules/_install.py index d200ff5..94c4c8b 100644 --- a/src/flaskpp/modules/_install.py +++ b/src/flaskpp/modules/_install.py @@ -1,5 +1,6 @@ from pathlib import Path from git import Repo, exc +from importlib import import_module import typer, shutil from flaskpp.modules import module_home @@ -8,6 +9,29 @@ def install_module(module_id: str, src: str): 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 load 'module: Module'.") + + 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 +46,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 +62,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() From de72099227438a278e02a83c07326462de89ff75 Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 26 Jan 2026 23:18:45 +0000 Subject: [PATCH 3/9] Improved setup system --- DOCS.md | 17 +++++++- pyproject.toml | 2 +- src/flaskpp/_init.py | 28 +++++++++++--- src/flaskpp/module.py | 2 +- src/flaskpp/modules/__init__.py | 29 ++++++++++++-- src/flaskpp/modules/creator_templates.py | 17 ++++++++ src/flaskpp/utils/setup.py | 49 ++++++++++++++---------- 7 files changed, 111 insertions(+), 33 deletions(-) diff --git a/DOCS.md b/DOCS.md index ad4af08..9217037 100644 --- a/DOCS.md +++ b/DOCS.md @@ -405,7 +405,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 @@ -417,6 +417,21 @@ from flaskpp.app.config import register_config class ModuleConfig: # TODO: Overwrite default config values or provide your own 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 ``` #### Data Package diff --git a/pyproject.toml b/pyproject.toml index 10d27d2..7044178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flaskpp" -version = "0.3.11" +version = "0.3.13" 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" } diff --git a/src/flaskpp/_init.py b/src/flaskpp/_init.py index 2ce78e3..f3508c3 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,7 +9,7 @@ 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 @@ -23,15 +22,15 @@ 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: + with open(cwd / "main.py", "w") as f: f.write(""" from flaskpp import FlaskPP @@ -77,10 +76,27 @@ def create_app(): } """) + (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/module.py b/src/flaskpp/module.py index 1ee3355..8d99177 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -237,7 +237,7 @@ def install_packages(self): for package in packages: typer.echo(f"Installing required package '{package}'...") result = subprocess.run( - [sys.executable, "-m", "pip", f"install --upgrade {package}"], + [sys.executable, "-m", "pip", "install", "--upgrade", package], capture_output=True, text=True ) diff --git a/src/flaskpp/modules/__init__.py b/src/flaskpp/modules/__init__.py index 8f283b1..c2c717c 100644 --- a/src/flaskpp/modules/__init__.py +++ b/src/flaskpp/modules/__init__.py @@ -25,7 +25,8 @@ def generate_modlib(app_name: str): 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"] = {} @@ -41,6 +42,26 @@ def generate_modlib(app_name: str): val = "0" config["modules"][mod_id] = val + if enabled(val): + from flaskpp.utils.setup import setup_config + try: + conf = import_module(f"modules.{module[2]}.config") + module_config = getattr(conf, "module_config", None) + if not module_config: + raise ImportError() + + base_config = module_config() + if not base_config: + raise ImportError() + + base = { + mod_id: base_config + } + setup_config(config, base, conf_exists) + typer.echo("\n") + except (ModuleNotFoundError, ImportError, TypeError): + pass + set_home = input( "\n" + typer.style("Do you want to define a home module?", fg=typer.colors.YELLOW, bold=True) + @@ -94,7 +115,7 @@ def register(): continue try: - mod = import_module(f"modules.{mod_id}") + mod = import_module(f"modules.{module[2]}") except ModuleNotFoundError as e: exception(e, f"Could not import module '{mod_id}' for app '{app_name}'.") continue @@ -135,7 +156,7 @@ def register(): app.jinja_loader = ChoiceLoader(loaders) -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] @@ -153,7 +174,7 @@ def installed_modules(package: Path, do_log: bool = True) -> list[tuple[str, str module_data = basic_checked_data(manifest) version = valid_version(module_data["version"]) _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}.") diff --git a/src/flaskpp/modules/creator_templates.py b/src/flaskpp/modules/creator_templates.py index 3d9c18c..d0e507c 100644 --- a/src/flaskpp/modules/creator_templates.py +++ b/src/flaskpp/modules/creator_templates.py @@ -79,12 +79,29 @@ def index(): module_config = """ from flaskpp.app.config import register_config +from security import token_hex @register_config() class {name}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 """ module_index = """ diff --git a/src/flaskpp/utils/setup.py b/src/flaskpp/utils/setup.py index 5cb62ff..cc65304 100644 --- a/src/flaskpp/utils/setup.py +++ b/src/flaskpp/utils/setup.py @@ -78,6 +78,33 @@ def base_config(): } +def setup_config(config: ConfigParser, base: dict, config_file_exists: bool = False) -> ConfigParser: + for k, v in base.items(): + if k not in config: + typer.echo(typer.style(f"\n{k.upper()}", bold=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 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) + @@ -109,6 +136,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 +144,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 = sanitize_text(input(input_prompt)).strip() - if not val: - val = str(value) - config[k][key] = val + setup_config(config, base_config(), conf_exists) with open(conf, "w") as f: config.write(f) From e0590ab8407adf1c4116689f2d4828670e5ef805 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 27 Jan 2026 15:20:54 +0000 Subject: [PATCH 4/9] v0.3.x final version --- DOCS.md | 2 +- pyproject.toml | 2 +- src/flaskpp/app/config/__init__.py | 15 +++-- src/flaskpp/app/utils/auto_nav.py | 22 ++++--- src/flaskpp/app/utils/fst.py | 95 ++++++++++++++++++++++++++++++ src/flaskpp/babel.py | 7 ++- src/flaskpp/flaskpp.py | 23 ++++++-- src/flaskpp/module.py | 8 +-- src/flaskpp/modules/__init__.py | 15 ++++- src/flaskpp/modules/_create.py | 4 +- src/flaskpp/modules/_install.py | 4 +- 11 files changed, 162 insertions(+), 35 deletions(-) create mode 100644 src/flaskpp/app/utils/fst.py diff --git a/DOCS.md b/DOCS.md index 9217037..e1a6b54 100644 --- a/DOCS.md +++ b/DOCS.md @@ -793,7 +793,7 @@ 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: bio = db.Column(db.String(512)) # ... ``` diff --git a/pyproject.toml b/pyproject.toml index 7044178..dc184d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flaskpp" -version = "0.3.13" +version = "0.3.19" 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" } diff --git a/src/flaskpp/app/config/__init__.py b/src/flaskpp/app/config/__init__.py index e37a707..80f5ec5 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 in installed_modules(modules): + m, _, p = module + 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/utils/auto_nav.py b/src/flaskpp/app/utils/auto_nav.py index 9331b77..d4b21bc 100644 --- a/src/flaskpp/app/utils/auto_nav.py +++ b/src/flaskpp/app/utils/auto_nav.py @@ -1,7 +1,9 @@ from typing import Callable, TYPE_CHECKING +from flaskpp.utils import safe_string + if TYPE_CHECKING: - from flask import Blueprint + from flask import Flask, Blueprint class Link: @@ -35,7 +37,7 @@ def __init__( self.saved = False def dropdown_route( - self, blueprint: "Blueprint", rule: str, + self, target: "Flask | Blueprint", rule: str, label: str, priority: int = 1, additional_classes: str = "", **route_kwargs @@ -47,12 +49,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), + label, _path(target, rule), additional_classes )) def decorator(func): - blueprint.add_url_rule(rule, view_func=func, **route_kwargs) + endpoint = safe_string(rule.strip("/")) + target.add_url_rule(rule, endpoint=endpoint, view_func=func, **route_kwargs) return func return decorator @@ -81,13 +84,13 @@ def _check(priority: int): check_priority(priority) -def _path(bp: "Blueprint", rule: str) -> str: - prefix = bp.url_prefix or "" +def _path(t: "Flask | Blueprint", rule: str) -> str: + prefix = t.url_prefix or "" return f"{prefix}{rule}" def autonav_route( - blueprint: "Blueprint", rule: str, + target: "Flask | Blueprint", rule: str, label: str, priority: int = 1, additional_classes: str = "", **route_kwargs @@ -97,12 +100,13 @@ def autonav_route( if not priority in _nav_links: _nav_links[priority] = [] _nav_links[priority].append(Link( - label, _path(blueprint, rule), + label, _path(target, rule), additional_classes )) def decorator(func): - blueprint.add_url_rule(rule, view_func=func, **route_kwargs) + endpoint = safe_string(rule.strip("/")) + target.add_url_rule(rule, endpoint=endpoint, 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..d732b30 --- /dev/null +++ b/src/flaskpp/app/utils/fst.py @@ -0,0 +1,95 @@ +from pathlib import Path +from importlib import import_module +from flask_security.forms import LoginForm, RegisterFormV2 +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]] = {} + + +class FormMeta(type): pass + + +def init_forms(app: "FlaskPP"): + modules = Path(app.root_path) / "modules" + + if not modules.exists() or not modules.is_dir(): + return + + for module in installed_modules(modules, False): + m, _, p = module + if not enabled(m): + continue + + try: + import_module(f"modules.{p}.config") + 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] = [] + + if not isinstance(type(cls), FormMeta): + cls = FormMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) + + _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] = [] + + if not isinstance(type(cls), FormMeta): + cls = FormMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) + + _register_forms[priority].append(cls) + return cls + + return decorator + + +def build_login_form() -> FormMeta: + cls = LoginForm + default_conf = FormMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) + + bases = tuple() + for configs in build_sorted_tuple(_login_forms): + bases += tuple(configs) + + return FormMeta( + "ExtendedLoginForm", + bases + (default_conf, ), + {} + ) + + +def build_register_form() -> FormMeta: + cls = RegisterFormV2 + default_conf = FormMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) + + bases = tuple() + for configs in build_sorted_tuple(_register_forms): + bases += tuple(configs) + + return FormMeta( + "ExtendedRegisterForm", + bases + (default_conf, ), + {} + ) 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/flaskpp.py b/src/flaskpp/flaskpp.py index da1876f..7b6782f 100644 --- a/src/flaskpp/flaskpp.py +++ b/src/flaskpp/flaskpp.py @@ -61,6 +61,8 @@ def __init__(self, import_name: str): limiter.init_app(self) if enabled("FPP_PROCESSING"): + self.context = {} + self.context_processor(lambda: self.context) set_default_handlers(self) ext_database = enabled("EXT_SQLALCHEMY") @@ -98,15 +100,24 @@ 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_base import init_mixins, build_user_model, build_role_model, user_roles + from flaskpp.app.utils.fst import init_forms, build_login_form, build_register_form init_mixins(self) + init_forms(self) + + User = build_user_model() + Role = build_role_model() + + User.roles = db.relationship( + Role, secondary=user_roles, + backref=db.backref("users", lazy="dynamic") + ) + security.init_app( self, - SQLAlchemyUserDatastore( - db, - build_user_model(), - build_role_model() - ) + SQLAlchemyUserDatastore(db, User, Role), + login_form=build_login_form(), + register_form=build_register_form() ) if enabled("EXT_AUTHLIB"): diff --git a/src/flaskpp/module.py b/src/flaskpp/module.py index 8d99177..7e1d412 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -5,7 +5,6 @@ from typing import Callable, TYPE_CHECKING import json, typer, subprocess, sys -from flaskpp.cli import cwd from flaskpp.utils import (takes_arg, required_arg_count, require_extensions, enabled, check_required_version) from flaskpp.utils.debugger import log @@ -155,7 +154,7 @@ def _load_manifest(self, manifest: Path) -> dict: if "modules" in requirements: from flaskpp.modules import installed_modules - modules = installed_modules(Path(self.root_path).parent) + modules = installed_modules(Path(self.root_path).parent, False) requirement = requirements["modules"] if isinstance(requirement, list): @@ -179,8 +178,8 @@ def _load_manifest(self, manifest: Path) -> dict: fulfilled_modules = [] for module in modules: - m, v = module - if not m in required_modules: + m, v, _ = module + if not m in required_modules or not enabled(m): continue if check_required_version(requirement[m], "module", v): fulfilled_modules.append(m) @@ -194,6 +193,7 @@ def _load_manifest(self, manifest: Path) -> dict: return module_data def extract(self): + from flaskpp.cli import cwd extract_path = self.root_path / "extract" def for_dir(name): diff --git a/src/flaskpp/modules/__init__.py b/src/flaskpp/modules/__init__.py index c2c717c..27ca3cc 100644 --- a/src/flaskpp/modules/__init__.py +++ b/src/flaskpp/modules/__init__.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING import os, typer, json -from flaskpp.cli import cwd from flaskpp.module import basic_checked_data, valid_version from flaskpp.utils import enabled from flaskpp.utils.debugger import log, exception @@ -15,12 +14,22 @@ from flask import Flask from flaskpp import FlaskPP -module_home = cwd / "modules" -conf_path = cwd / "app_configs" +module_home = None +conf_path = None _modules = {} +def _setup_globals(): + global module_home, conf_path + + from flaskpp.cli import cwd + module_home = cwd / "modules" + conf_path = cwd / "app_configs" + + def generate_modlib(app_name: str): + _setup_globals() + conf = conf_path / f"{app_name}.conf" config = ConfigParser() config.optionxform = str diff --git a/src/flaskpp/modules/_create.py b/src/flaskpp/modules/_create.py index ce5a869..52f3fc8 100644 --- a/src/flaskpp/modules/_create.py +++ b/src/flaskpp/modules/_create.py @@ -2,11 +2,13 @@ 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, module_home, _setup_globals from flaskpp.utils import prompt_yes_no, sanitize_text, safe_string def create_module(module_name: str): + _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(): diff --git a/src/flaskpp/modules/_install.py b/src/flaskpp/modules/_install.py index 94c4c8b..a2862ce 100644 --- a/src/flaskpp/modules/_install.py +++ b/src/flaskpp/modules/_install.py @@ -3,9 +3,11 @@ from importlib import import_module import typer, shutil -from flaskpp.modules import module_home +from flaskpp.modules import module_home, _setup_globals def install_module(module_id: str, src: str): + _setup_globals() + if not src: raise NotImplementedError("Module hub is not ready yet.") From 864ed52a75ad97adcbc6b229893d95feead08957 Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 28 Jan 2026 03:35:30 +0000 Subject: [PATCH 5/9] Improved FST stability --- pyproject.toml | 3 +- src/flaskpp/app/config/default.py | 6 ++-- src/flaskpp/app/data/fst_base.py | 9 +----- src/flaskpp/app/utils/auto_nav.py | 8 ++---- src/flaskpp/app/utils/fst.py | 43 ++++++++++++---------------- src/flaskpp/app/utils/translating.py | 25 ++++++++-------- src/flaskpp/cli.py | 3 +- src/flaskpp/flaskpp.py | 19 +++++------- src/flaskpp/i18n.py | 10 ++++++- src/flaskpp/module.py | 14 ++++++--- src/flaskpp/modules/__init__.py | 10 +++---- src/flaskpp/utils/setup.py | 16 +++++++---- 12 files changed, 85 insertions(+), 81 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc184d1..1fcc288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flaskpp" -version = "0.3.19" +version = "0.4.4" 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,6 +24,7 @@ dependencies = [ "pymysql", "python-dotenv", "python-socketio[asgi]", + "argon2_cffi", "authlib", "uvicorn", "asgiref", diff --git a/src/flaskpp/app/config/default.py b/src/flaskpp/app/config/default.py index bb69f66..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") @@ -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/fst_base.py b/src/flaskpp/app/data/fst_base.py index 3eee996..c063e40 100644 --- a/src/flaskpp/app/data/fst_base.py +++ b/src/flaskpp/app/data/fst_base.py @@ -79,15 +79,8 @@ 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) diff --git a/src/flaskpp/app/utils/auto_nav.py b/src/flaskpp/app/utils/auto_nav.py index d4b21bc..6c815e3 100644 --- a/src/flaskpp/app/utils/auto_nav.py +++ b/src/flaskpp/app/utils/auto_nav.py @@ -1,7 +1,5 @@ from typing import Callable, TYPE_CHECKING -from flaskpp.utils import safe_string - if TYPE_CHECKING: from flask import Flask, Blueprint @@ -54,8 +52,7 @@ def dropdown_route( )) def decorator(func): - endpoint = safe_string(rule.strip("/")) - target.add_url_rule(rule, endpoint=endpoint, view_func=func, **route_kwargs) + target.add_url_rule(rule, view_func=func, **route_kwargs) return func return decorator @@ -105,8 +102,7 @@ def autonav_route( )) def decorator(func): - endpoint = safe_string(rule.strip("/")) - target.add_url_rule(rule, endpoint=endpoint, 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 index d732b30..3033831 100644 --- a/src/flaskpp/app/utils/fst.py +++ b/src/flaskpp/app/utils/fst.py @@ -1,6 +1,8 @@ from pathlib import Path from importlib import import_module from flask_security.forms import LoginForm, RegisterFormV2 +from flask_mailman import EmailMessage +from threading import Thread from typing import Callable, TYPE_CHECKING from flaskpp.modules import installed_modules @@ -13,9 +15,6 @@ _register_forms: dict[int, list[type]] = {} -class FormMeta(type): pass - - def init_forms(app: "FlaskPP"): modules = Path(app.root_path) / "modules" @@ -28,7 +27,7 @@ def init_forms(app: "FlaskPP"): continue try: - import_module(f"modules.{p}.config") + import_module(f"modules.{p}.forms") except ModuleNotFoundError: pass @@ -39,10 +38,6 @@ def login_form(priority: int = 1) -> Callable: def decorator(cls): if not priority in _login_forms: _login_forms[priority] = [] - - if not isinstance(type(cls), FormMeta): - cls = FormMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) - _login_forms[priority].append(cls) return cls @@ -55,41 +50,41 @@ def register_form(priority: int = 1) -> Callable: def decorator(cls): if not priority in _register_forms: _register_forms[priority] = [] - - if not isinstance(type(cls), FormMeta): - cls = FormMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) - _register_forms[priority].append(cls) return cls return decorator -def build_login_form() -> FormMeta: - cls = LoginForm - default_conf = FormMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) - +def build_login_form() -> type: bases = tuple() for configs in build_sorted_tuple(_login_forms): bases += tuple(configs) - return FormMeta( + return type( "ExtendedLoginForm", - bases + (default_conf, ), + bases + (LoginForm, ), {} ) -def build_register_form() -> FormMeta: - cls = RegisterFormV2 - default_conf = FormMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) - +def build_register_form() -> type: bases = tuple() for configs in build_sorted_tuple(_register_forms): bases += tuple(configs) - return FormMeta( + return type( "ExtendedRegisterForm", - bases + (default_conf, ), + bases + (RegisterFormV2, ), {} ) + + +def send_security_mail(msg: dict): + message = EmailMessage( + subject=msg["subject"], + body=msg["body"], + from_email=msg["sender"], + to=[msg["recipient"]], + ) + Thread(target=message.send).start() 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/cli.py b/src/flaskpp/cli.py index 604474b..fa0a2d5 100644 --- a/src/flaskpp/cli.py +++ b/src/flaskpp/cli.py @@ -1,6 +1,6 @@ from importlib.metadata import version from pathlib import Path -import typer +import typer, sys from flaskpp._help import help_message from flaskpp._init import initialize @@ -63,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 7b6782f..ea6dbea 100644 --- a/src/flaskpp/flaskpp.py +++ b/src/flaskpp/flaskpp.py @@ -100,24 +100,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, user_roles - from flaskpp.app.utils.fst import init_forms, build_login_form, build_register_form + from flaskpp.app.data.fst_base 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) - User = build_user_model() - Role = build_role_model() - - User.roles = db.relationship( - Role, secondary=user_roles, - backref=db.backref("users", lazy="dynamic") - ) - security.init_app( self, - SQLAlchemyUserDatastore(db, User, Role), + SQLAlchemyUserDatastore( + db, build_user_model(), build_role_model() + ), login_form=build_login_form(), - register_form=build_register_form() + register_form=build_register_form(), + send_mail=send_security_mail, ) if enabled("EXT_AUTHLIB"): 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 7e1d412..434af4b 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -104,13 +104,17 @@ def _enable(self, app: "FlaskPP", home: bool): self.frontend_engine = engine app.on_shutdown(engine.shutdown) + mod_tailwind = lambda: Markup(f"") self.context_processor(lambda: dict( **self.context, - tailwind=Markup(f"") + tailwind=mod_tailwind() )) + if enabled("FPP_PROCESSING"): + app.context[f"{self.name}_tailwind"] = mod_tailwind + if self._on_enable is not None: self._on_enable(app) @@ -194,7 +198,7 @@ def _load_manifest(self, manifest: Path) -> dict: def extract(self): from flaskpp.cli import cwd - extract_path = self.root_path / "extract" + extract_path = Path(self.root_path) / "extract" def for_dir(name): for file in (extract_path / name).rglob("*"): @@ -211,6 +215,8 @@ def for_dir(name): )) continue + typer.echo(f"Extracting '{'/'.join(rel.parts)}' from module '{self.module_name}'.") + dst.write_bytes( file.read_bytes() ) diff --git a/src/flaskpp/modules/__init__.py b/src/flaskpp/modules/__init__.py index 27ca3cc..dc4eb37 100644 --- a/src/flaskpp/modules/__init__.py +++ b/src/flaskpp/modules/__init__.py @@ -51,11 +51,11 @@ def generate_modlib(app_name: str): val = "0" config["modules"][mod_id] = val - if enabled(val): + if val.lower() in ("1", "y", "yes"): from flaskpp.utils.setup import setup_config try: - conf = import_module(f"modules.{module[2]}.config") - module_config = getattr(conf, "module_config", None) + cfg = import_module(f"modules.{module[2]}.config") + module_config = getattr(cfg, "module_config", None) if not module_config: raise ImportError() @@ -66,9 +66,9 @@ def generate_modlib(app_name: str): base = { mod_id: base_config } - setup_config(config, base, conf_exists) + config = setup_config(config, base, conf_exists) typer.echo("\n") - except (ModuleNotFoundError, ImportError, TypeError): + except (ModuleNotFoundError, ImportError, TypeError) as e: pass set_home = input( diff --git a/src/flaskpp/utils/setup.py b/src/flaskpp/utils/setup.py index cc65304..65acb9d 100644 --- a/src/flaskpp/utils/setup.py +++ b/src/flaskpp/utils/setup.py @@ -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", @@ -80,8 +80,10 @@ def base_config(): 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: - typer.echo(typer.style(f"\n{k.upper()}", bold=True)) + print_key = True config[k] = {} for key, value in v.items(): @@ -91,6 +93,10 @@ def setup_config(config: ConfigParser, base: dict, config_file_exists: bool = Fa 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}): " @@ -144,7 +150,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)) - setup_config(config, base_config(), conf_exists) + config = setup_config(config, base_config(), conf_exists) with open(conf, "w") as f: config.write(f) From ddd8906410aa5bd6a3a208952800d5e1a93834ba Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 28 Jan 2026 22:10:22 +0000 Subject: [PATCH 6/9] Server backup --- DOCS.md | 22 +++++++++++++++++++++- pyproject.toml | 2 +- src/flaskpp/_init.py | 11 ++++++++++- src/flaskpp/app/data/fst_base.py | 5 +++-- src/flaskpp/flaskpp.py | 4 +++- src/flaskpp/module.py | 6 +++--- src/flaskpp/modules/__init__.py | 4 ++-- src/flaskpp/modules/_create.py | 6 +++--- src/flaskpp/modules/_install.py | 4 ++-- 9 files changed, 48 insertions(+), 16 deletions(-) diff --git a/DOCS.md b/DOCS.md index e1a6b54..d889cad 100644 --- a/DOCS.md +++ b/DOCS.md @@ -281,6 +281,8 @@ A fully qualified manifest file would then look like this: "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", @@ -456,7 +458,7 @@ def init_models(mod: Module): #### Extracting -The Module class provides a `module.extract()` function. This function is meant to be used by the Flask++ CLI 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. +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 @@ -794,10 +796,28 @@ from flaskpp.app.extensions import db # -> Like config priority, it should be a value inclusively between 1 and 10 and defaults to 1. ) 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 simply 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 1fcc288..dc848f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flaskpp" -version = "0.4.4" +version = "0.4.7" 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" } diff --git a/src/flaskpp/_init.py b/src/flaskpp/_init.py index f3508c3..277a887 100644 --- a/src/flaskpp/_init.py +++ b/src/flaskpp/_init.py @@ -15,6 +15,9 @@ def initialize(skip_defaults: bool, skip_babel: bool, skip_tailwind: bool, skip_ 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) @@ -33,10 +36,16 @@ def initialize(skip_defaults: bool, skip_babel: bool, skip_tailwind: bool, skip_ 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_url_rule( + "/", endpoint="index", + view_func=lambda: render_template("index.html") + ) + # TODO: Extend the Flask++ default setup with your own factory return app diff --git a/src/flaskpp/app/data/fst_base.py b/src/flaskpp/app/data/fst_base.py index c063e40..fc6ba67 100644 --- a/src/flaskpp/app/data/fst_base.py +++ b/src/flaskpp/app/data/fst_base.py @@ -4,7 +4,7 @@ from typing import Callable, TYPE_CHECKING import inspect -from flaskpp.utils import check_priority, build_sorted_tuple +from flaskpp.utils import check_priority, build_sorted_tuple, enabled from flaskpp.app.extensions import db if TYPE_CHECKING: @@ -83,4 +83,5 @@ def build_role_model() -> type: {} ) -fsqla.FsModels.set_db_info(db) +if enabled("EXT_FST"): + fsqla.FsModels.set_db_info(db) diff --git a/src/flaskpp/flaskpp.py b/src/flaskpp/flaskpp.py index ea6dbea..4918b3a 100644 --- a/src/flaskpp/flaskpp.py +++ b/src/flaskpp/flaskpp.py @@ -136,7 +136,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 diff --git a/src/flaskpp/module.py b/src/flaskpp/module.py index 434af4b..762ced4 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -104,9 +104,9 @@ def _enable(self, app: "FlaskPP", home: bool): self.frontend_engine = engine app.on_shutdown(engine.shutdown) - mod_tailwind = lambda: Markup(f"") + mod_tailwind = lambda: Markup( + f"" + ) self.context_processor(lambda: dict( **self.context, tailwind=mod_tailwind() diff --git a/src/flaskpp/modules/__init__.py b/src/flaskpp/modules/__init__.py index dc4eb37..a90e5a5 100644 --- a/src/flaskpp/modules/__init__.py +++ b/src/flaskpp/modules/__init__.py @@ -19,7 +19,7 @@ _modules = {} -def _setup_globals(): +def setup_globals(): global module_home, conf_path from flaskpp.cli import cwd @@ -28,7 +28,7 @@ def _setup_globals(): def generate_modlib(app_name: str): - _setup_globals() + setup_globals() conf = conf_path / f"{app_name}.conf" config = ConfigParser() diff --git a/src/flaskpp/modules/_create.py b/src/flaskpp/modules/_create.py index 52f3fc8..474301e 100644 --- a/src/flaskpp/modules/_create.py +++ b/src/flaskpp/modules/_create.py @@ -2,12 +2,12 @@ from flaskpp.flaskpp import version from flaskpp.module import version_check -from flaskpp.modules import creator_templates, module_home, _setup_globals +from flaskpp.modules import creator_templates, module_home, setup_globals from flaskpp.utils import prompt_yes_no, sanitize_text, safe_string def create_module(module_name: str): - _setup_globals() + setup_globals() tmp_id = safe_string(module_name).lower() mod_id = sanitize_text(input(f"Enter your module id ({tmp_id}): ")) @@ -34,7 +34,7 @@ def create_module(module_name: str): "version": sanitize_text(input("Enter the version of your module [required]: ")), "author": sanitize_text(input("Enter your name or nickname: ")), "requires": { - "fpp": f">={str(version()).strip("v")}", + "fpp": f">={str(version()).strip('v')}", "packages": [], "modules": {} } diff --git a/src/flaskpp/modules/_install.py b/src/flaskpp/modules/_install.py index a2862ce..e952a60 100644 --- a/src/flaskpp/modules/_install.py +++ b/src/flaskpp/modules/_install.py @@ -3,10 +3,10 @@ from importlib import import_module import typer, shutil -from flaskpp.modules import module_home, _setup_globals +from flaskpp.modules import module_home, setup_globals def install_module(module_id: str, src: str): - _setup_globals() + setup_globals() if not src: raise NotImplementedError("Module hub is not ready yet.") From 33185c2636acd51771d47b675e9f8d70a30f407a Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 3 Feb 2026 07:37:57 +0100 Subject: [PATCH 7/9] Added base modules and cleaner runtime --- DOCS.md | 35 ++- pyproject.toml | 3 +- src/flaskpp/_init.py | 40 +-- src/flaskpp/app/__init__.py | 7 + src/flaskpp/app/config/__init__.py | 4 +- src/flaskpp/app/data/__init__.py | 21 +- src/flaskpp/app/data/noinit_translations.py | 25 +- src/flaskpp/app/static/css/tailwind_raw.css | 2 +- src/flaskpp/app/templates/403.html | 18 ++ src/flaskpp/app/templates/404.html | 33 +- src/flaskpp/app/templates/501.html | 18 ++ src/flaskpp/app/templates/base_email.html | 62 ++++ src/flaskpp/app/templates/base_error.html | 11 + src/flaskpp/app/templates/base_example.html | 4 +- src/flaskpp/app/templates/error.html | 22 -- src/flaskpp/app/utils/auto_nav.py | 26 +- src/flaskpp/app/utils/fst.py | 4 +- src/flaskpp/app/utils/processing.py | 7 +- src/flaskpp/flaskpp.py | 56 +++- src/flaskpp/module.py | 327 +++++++++++++++----- src/flaskpp/modules/__init__.py | 84 +++-- src/flaskpp/modules/_create.py | 99 +++++- src/flaskpp/modules/_install.py | 6 +- src/flaskpp/modules/creator_templates.py | 81 ++--- src/flaskpp/socket.py | 23 +- src/flaskpp/utils/debugger.py | 13 +- src/flaskpp/utils/run.py | 4 +- 27 files changed, 737 insertions(+), 298 deletions(-) create mode 100644 src/flaskpp/app/__init__.py create mode 100644 src/flaskpp/app/templates/403.html create mode 100644 src/flaskpp/app/templates/501.html create mode 100644 src/flaskpp/app/templates/base_email.html create mode 100644 src/flaskpp/app/templates/base_error.html delete mode 100644 src/flaskpp/app/templates/error.html diff --git a/DOCS.md b/DOCS.md index d889cad..40a948a 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,4 +1,4 @@ -# Flask++ v0.3.x Documentation +# Flask++ v0.4.x Documentation ## Core @@ -304,9 +304,9 @@ from flaskpp.exceptions import ModuleError module = Module( __file__, __name__, - - # Optional a list of required_extensions: - [ + # extends=YourBaseModule, + # -> You can use base modules as extensions for your module. + # required_extensions=[ # "sqlalchemy", # "socket", # "babel", @@ -316,10 +316,18 @@ module = Module( # "cache", # "api", # "jwt_extended" - ], - # init_routes_on_enable=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. + # is_base=True + # -> You can set the module as a base module that can be used to extend other modules. + # Base modules are not allowed to be registered. Do not enable them in your app config. ) # Now if you need to do stuff when your module gets enabled: @@ -694,8 +702,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", @@ -726,12 +734,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) diff --git a/pyproject.toml b/pyproject.toml index dc848f8..7700edb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flaskpp" -version = "0.4.7" +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" } @@ -28,6 +28,7 @@ dependencies = [ "authlib", "uvicorn", "asgiref", + "immutables", "requests", "redis", "pytz", diff --git a/src/flaskpp/_init.py b/src/flaskpp/_init.py index 277a887..a045edf 100644 --- a/src/flaskpp/_init.py +++ b/src/flaskpp/_init.py @@ -34,16 +34,15 @@ def initialize(skip_defaults: bool, skip_babel: bool, skip_tailwind: bool, skip_ (static / "js").mkdir(exist_ok=True) (static / "img").mkdir(exist_ok=True) with open(cwd / "main.py", "w") as f: - f.write(""" -from flaskpp import FlaskPP + f.write("""from flaskpp import FlaskPP from flask import render_template def create_app(): app = FlaskPP(__name__) - app.add_url_rule( + app.add_app_url_rule( "/", endpoint="index", - view_func=lambda: render_template("index.html") + view_func=lambda: render_template("app/index.html") ) # TODO: Extend the Flask++ default setup with your own factory @@ -52,27 +51,23 @@ def create_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"; @@ -82,11 +77,9 @@ def create_app(): @theme { /* ... */ -} - """) +}""") - (cwd / ".gitignore").write_text(""" -[folders] + (cwd / ".gitignore").write_text("""[folders] __pycache__/ app_configs/ services/ @@ -99,8 +92,7 @@ def create_app(): [files] messages.pot -tailwind.css -""") +tailwind.css""") if not skip_babel: typer.echo(typer.style("Generating default translations...", bold=True)) 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 80f5ec5..d264200 100644 --- a/src/flaskpp/app/config/__init__.py +++ b/src/flaskpp/app/config/__init__.py @@ -21,8 +21,8 @@ def init_configs(app: "FlaskPP"): if not modules.exists() or not modules.is_dir(): return - for module in installed_modules(modules): - m, _, p = module + for module_info in installed_modules(modules): + m, _, p = module_info if not enabled(m): continue diff --git a/src/flaskpp/app/data/__init__.py b/src/flaskpp/app/data/__init__.py index 4ae6cb7..1b15cb6 100644 --- a/src/flaskpp/app/data/__init__.py +++ b/src/flaskpp/app/data/__init__.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING import os +from flaskpp.modules import installed_modules +from flaskpp.utils import enabled from flaskpp.utils.debugger import log if TYPE_CHECKING: @@ -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 diff --git a/src/flaskpp/app/data/noinit_translations.py b/src/flaskpp/app/data/noinit_translations.py index e077f17..4fc20eb 100644 --- a/src/flaskpp/app/data/noinit_translations.py +++ b/src/flaskpp/app/data/noinit_translations.py @@ -1,7 +1,7 @@ import json -from flaskpp.app.data import commit, _package -from flaskpp.app.data.babel import add_entry, get_entries, get_entry +from flaskpp.app.data import commit, _package, delete_model +from flaskpp.app.data.babel import add_entry, get_entries from flaskpp.babel import valid_state from flaskpp.utils import enabled from flaskpp.utils.debugger import log @@ -20,6 +20,8 @@ "NO", "HINT", "UNDERSTOOD", + "FORBIDDEN_TITLE", + "FORBIDDEN_MSG", ] @@ -36,6 +38,8 @@ _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.", } @@ -52,6 +56,8 @@ _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." } @@ -77,12 +83,15 @@ def setup_db(domain: str = "flaskpp"): 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: log("info", f"Setting up Flask++ translations...") @@ -100,7 +109,9 @@ def get_locale_data(locale: str) -> tuple[str, str]: locale = locale.split("_")[0] try: - locale_data = json.loads((_package / "locales.json").read_text()) + locale_data = json.loads( + (_package / "locales.json").read_text(encoding="utf-8") + ) except json.JSONDecodeError: raise I18nError("Failed to parse locales.json") diff --git a/src/flaskpp/app/static/css/tailwind_raw.css b/src/flaskpp/app/static/css/tailwind_raw.css index 34a2477..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 md:w-fit; + @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/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..475e854 --- /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 6c815e3..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 Flask, 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, target: "Flask | Blueprint", rule: str, + self, target: "FlaskPP | Module", rule: str, label: str, priority: int = 1, additional_classes: str = "", **route_kwargs @@ -48,7 +56,8 @@ def dropdown_route( self.dropdown_links[priority] = [] self.dropdown_links[priority].append(Link( label, _path(target, rule), - additional_classes + additional_classes, + target if getattr(target, "is_base", False) else None )) def decorator(func): @@ -81,13 +90,13 @@ def _check(priority: int): check_priority(priority) -def _path(t: "Flask | Blueprint", rule: str) -> str: +def _path(t: "FlaskPP | Module", rule: str) -> str: prefix = t.url_prefix or "" return f"{prefix}{rule}" def autonav_route( - target: "Flask | Blueprint", rule: str, + target: "FlaskPP | Module", rule: str, label: str, priority: int = 1, additional_classes: str = "", **route_kwargs @@ -98,7 +107,8 @@ def autonav_route( _nav_links[priority] = [] _nav_links[priority].append(Link( label, _path(target, rule), - additional_classes + additional_classes, + target if getattr(target, "is_base", False) else None )) def decorator(func): diff --git a/src/flaskpp/app/utils/fst.py b/src/flaskpp/app/utils/fst.py index 3033831..4fd5445 100644 --- a/src/flaskpp/app/utils/fst.py +++ b/src/flaskpp/app/utils/fst.py @@ -21,8 +21,8 @@ def init_forms(app: "FlaskPP"): if not modules.exists() or not modules.is_dir(): return - for module in installed_modules(modules, False): - m, _, p = module + for module_info in installed_modules(modules, False): + m, _, p = module_info if not enabled(m): continue diff --git a/src/flaskpp/app/utils/processing.py b/src/flaskpp/app/utils/processing.py index 092dee4..d8e785c 100644 --- a/src/flaskpp/app/utils/processing.py +++ b/src/flaskpp/app/utils/processing.py @@ -1,5 +1,5 @@ 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 @@ -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/flaskpp.py b/src/flaskpp/flaskpp.py index 4918b3a..ed98653 100644 --- a/src/flaskpp/flaskpp.py +++ b/src/flaskpp/flaskpp.py @@ -1,11 +1,12 @@ from flask import Flask, Blueprint, send_from_directory +from flask.sansio.scaffold import T_route from werkzeug.middleware.proxy_fix import ProxyFix from threading import Thread, Event from asgiref.wsgi import WsgiToAsgi 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 @@ -13,6 +14,7 @@ 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.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 +33,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() @@ -72,7 +75,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,)) @@ -88,7 +91,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 @@ -152,9 +158,11 @@ 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(): @@ -164,7 +172,7 @@ def _startup(self): def _shutdown(self): with self.app_context(): log("info", "Running shutdown hooks...") - [hook() for hook in self._shutdown_hooks] + [hook() for hook in reversed(self._shutdown_hooks)] def _run_server(self): import uvicorn @@ -181,6 +189,19 @@ def _handle_shutdown(self, signum: int, frame: "FrameType"): 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 @@ -217,24 +238,16 @@ def start(self): 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"): + if enabled("FRONTEND_ENGINE") and self._allow_vite: from flaskpp.fpp_node.fpp_vite import Frontend engine = Frontend(self) self.context_processor(lambda: { @@ -243,6 +256,17 @@ 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 "/" + ) + self._startup() self._server.start() self._shutdown_flag.wait() diff --git a/src/flaskpp/module.py b/src/flaskpp/module.py index 762ced4..8c1e106 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -1,42 +1,56 @@ -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, 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.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, allowed_for_home: 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, is_base: bool = False): + 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.") + + if extends and 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.is_base = is_base + self.parent = None self.module_name = import_name.split(".")[-1] self.import_name = import_name @@ -50,23 +64,31 @@ def __init__(self, file: str, import_name: str, required_extensions: list = None self._handlers = {} self.home = False + 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 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 self.is_base: + raise ModuleError(f"[{self.module_name}] Base modules are not allowed to be registered.") + if home and not self._allow_home: - raise ModuleError(f"Module '{self.module_name}' is not allowed to be registered as home module.") + 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" @@ -75,44 +97,55 @@ 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.init_handling() + self.base.init_routes() + self.register_blueprint(self.base) + 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) - mod_tailwind = lambda: Markup( - f"" - ) - self.context_processor(lambda: dict( - **self.context, - tailwind=mod_tailwind() - )) + if self.base: + mod_tailwind = lambda: Markup( + f"\n" + f"" + ) + context_processor = lambda: dict( + **(self.base.context | self.context), + tailwind=mod_tailwind() + ) + else: + mod_tailwind = lambda: Markup( + f"" + ) + context_processor = lambda: dict( + **self.context, + tailwind=mod_tailwind() + ) - if enabled("FPP_PROCESSING"): + 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: @@ -124,36 +157,39 @@ 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.") + log("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.") + log("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}'.") + log("warn", f"[{self.module_name}] Missing description.") if not "author" in module_data: - log("warn", f"Author of '{self.module_name}' not defined.") + log("warn", f"[{self.module_name}] Author not defined.") if not "requires" in module_data: - log("warn", f"Requirements of '{self.module_name}' not defined.") + log("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.") + log("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']}." + f"[{self.module_name}] Module requires Flask++ version {requirements['fpp']}." ) if "modules" in requirements: @@ -165,7 +201,7 @@ def _load_manifest(self, manifest: Path) -> dict: new = {} for r in requirement: if not isinstance(r, str): - raise ManifestError(f"Invalid module requirement '{r}' for '{self.module_name}'.") + raise ManifestError(f"[{self.module_name}] Invalid module requirement '{r}'.") r = r.split("@") if len(r) == 2: m, v = r @@ -176,53 +212,92 @@ def _load_manifest(self, manifest: Path) -> dict: requirement = new if not isinstance(requirement, dict): - raise ManifestError(f"Invalid modules requirement type for '{self.module_name}': {type(requirement)}") + raise ManifestError(f"[{self.module_name}] Invalid modules requirement type: {type(requirement)}") required_modules = [m for m in requirement] fulfilled_modules = [] for module in modules: - m, v, _ = module - if not m in required_modules or not enabled(m): + m, v, p = module + m_enabled = enabled(m) + + if not (m_enabled or m in required_modules): + if not m_enabled: + continue + + try: + mod = import_module(f"modules.{p}") + mod = getattr(mod, "module", None) + if mod is None or not mod.base: + raise ImportError() + + m = mod.base.name + if not m in required_modules: + continue + except (ModuleNotFoundError, ImportError): + continue + + required_version = requirement.get(m) + if not required_version: continue - if check_required_version(requirement[m], "module", v): + + if check_required_version(required_version, "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}] Missing or mismatching module requirements: {missing}" ) return module_data + 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) + 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 - def for_dir(name): - for file in (extract_path / name).rglob("*"): + for file in (path / name).rglob("*"): if not file.is_file(): continue - rel = file.relative_to(extract_path) + 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 '{self.module_name}' couldn't extract '{'/'.join(rel.parts)}': " - "File already exists.", fg=typer.colors.YELLOW, bold=True + f"[{module_name}] Could not extract '{'/'.join(rel.parts)}': " + "File already exists.", fg=typer.colors.YELLOW )) continue - typer.echo(f"Extracting '{'/'.join(rel.parts)}' from module '{self.module_name}'.") + typer.echo(f"[{module_name}] Extracting '{'/'.join(rel.parts)}'.") dst.write_bytes( file.read_bytes() ) - for_dir("static") - for_dir("templates") + 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: @@ -235,13 +310,26 @@ def install_packages(self): packages = requirements["packages"] if not isinstance(packages, list): typer.echo(typer.style( - f"Invalid packages requirement type for '{self.module_name}': {type(packages)}", + 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: - typer.echo(f"Installing required package '{package}'...") + 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, @@ -249,15 +337,72 @@ def install_packages(self): ) if result.returncode != 0: typer.echo(typer.style( - f"Failed to install package '{package}' for '{self.module_name}': {result.stderr}", + f"[{module_name}] Failed to install package '{package}': {result.stderr}", fg=typer.colors.RED, bold=True )) typer.echo(typer.style( - f"Finished installing required packages for '{self.module_name}'.", - fg=typer.colors.GREEN + 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 = {} + + 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: + log("warn", 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: + log("warn", f"[{self.module_name}] Failed to initialize handling: {e}") def init_routes(self): try: @@ -267,10 +412,10 @@ 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}") + log("warn", f"[{self.module_name}] Failed to register routes: {e}") def wrap_message(self, message: str) -> str: - domain = self.context.get("DOMAIN") + domain = self.ref.context.get("DOMAIN") if not domain: return message return f"{message}@{domain}" @@ -293,19 +438,29 @@ def wrapper(*args): 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: - return url_for(f"{self.name}.{endpoint}", **kwargs) + if self.is_base and not self.parent: + raise ModuleError(f"[{self.module_name}] Base modules require a parent module to calculate urls.") + return url_for(f"{self.ref.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 @@ -313,6 +468,13 @@ def on_enable(self, fn: Callable) -> Callable: 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() @@ -358,6 +520,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 a90e5a5..b1cb5bf 100644 --- a/src/flaskpp/modules/__init__.py +++ b/src/flaskpp/modules/__init__.py @@ -12,20 +12,22 @@ if TYPE_CHECKING: from flask import Flask - from flaskpp import FlaskPP + from flaskpp import FlaskPP, Module module_home = None conf_path = None _modules = {} -def setup_globals(): +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() @@ -44,32 +46,22 @@ 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"): - from flaskpp.utils.setup import setup_config try: - cfg = import_module(f"modules.{module[2]}.config") - module_config = getattr(cfg, "module_config", None) - if not module_config: - raise ImportError() - - base_config = module_config() - if not base_config: - raise ImportError() - - base = { - mod_id: base_config - } - config = setup_config(config, base, conf_exists) - typer.echo("\n") - except (ModuleNotFoundError, ImportError, TypeError) as e: - pass + 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" + @@ -118,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.{module[2]}") + 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 @@ -142,21 +134,40 @@ def register(): 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("info", 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") 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())) @@ -182,6 +193,10 @@ 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.name) ) @@ -190,3 +205,14 @@ def installed_modules(package: Path, do_log: bool = True) -> list[tuple[str, str 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 474301e..75ceac5 100644 --- a/src/flaskpp/modules/_create.py +++ b/src/flaskpp/modules/_create.py @@ -2,12 +2,19 @@ from flaskpp.flaskpp import version from flaskpp.module import version_check -from flaskpp.modules import creator_templates, module_home, setup_globals +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): - setup_globals() + module_home, _ = setup_globals() tmp_id = safe_string(module_name).lower() mod_id = sanitize_text(input(f"Enter your module id ({tmp_id}): ")) @@ -22,16 +29,54 @@ 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')}", @@ -50,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)) @@ -78,14 +123,28 @@ def create_module(module_name: str): templates = module_dst / "templates" templates.mkdir(exist_ok=True) + register_config = "@register_config()" + extend_config = "" + if extends: + extend_config_import = f"{base.import_name if base else 'modules.' + extend_id}.config" + extend_config_name = f"{_config_name(base.name) if base else _config_name(extend_id)}Config" + config_import = f"""from flaskpp.app.config import register_config +from {extend_config_import} import {extend_config_name}\n\n""" + extend_config = f"({extend_config_name})" - config_name = "" - for s in mod_id.split("_"): - config_name += s.capitalize() + elif base_module: + config_import = "" + register_config = "" + + else: + config_import = "from flaskpp.app.config import register_config\n\n" (module_dst / "config.py").write_text( creator_templates.module_config.format( - name=config_name + config_import=config_import, + register=register_config, + extends=extend_config, + name=_config_name(mod_id) )) (module_dst / "routes.py").write_text( creator_templates.module_routes @@ -105,19 +164,33 @@ def create_module(module_name: str): 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 e952a60..47deb6e 100644 --- a/src/flaskpp/modules/_install.py +++ b/src/flaskpp/modules/_install.py @@ -3,10 +3,10 @@ from importlib import import_module import typer, shutil -from flaskpp.modules import module_home, setup_globals +from flaskpp.modules import setup_globals def install_module(module_id: str, src: str): - setup_globals() + module_home, _ = setup_globals() if not src: raise NotImplementedError("Module hub is not ready yet.") @@ -18,7 +18,7 @@ def finalize(): from flaskpp import Module if not isinstance(module, Module): - raise ImportError("Failed to load 'module: Module'.") + raise ImportError("Failed to import 'module: Module'.") module.extract() module.install_packages() diff --git a/src/flaskpp/modules/creator_templates.py b/src/flaskpp/modules/creator_templates.py index d0e507c..982f76d 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 @@ -45,11 +42,10 @@ def init_handling(mod: Module): if not handle_request: continue - mod.handler(handler_name)(handle_request) -""" + mod.handler(handler_name)(handle_request)""" -handling_example = """ -from flask import flash, redirect + +handling_example = """from flask import flash, redirect from flaskpp import Module from flaskpp.utils import enabled @@ -59,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 @@ -74,22 +69,19 @@ 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 -from security import token_hex - -@register_config() -class {name}Config: +module_config = """# from security import token_hex +{config_import} +{register} +class {name}Config{extends}: # TODO: Write your modules required config data here pass def module_config(): - # return { + # return {{ # TODO: Write required config data (will be prompted by the setup if module is set to 1) # "protected_MY_SECRET": token_hex(32), @@ -100,12 +92,11 @@ def module_config(): # "ADDITIONAL_DATA": "", # -> simple config prompt without default value - # } - pass -""" + # }} + pass""" + -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 %} @@ -116,18 +107,16 @@ def module_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 @@ -140,15 +129,13 @@ def init_models(mod: Module): if file.stem == "__init__" or file.stem.startswith("noinit"): continue rel = file.relative_to(_package).with_suffix("") - import_module(f"{mod.import_name}.data.{".".join(rel.parts)}") -""" + 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..e8b4387 100644 --- a/src/flaskpp/socket.py +++ b/src/flaskpp/socket.py @@ -2,6 +2,7 @@ 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 @@ -13,9 +14,10 @@ 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,8 +48,8 @@ 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: @@ -130,8 +132,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 +256,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/debugger.py b/src/flaskpp/utils/debugger.py index 23d2a7e..f93a2a4 100644 --- a/src/flaskpp/utils/debugger.py +++ b/src/flaskpp/utils/debugger.py @@ -1,6 +1,7 @@ from datetime import datetime import traceback, sys +_execution = False _debug = False @@ -16,7 +17,10 @@ def get_time() -> str: def log(category: str, message: str): - log_str = f"[FLASK]\t[{get_time()}] [{category.upper()}] {message}" + if not _execution: + return + + log_str = f"[FLASK]\t[{get_time()}] [{category.upper()}]\t{message}" print(log_str) @@ -35,7 +39,14 @@ def debug_msg(message: str): def start_session(debug: bool): + global _execution + from flaskpp.utils import enabled + _execution = enabled("IN_EXECUTION") + if not _execution: + return + 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/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: From 0242fd3656226cecd476250e1a52789ac6c836f2 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 3 Feb 2026 23:25:01 +0100 Subject: [PATCH 8/9] Updated docs and bug fixes --- DOCS.md | 40 +++-- src/flaskpp/app/data/fst_base.py | 13 +- src/flaskpp/app/data/noinit_translations.py | 36 +++++ src/flaskpp/app/templates/base_email.html | 10 +- src/flaskpp/module.py | 155 +++++++++++--------- src/flaskpp/modules/_install.py | 5 +- 6 files changed, 166 insertions(+), 93 deletions(-) diff --git a/DOCS.md b/DOCS.md index 40a948a..53231e4 100644 --- a/DOCS.md +++ b/DOCS.md @@ -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,6 +276,9 @@ 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 @@ -286,7 +289,9 @@ A fully qualified manifest file would then look like this: ], "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. } } } @@ -299,13 +304,15 @@ 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__, - # extends=YourBaseModule, + # 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", @@ -325,13 +332,10 @@ module = Module( # -> 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. - # is_base=True - # -> You can set the module as a base module that can be used to extend other modules. - # Base modules are not allowed to be registered. Do not enable them in your app config. ) # 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"): @@ -428,9 +432,25 @@ 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 @@ -464,6 +484,8 @@ def init_models(mod: Module): 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. @@ -644,7 +666,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: @@ -812,7 +834,7 @@ class MyUserMixin: # ... ``` -You can also create your own forms for FST. For that simply 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: +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 diff --git a/src/flaskpp/app/data/fst_base.py b/src/flaskpp/app/data/fst_base.py index fc6ba67..4704f29 100644 --- a/src/flaskpp/app/data/fst_base.py +++ b/src/flaskpp/app/data/fst_base.py @@ -4,6 +4,7 @@ from typing import Callable, TYPE_CHECKING import inspect +from flaskpp.modules import installed_modules from flaskpp.utils import check_priority, build_sorted_tuple, enabled from flaskpp.app.extensions import db @@ -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: diff --git a/src/flaskpp/app/data/noinit_translations.py b/src/flaskpp/app/data/noinit_translations.py index 4fc20eb..a3b69e0 100644 --- a/src/flaskpp/app/data/noinit_translations.py +++ b/src/flaskpp/app/data/noinit_translations.py @@ -1,3 +1,5 @@ +from importlib import import_module +from typing import Callable import json from flaskpp.app.data import commit, _package, delete_model @@ -118,3 +120,37 @@ def get_locale_data(locale: str) -> tuple[str, str]: flags = locale_data.get("flags", {}) names = locale_data.get("names", {}) return flags.get(locale, "🇬🇧"), names.get(locale, "English") + + +def update_translations(executor: str, msg_keys: list[str], add_entries_fn: Callable, translations_import_name: str, domain: str = None): + default_domain = valid_state().domain.domain + if not domain: + domain = default_domain + entries = get_entries(domain=domain) + + if entries: + log("info", f"[{executor}] Updating translations...") + + keys = [e.key for e in entries] + for key in msg_keys: + if key not in keys: + add_entries_fn(key, domain) + + translations_module = import_module(translations_import_name) + for entry in entries: + key = entry.key + translations = getattr(translations_module, f"_translations_{entry.locale}", _translations_en) + try: + if translations[key] != entry.text: + entry.text = translations[key] + except KeyError: + if domain == default_domain: + continue + delete_model(entry, False) + else: + log("info", f"[{executor}] Setting up translations...") + + for key in msg_keys: + add_entries_fn(key, domain) + + commit() diff --git a/src/flaskpp/app/templates/base_email.html b/src/flaskpp/app/templates/base_email.html index 475e854..151361e 100644 --- a/src/flaskpp/app/templates/base_email.html +++ b/src/flaskpp/app/templates/base_email.html @@ -24,31 +24,31 @@ style="background-color:#ffffff;font-family:'Cabin',sans-serif"> - + {% block brand %}{% endblock %} - + {% block content %}{% endblock %} - + {% block legal %}{% endblock %} - + {% block socials %}{% endblock %} - + {% block copy %}{% endblock %} diff --git a/src/flaskpp/module.py b/src/flaskpp/module.py index 8c1e106..cd3a991 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -38,25 +38,17 @@ class Module(Blueprint): 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, is_base: bool = False): + allow_frontend_engine: bool = True): if not "modules." in import_name: raise ModuleError("Modules have to be created inside the 'modules' package.") - if extends and 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.is_base = is_base - self.parent = None - 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"], @@ -64,6 +56,14 @@ def __init__(self, file: str, import_name: str, 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 []) @@ -77,7 +77,7 @@ def __init__(self, file: str, import_name: str, self.info["id"], import_name, static_folder=(Path(self.root_path) / "static"), - url_prefix="/base" if is_base else None + url_prefix="/base" if self.is_base else None ) def __repr__(self): @@ -87,6 +87,8 @@ def _enable(self, app: "FlaskPP", home: bool): 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: @@ -103,10 +105,16 @@ def _enable(self, app: "FlaskPP", home: bool): 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) - self.errorhandler(NotFound)(self._not_found) + + 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 @@ -192,70 +200,69 @@ def _load_manifest(self, manifest: Path) -> dict: f"[{self.module_name}] Module requires Flask++ version {requirements['fpp']}." ) - if "modules" in requirements: - from flaskpp.modules import installed_modules - 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)}") - - required_modules = [m for m in requirement] - fulfilled_modules = [] - - for module in modules: - m, v, p = module - m_enabled = enabled(m) - - if not (m_enabled or m in required_modules): - if not m_enabled: - continue - - try: - mod = import_module(f"modules.{p}") - mod = getattr(mod, "module", None) - if mod is None or not mod.base: - raise ImportError() - - m = mod.base.name - if not m in required_modules: - continue - except (ModuleNotFoundError, ImportError): - continue - - required_version = requirement.get(m) - if not required_version: - continue - - if check_required_version(required_version, "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"[{self.module_name}] Missing or mismatching module requirements: {missing}" - ) - 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}") @@ -365,6 +372,8 @@ def setup_config(self, config: "ConfigParser", config_file_exists: bool = False) extend = base_module_config() or {} except (ModuleNotFoundError, ImportError, TypeError): extend = {} + else: + extend = {} try: main = module_config() or {} @@ -415,7 +424,7 @@ def init_routes(self): log("warn", f"[{self.module_name}] Failed to register routes: {e}") def wrap_message(self, message: str) -> str: - domain = self.ref.context.get("DOMAIN") + domain = self.context.get("DOMAIN") if not domain: return message return f"{message}@{domain}" @@ -450,7 +459,9 @@ def render_template(self, template: str, **context) -> str: 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.") - return url_for(f"{self.ref.name}.{endpoint}", **kwargs) + 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 self.is_base: diff --git a/src/flaskpp/modules/_install.py b/src/flaskpp/modules/_install.py index 47deb6e..fcb1d0f 100644 --- a/src/flaskpp/modules/_install.py +++ b/src/flaskpp/modules/_install.py @@ -20,8 +20,9 @@ def finalize(): if not isinstance(module, Module): raise ImportError("Failed to import 'module: Module'.") - module.extract() - module.install_packages() + if not module.is_base: + module.extract() + module.install_packages() typer.echo(typer.style( f"Module '{module}' has been successfully installed.", From 3a36fe64cd4fafff89bcafe0e25c7f3973ab4d17 Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 8 Feb 2026 03:26:42 +0100 Subject: [PATCH 9/9] Last patch for Flask++ (discontinued) --- DOCS.md | 7 +- src/flaskpp/app/data/__init__.py | 4 +- src/flaskpp/app/data/{fst_base.py => fst.py} | 0 src/flaskpp/app/data/noinit_translations.py | 100 ++----------------- src/flaskpp/app/static/js/base.js | 9 +- src/flaskpp/app/utils/fst.py | 8 +- src/flaskpp/app/utils/i18n.py | 82 +++++++++++++++ src/flaskpp/app/utils/mailing.py | 40 ++++++++ src/flaskpp/app/utils/processing.py | 6 +- src/flaskpp/flaskpp.py | 38 +++++-- src/flaskpp/fpp_node/fpp_vite.py | 2 +- src/flaskpp/module.py | 41 +++++--- src/flaskpp/modules/__init__.py | 24 +++-- src/flaskpp/modules/_create.py | 17 +--- src/flaskpp/modules/creator_templates.py | 6 +- src/flaskpp/socket.py | 7 +- src/flaskpp/utils/__init__.py | 4 +- src/flaskpp/utils/debugger.py | 52 ---------- src/flaskpp/utils/logging.py | 70 +++++++++++++ src/flaskpp/utils/setup.py | 1 + 20 files changed, 309 insertions(+), 209 deletions(-) rename src/flaskpp/app/data/{fst_base.py => fst.py} (100%) create mode 100644 src/flaskpp/app/utils/i18n.py create mode 100644 src/flaskpp/app/utils/mailing.py delete mode 100644 src/flaskpp/utils/debugger.py create mode 100644 src/flaskpp/utils/logging.py diff --git a/DOCS.md b/DOCS.md index 53231e4..f64e6fe 100644 --- a/DOCS.md +++ b/DOCS.md @@ -580,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 diff --git a/src/flaskpp/app/data/__init__.py b/src/flaskpp/app/data/__init__.py index 1b15cb6..6ba1f24 100644 --- a/src/flaskpp/app/data/__init__.py +++ b/src/flaskpp/app/data/__init__.py @@ -6,7 +6,7 @@ from flaskpp.modules import installed_modules from flaskpp.utils import enabled -from flaskpp.utils.debugger import log +from flaskpp.utils.logging import debug if TYPE_CHECKING: from flask import Flask @@ -89,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 100% rename from src/flaskpp/app/data/fst_base.py rename to src/flaskpp/app/data/fst.py diff --git a/src/flaskpp/app/data/noinit_translations.py b/src/flaskpp/app/data/noinit_translations.py index a3b69e0..cb0d501 100644 --- a/src/flaskpp/app/data/noinit_translations.py +++ b/src/flaskpp/app/data/noinit_translations.py @@ -1,14 +1,11 @@ -from importlib import import_module -from typing import Callable -import json - -from flaskpp.app.data import commit, _package, delete_model -from flaskpp.app.data.babel import add_entry, get_entries 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", @@ -27,7 +24,7 @@ ] -_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.", @@ -45,7 +42,7 @@ } -_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.", @@ -64,93 +61,10 @@ } -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) - - from .. import data - for entry in entries: - key = entry.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: - 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(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, msg_keys: list[str], add_entries_fn: Callable, translations_import_name: str, domain: str = None): - default_domain = valid_state().domain.domain - if not domain: - domain = default_domain - entries = get_entries(domain=domain) - - if entries: - log("info", f"[{executor}] Updating translations...") - - keys = [e.key for e in entries] - for key in msg_keys: - if key not in keys: - add_entries_fn(key, domain) - - translations_module = import_module(translations_import_name) - for entry in entries: - key = entry.key - translations = getattr(translations_module, f"_translations_{entry.locale}", _translations_en) - try: - if translations[key] != entry.text: - entry.text = translations[key] - except KeyError: - if domain == default_domain: - continue - delete_model(entry, False) - else: - log("info", f"[{executor}] Setting up translations...") - - for key in msg_keys: - add_entries_fn(key, domain) - - commit() + update_translations("Flask++", __name__, domain) 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/utils/fst.py b/src/flaskpp/app/utils/fst.py index 4fd5445..4ec50a7 100644 --- a/src/flaskpp/app/utils/fst.py +++ b/src/flaskpp/app/utils/fst.py @@ -1,7 +1,7 @@ from pathlib import Path from importlib import import_module from flask_security.forms import LoginForm, RegisterFormV2 -from flask_mailman import EmailMessage +from flask_mailman import EmailMessage, EmailMultiAlternatives from threading import Thread from typing import Callable, TYPE_CHECKING @@ -27,7 +27,7 @@ def init_forms(app: "FlaskPP"): continue try: - import_module(f"modules.{p}.forms") + import_module(f"modules.{p}.fst_forms") except ModuleNotFoundError: pass @@ -81,10 +81,12 @@ def build_register_form() -> type: def send_security_mail(msg: dict): - message = EmailMessage( + 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 d8e785c..0cd315a 100644 --- a/src/flaskpp/app/utils/processing.py +++ b/src/flaskpp/app/utils/processing.py @@ -3,11 +3,11 @@ 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: diff --git a/src/flaskpp/flaskpp.py b/src/flaskpp/flaskpp.py index ed98653..7a41de3 100644 --- a/src/flaskpp/flaskpp.py +++ b/src/flaskpp/flaskpp.py @@ -1,5 +1,4 @@ from flask import Flask, Blueprint, send_from_directory -from flask.sansio.scaffold import T_route from werkzeug.middleware.proxy_fix import ProxyFix from threading import Thread, Event from asgiref.wsgi import WsgiToAsgi @@ -13,7 +12,7 @@ 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 @@ -65,7 +64,10 @@ def __init__(self, import_name: str, allow_frontend_engine: bool = True, **kwarg if enabled("FPP_PROCESSING"): self.context = {} - self.context_processor(lambda: self.context) + self.context_processor(lambda: dict( + **self.context, + main_context=self.context + )) set_default_handlers(self) ext_database = enabled("EXT_SQLALCHEMY") @@ -106,7 +108,7 @@ def __init__(self, import_name: str, allow_frontend_engine: bool = True, **kwarg 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) @@ -166,25 +168,36 @@ def __init__(self, import_name: str, allow_frontend_engine: bool = True, **kwarg 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...") + 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() @@ -233,7 +246,10 @@ 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) @@ -247,6 +263,8 @@ def start(self): view_func=lambda filename: send_from_directory(Path(self.root_path) / "static", filename) ) + log(f"[{__name__}] Modules registered.") + if enabled("FRONTEND_ENGINE") and self._allow_vite: from flaskpp.fpp_node.fpp_vite import Frontend engine = Frontend(self) @@ -267,6 +285,8 @@ def start(self): 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/module.py b/src/flaskpp/module.py index cd3a991..e1eb772 100644 --- a/src/flaskpp/module.py +++ b/src/flaskpp/module.py @@ -8,7 +8,7 @@ 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: @@ -137,7 +137,8 @@ def _enable(self, app: "FlaskPP", home: bool): ) context_processor = lambda: dict( **(self.base.context | self.context), - tailwind=mod_tailwind() + tailwind=mod_tailwind(), + context=(self.base.context | self.context) ) else: mod_tailwind = lambda: Markup( @@ -145,7 +146,8 @@ def _enable(self, app: "FlaskPP", home: bool): ) context_processor = lambda: dict( **self.context, - tailwind=mod_tailwind() + tailwind=mod_tailwind(), + context=self.context ) if self.home: @@ -168,22 +170,22 @@ def _load_manifest(self, manifest: Path) -> dict: raise ModuleError(f"[{self.module_name}] Failed to load manifest: {e}") if not "id" in module_data: - log("warn", f"[{self.module_name}] Missing id; 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"[{self.module_name}] 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"[{self.module_name}] Missing description.") + warn(f"[{self.module_name}] Missing description.") if not "author" in module_data: - log("warn", f"[{self.module_name}] Author not defined.") + warn(f"[{self.module_name}] Author not defined.") if not "requires" in module_data: - log("warn", f"[{self.module_name}] Requirements not defined.") + warn(f"[{self.module_name}] Requirements not defined.") else: if not enabled("IN_EXECUTION"): @@ -192,7 +194,7 @@ def _load_manifest(self, manifest: Path) -> dict: requirements = module_data["requires"] if not "fpp" in requirements: - log("warn", f"[{self.module_name}] Required Flask++ version of not defined.") + warn(f"[{self.module_name}] Required Flask++ version of not defined.") else: fulfilled = check_required_version(requirements["fpp"]) if not fulfilled: @@ -401,7 +403,7 @@ def init_models(self): raise ImportError("Missing init function in data.") init_models(self) except (ModuleNotFoundError, ImportError, TypeError) as e: - log("warn", f"[{self.module_name}] Failed to initialize database models: {e}") + debug(f"[{self.module_name}] Failed to initialize database models: {e}") def init_handling(self): try: @@ -411,7 +413,7 @@ def init_handling(self): raise ImportError("Missing init function in handling.") init_handling(self) except (ModuleNotFoundError, ImportError, TypeError) as e: - log("warn", f"[{self.module_name}] Failed to initialize handling: {e}") + debug(f"[{self.module_name}] Failed to initialize handling: {e}") def init_routes(self): try: @@ -421,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"[{self.module_name}] Failed to register routes: {e}") + debug(f"[{self.module_name}] Failed to register routes: {e}") def wrap_message(self, message: str) -> str: domain = self.context.get("DOMAIN") @@ -439,8 +441,8 @@ 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 @@ -475,6 +477,17 @@ def on_enable(self, fn: Callable) -> Callable: 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", "")) diff --git a/src/flaskpp/modules/__init__.py b/src/flaskpp/modules/__init__.py index b1cb5bf..cb3ba15 100644 --- a/src/flaskpp/modules/__init__.py +++ b/src/flaskpp/modules/__init__.py @@ -7,7 +7,7 @@ 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: @@ -124,13 +124,13 @@ 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: @@ -154,7 +154,7 @@ def register(): if is_home: primary_loader = loader_context[module.name] - log("info", f"[{module.module_name}] Registered module 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"[{module.module_name}] Failed registering module.") @@ -163,17 +163,23 @@ def 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(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, str]]: @@ -201,7 +207,7 @@ def installed_modules(package: Path, do_log: bool = True) -> list[tuple[str, str (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] diff --git a/src/flaskpp/modules/_create.py b/src/flaskpp/modules/_create.py index 75ceac5..91e1eed 100644 --- a/src/flaskpp/modules/_create.py +++ b/src/flaskpp/modules/_create.py @@ -124,27 +124,20 @@ def create_module(module_name: str): templates.mkdir(exist_ok=True) register_config = "@register_config()" - extend_config = "" - if extends: - extend_config_import = f"{base.import_name if base else 'modules.' + extend_id}.config" - extend_config_name = f"{_config_name(base.name) if base else _config_name(extend_id)}Config" - config_import = f"""from flaskpp.app.config import register_config -from {extend_config_import} import {extend_config_name}\n\n""" - extend_config = f"({extend_config_name})" - - elif base_module: + if base_module: config_import = "" register_config = "" else: - config_import = "from flaskpp.app.config import register_config\n\n" + 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( config_import=config_import, register=register_config, - extends=extend_config, - name=_config_name(mod_id) + 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 diff --git a/src/flaskpp/modules/creator_templates.py b/src/flaskpp/modules/creator_templates.py index 982f76d..a91cbf2 100644 --- a/src/flaskpp/modules/creator_templates.py +++ b/src/flaskpp/modules/creator_templates.py @@ -74,8 +74,10 @@ def index(): module_config = """# from security import token_hex {config_import} +from . import module + {register} -class {name}Config{extends}: +class {name}Config(module.base_config): # TODO: Write your modules required config data here pass @@ -93,7 +95,7 @@ def module_config(): # "ADDITIONAL_DATA": "", # -> simple config prompt without default value # }} - pass""" + pass{config_export}""" module_index = """{% extends "base_example.html" %} diff --git a/src/flaskpp/socket.py b/src/flaskpp/socket.py index e8b4387..77a8091 100644 --- a/src/flaskpp/socket.py +++ b/src/flaskpp/socket.py @@ -7,7 +7,7 @@ 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 @@ -55,6 +55,9 @@ 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) @@ -75,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 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 f93a2a4..0000000 --- a/src/flaskpp/utils/debugger.py +++ /dev/null @@ -1,52 +0,0 @@ -from datetime import datetime -import traceback, sys - -_execution = False -_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): - if not _execution: - return - - log_str = f"[FLASK]\t[{get_time()}] [{category.upper()}]\t{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 _execution - from flaskpp.utils import enabled - _execution = enabled("IN_EXECUTION") - if not _execution: - return - - 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/setup.py b/src/flaskpp/utils/setup.py index 65acb9d..0a9ef4a 100644 --- a/src/flaskpp/utils/setup.py +++ b/src/flaskpp/utils/setup.py @@ -74,6 +74,7 @@ def base_config(): "dev": { "DB_AUTOUPDATE": 0, + "I18N_AUTOUPDATE": 0, } }