-
-
{{ _("Contact") }}
-
-
-
+
{% endblock %}
diff --git a/examples/example_module/utils.py b/examples/example_module/utils.py
deleted file mode 100644
index f43db79..0000000
--- a/examples/example_module/utils.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from flask import render_template as _render_template
-
-from . import NAME
-
-
-def render_template(template: str, **context) -> str:
- return _render_template(f"{NAME}/{template}", **context)
diff --git a/examples/fpp_project/setup.bat b/examples/fpp_project/setup.bat
index a0220dc..89892d7 100644
--- a/examples/fpp_project/setup.bat
+++ b/examples/fpp_project/setup.bat
@@ -38,7 +38,8 @@ rem - Flask++ Setup Toolchain -
rem --------------------------------------
fpp init
-fpp modules install example --src ..\example_module
+fpp modules create example
+rem fpp modules install example --src ..\example_module
rem fpp modules install mymodule -s https://github.com/OrgaOrUser/fpp-module
fpp setup
fpp run --interactive
diff --git a/examples/fpp_project/setup.sh b/examples/fpp_project/setup.sh
index c3540ab..0f1c062 100644
--- a/examples/fpp_project/setup.sh
+++ b/examples/fpp_project/setup.sh
@@ -123,7 +123,8 @@ fi
#####################################
fpp init
-fpp modules install example --src ../example_module
+fpp modules create example
+#fpp modules install example --src ../example_module
#fpp modules install mymodule -s https://github.com/OrgaOrUser/fpp-module
fpp setup
fpp run --interactive
diff --git a/pyproject.toml b/pyproject.toml
index 7489e10..22806ef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "flaskpp"
-version = "0.1.0"
-description = ""
+version = "0.2.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" }
]
@@ -11,7 +11,6 @@ dependencies = [
"flask",
"flask-sqlalchemy",
"flask-migrate",
- "flask-socketio",
"flask-limiter",
"flask-babelplus",
"flask-mailman",
@@ -23,6 +22,7 @@ dependencies = [
"pymysql",
"python-dotenv",
+ "python-socketio[asgi]",
"authlib",
"uvicorn",
"asgiref",
@@ -30,6 +30,7 @@ dependencies = [
"redis",
"pytz",
"gitpython",
+ "tqdm",
"typer"
]
@@ -43,3 +44,24 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
+
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.package-data]
+flaskpp = [
+ "babel.cfg",
+ "app/templates/**/*.html",
+ "app/static/**/*",
+]
+
+[project.optional-dependencies]
+test = [
+ "pytest",
+ "pytest-cov",
+ "pytest-mock"
+]
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+addopts = "--disable-warnings"
\ No newline at end of file
diff --git a/src/flaskpp/__init__.py b/src/flaskpp/__init__.py
index 72e865b..d77c864 100644
--- a/src/flaskpp/__init__.py
+++ b/src/flaskpp/__init__.py
@@ -1,18 +1,22 @@
-from flask import Flask, Blueprint
+from flask import Flask, Blueprint, render_template as _render_template, url_for
from werkzeug.middleware.proxy_fix import ProxyFix
+from markupsafe import Markup
from threading import Thread
from datetime import datetime
from asgiref.wsgi import WsgiToAsgi
+from socketio import ASGIApp
from pathlib import Path
-import os
-
-from .app.config import CONFIG_MAP
-from .app.config.default import DefaultConfig
-from .app.utils.processing import handlers
-from .app.i18n import init_i18n
-from .modules import register_modules
-from .utils import enabled
-from .utils.debugger import start_session, log
+from importlib import import_module
+import os, json, re
+
+from flaskpp.app.config import CONFIG_MAP
+from flaskpp.app.config.default import DefaultConfig
+from flaskpp.app.utils.processing import handlers
+from flaskpp.app.i18n import init_i18n
+from flaskpp.modules import register_modules, ManifestError, ModuleError
+from flaskpp.tailwind import generate_tailwind_css
+from flaskpp.utils import enabled
+from flaskpp.utils.debugger import start_session, log, exception
_fpp_default = Blueprint("fpp_default", __name__,
static_folder=(Path(__file__).parent / "app" / "static").resolve(),
@@ -75,8 +79,9 @@ def __init__(self, import_name: str, config_name: str):
x_port=count,
x_prefix=count)
- from .app.extensions import limiter
- limiter.init_app(self)
+ if self.config["RATELIMIT"]:
+ from flaskpp.app.extensions import limiter
+ limiter.init_app(self)
fpp_processing = enabled("FPP_PROCESSING")
if fpp_processing:
@@ -85,8 +90,8 @@ def __init__(self, import_name: str, config_name: str):
ext_database = enabled("EXT_SQLALCHEMY")
db_updater = None
if ext_database:
- from .app.extensions import db, migrate
- from .app.data import init_models
+ from flaskpp.app.extensions import db, migrate
+ from flaskpp.app.data import init_models
db.init_app(self)
migrate.init_app(self, db)
init_models()
@@ -94,18 +99,14 @@ def __init__(self, import_name: str, config_name: str):
if enabled("DB_AUTOUPDATE"):
db_updater = Thread(target=db_autoupdate, args=(self,))
- if enabled("EXT_SOCKET"):
- from .app.extensions import socket
- socket.init_app(self)
-
- if fpp_processing:
- socket.on("default_event")(handlers["socket_event_handler"])
- socket.on_error_default(handlers["handle_socket_error"])
+ if enabled("EXT_SOCKET") and fpp_processing:
+ from flaskpp.app.extensions import socket
+ socket.on("default_event")(handlers["socket_event_handler"])
if enabled("EXT_BABEL"):
- from .app.extensions import babel
- from .app.i18n import DBDomain
- from .app.utils.translating import set_locale
+ from flaskpp.app.extensions import babel
+ from flaskpp.app.i18n import DBDomain
+ from flaskpp.app.utils.translating import set_locale
domain = DBDomain()
babel.init_app(self, default_domain=domain)
self.extensions["babel_domain"] = domain
@@ -116,40 +117,186 @@ def __init__(self, import_name: str, config_name: str):
raise RuntimeError("For EXT_FST EXT_SQLALCHEMY extension must be enabled.")
from flask_security import SQLAlchemyUserDatastore
- from .app.extensions import security, db
- from .app.data.fst_base import UserBase, RoleBase
+ from flaskpp.app.extensions import security, db
+ from flaskpp.app.data.fst_base import User, Role
security.init_app(
self,
- SQLAlchemyUserDatastore(db, UserBase, RoleBase)
+ SQLAlchemyUserDatastore(db, User, Role)
)
if enabled("EXT_AUTHLIB"):
- from .app.extensions import oauth
+ from flaskpp.app.extensions import oauth
oauth.init_app(self)
if enabled("EXT_MAILING"):
- from .app.extensions import mailer
+ from flaskpp.app.extensions import mailer
mailer.init_app(self)
if enabled("EXT_CACHE"):
- from .app.extensions import cache
+ from flaskpp.app.extensions import cache
cache.init_app(self)
if enabled("EXT_API"):
- from .app.extensions import api
+ from flaskpp.app.extensions import api
api.init_app(self)
if enabled("EXT_JWT_EXTENDED"):
- from .app.extensions import jwt
+ from flaskpp.app.extensions import jwt
jwt.init_app(self)
+ generate_tailwind_css(self)
+
self.register_blueprint(_fpp_default)
+ self.url_prefix = ""
register_modules(self)
+ self.static_url_path = f"{self.url_prefix}/static"
+
+ if enabled("FRONTEND_ENGINE"):
+ from flaskpp.fpp_node.vite import Frontend
+ engine = Frontend(self)
+ self.context_processor(lambda: {
+ "vite_main": engine.vite
+ })
+ self.frontend_engine = engine
init_i18n(self)
if db_updater:
db_updater.start()
- def to_asgi(self) -> WsgiToAsgi:
- return WsgiToAsgi(self)
+ self._asgi_app = None
+
+ def to_asgi(self) -> WsgiToAsgi | ASGIApp:
+ if self._asgi_app is not None:
+ return self._asgi_app
+
+ app = WsgiToAsgi(self)
+ if enabled("EXT_SOCKET"):
+ from flaskpp.app.extensions import socket
+ self._asgi_app = ASGIApp(socket, other_asgi_app=app)
+ return self._asgi_app
+ self._asgi_app = app
+ return app
+
+
+class Module(Blueprint):
+ def __init__(self, file: str, import_name: str, required_extensions: list = None):
+ if not "modules." in import_name:
+ raise ModuleError("Modules have to be created in the modules package.")
+
+ self.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.safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", self.name)
+ self.extensions = required_extensions or []
+ self.context = {
+ "NAME": self.safe_name,
+ }
+ self.home = False
+
+ from flaskpp.app.extensions import require_extensions
+ self.enable = require_extensions(*self.extensions)(self._enable)
+
+ super().__init__(
+ self.safe_name,
+ import_name,
+ static_folder=(Path(self.root_path) / "static")
+ )
+
+ def __repr__(self):
+ return f"<{self.info['name']} {self.version}> {self.info.get('description', '')}"
+
+ def _enable(self, app: FlaskPP, home: bool):
+ if home:
+ self.static_url_path = "/static"
+ app.url_prefix = "/app"
+ self.home = True
+ else:
+ self.url_prefix = f"/{self.safe_name}"
+ self.static_url_path = f"/{self.safe_name}/static"
+
+ try:
+ routes = import_module(f"{self.import_name}.routes")
+ init = getattr(routes, "init_routes", None)
+ if not init:
+ raise ImportError("Missing init function in routes.")
+ init(self)
+ except (ModuleNotFoundError, ImportError, TypeError) as e:
+ log("warn", f"Failed to register routes for {self.name}: {e}")
+
+ if "sqlalchemy" in self.extensions:
+ try:
+ data = import_module(f"{self.import_name}.data")
+ init = getattr(data, "init_models", None)
+ if not init:
+ raise ImportError("Missing init function in data.")
+ init()
+ except (ModuleNotFoundError, ImportError, TypeError) as e:
+ log("warn", f"Failed to initialize models for {self.name}: {e}")
+
+ if enabled("FRONTEND_ENGINE"):
+ from flaskpp.fpp_node.vite import Frontend
+ engine = Frontend(self)
+ self.context["vite"] = engine.vite
+ self.frontend_engine = engine
+
+ self.context_processor(lambda: dict(
+ **self.context,
+ tailwind=Markup(f"
")
+ ))
+ app.register_blueprint(self)
+
+ def _load_manifest(self, manifest: Path) -> dict:
+ if not manifest.exists():
+ raise FileNotFoundError(f"Manifest file for {self.name} not found.")
+
+ try:
+ module_data = json.loads(manifest.read_text())
+ except json.decoder.JSONDecodeError:
+ raise ManifestError(f"Invalid format for manifest of {self.name}.")
+
+ if not "name" in module_data:
+ module_data["name"] = self.name
+ else:
+ self.name = module_data["name"]
+
+ if not "description" in module_data:
+ log("warn", f"Missing description of {module_data['name']}.")
+
+ if not "version" in module_data:
+ raise ManifestError("Module version not defined.")
+
+ if not "author" in module_data:
+ log("warn", f"Author of {module_data['name']} not defined.")
+
+ return module_data
+
+ @property
+ def version(self) -> str:
+ version_str = self.info.get("version", "").lower().strip()
+ if not version_str:
+ raise ManifestError("Module version not defined.")
+
+ if " " in version_str and not (version_str.endswith("alpha") or version_str.endswith("beta")):
+ raise ManifestError("Invalid version string format.")
+
+ if version_str.startswith("v"):
+ version_str = version_str[1:]
+
+ try:
+ v_numbers = version_str.split(" ")[0].split(".")
+ if len(v_numbers) > 3:
+ raise ManifestError("Too many version numbers.")
+
+ for v_number in v_numbers:
+ int(v_number)
+ except ValueError:
+ raise ManifestError("Invalid version numbers.")
+
+ return version_str
+
+ def render_template(self, template: str, **context) -> str:
+ render_name = template if self.home else f"{self.safe_name}/{template}"
+ return _render_template(render_name, **context)
diff --git a/src/flaskpp/__main__.py b/src/flaskpp/__main__.py
index 9ae637f..805efa8 100644
--- a/src/flaskpp/__main__.py
+++ b/src/flaskpp/__main__.py
@@ -1,4 +1,4 @@
-from .cli import main
+from flaskpp.cli import main
if __name__ == "__main__":
main()
diff --git a/src/flaskpp/app/config/default.py b/src/flaskpp/app/config/default.py
index 829a4a6..492e79c 100644
--- a/src/flaskpp/app/config/default.py
+++ b/src/flaskpp/app/config/default.py
@@ -1,6 +1,6 @@
import os
-from ..config import register_config
+from flaskpp.app.config import register_config
@register_config('default')
@@ -26,6 +26,7 @@ class DefaultConfig:
# -------------------------------------------------
# Flask-Limiter (Rate Limiting)
# -------------------------------------------------
+ RATELIMIT = True
RATELIMIT_STORAGE_URL = f"{os.getenv('REDIS_URL', 'redis://localhost:6379')}/1"
RATELIMIT_DEFAULT = "500 per day; 100 per hour"
RATELIMIT_STRATEGY = "fixed-window"
diff --git a/src/flaskpp/app/data/babel.py b/src/flaskpp/app/data/babel.py
index f378c1e..fb3363d 100644
--- a/src/flaskpp/app/data/babel.py
+++ b/src/flaskpp/app/data/babel.py
@@ -1,5 +1,5 @@
-from . import add_model, delete_model, commit
-from ..extensions import db
+from flaskpp.app.data import add_model, delete_model, commit
+from flaskpp.app.extensions import db
class I18nMessage(db.Model):
diff --git a/src/flaskpp/app/data/fst_base.py b/src/flaskpp/app/data/fst_base.py
index 5c0e0f0..22863c7 100644
--- a/src/flaskpp/app/data/fst_base.py
+++ b/src/flaskpp/app/data/fst_base.py
@@ -1,6 +1,50 @@
from flask_security.models import fsqla_v3 as fsqla
+import inspect
+
+from flaskpp.app.extensions import db
+
+_user_mixins: list[type] = []
+_role_mixins: list[type] = []
+
+
+def _valid_mixin(cls, kind: str):
+ if not inspect.isclass(cls):
+ raise TypeError(f"{kind} mixin must be a class.")
+ if hasattr(cls, "__tablename__"):
+ raise TypeError(f"{kind} mixins must not define tables.")
+
+
+def user_mixin(cls):
+ _valid_mixin(cls, "User")
+ _user_mixins.append(cls)
+ return cls
+
+
+def role_mixin(cls):
+ _valid_mixin(cls, "Role")
+ _role_mixins.append(cls)
+ return cls
+
+
+def _build_user_model():
+ bases = tuple(_user_mixins) + (db.Model, fsqla.FsUserMixin)
+
+ return type(
+ "User",
+ bases,
+ {}
+ )
+
+
+def _build_role_model():
+ bases = tuple(_role_mixins) + (db.Model, fsqla.FsRoleMixin)
+
+ return type(
+ "Role",
+ bases,
+ {}
+ )
-from ..extensions import db
user_roles = db.Table(
"user_roles",
@@ -11,9 +55,5 @@
fsqla.FsModels.set_db_info(db)
-class UserBase(db.Model, fsqla.FsUserMixin):
- pass
-
-
-class RoleBase(db.Model, fsqla.FsRoleMixin):
- pass
+User = _build_user_model()
+Role = _build_role_model()
diff --git a/src/flaskpp/app/extensions.py b/src/flaskpp/app/extensions.py
index e11f3e9..2d19ae9 100644
--- a/src/flaskpp/app/extensions.py
+++ b/src/flaskpp/app/extensions.py
@@ -2,7 +2,7 @@
from flask_limiter.util import get_remote_address
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
-from flask_socketio import SocketIO
+from socketio import AsyncServer
from flask_babelplus import Babel
from flask_security import Security
from authlib.integrations.flask_client import OAuth
@@ -10,11 +10,15 @@
from flask_caching import Cache
from flask_smorest import Api
from flask_jwt_extended import JWTManager
+from functools import wraps
+
+from flaskpp.utils import enabled
+from flaskpp.utils.debugger import log
limiter = Limiter(get_remote_address)
db = SQLAlchemy()
migrate = Migrate()
-socket = SocketIO()
+socket = AsyncServer(async_mode="asgi", cors_allowed_origins="*")
babel = Babel()
security = Security()
oauth = OAuth()
@@ -22,3 +26,20 @@
cache = Cache()
api = Api()
jwt = JWTManager()
+
+
+def require_extensions(*extensions):
+ def decorator(func):
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ for ext in extensions:
+ if not isinstance(ext, str):
+ log("warn", f"Invalid extension '{ext}'.")
+ continue
+
+ if not enabled(f"EXT_{ext.upper()}"):
+ raise RuntimeError(f"Extension '{ext}' is not enabled.")
+ return func(*args, **kwargs)
+
+ return wrapper
+ return decorator
diff --git a/src/flaskpp/app/i18n.py b/src/flaskpp/app/i18n.py
index 0d76340..b4173b2 100644
--- a/src/flaskpp/app/i18n.py
+++ b/src/flaskpp/app/i18n.py
@@ -2,9 +2,8 @@
from babel.support import Translations
from flask import Flask, current_app
-from .data.babel import I18nMessage
-from .utils.translating import t, tn, get_locale
-from ..utils.debugger import debug_msg
+from flaskpp.app.data.babel import I18nMessage
+from flaskpp.app.utils.translating import t, tn, get_locale
class DBMergedTranslations(Translations):
@@ -25,20 +24,16 @@ def _db_get(self, msgid):
def gettext(self, message):
db_val = self._db_get(message)
if db_val:
- debug_msg(f"[i18n] DB hit: {message!r} β {db_val!r}")
return db_val
mo_val = self._wrapped.gettext(message)
- debug_msg(f"[i18n] MO/fallback: {message!r} β {mo_val!r}")
return mo_val
def ngettext(self, singular, plural, n):
key = plural if n != 1 else singular
db_val = self._db_get(key)
if db_val:
- debug_msg(f"[i18n] DB plural hit: {key!r} β {db_val!r}")
return db_val
mo_val = self._wrapped.ngettext(singular, plural, n)
- debug_msg(f"[i18n] MO plural: {singular!r}/{plural!r} β {mo_val!r}")
return mo_val
@@ -52,7 +47,6 @@ def get_translations(self):
domain=self.domain or "messages"
)
- debug_msg(f"domain={self.domain}, locale={locale}, has_wrapped={wrapped is not None}")
return DBMergedTranslations(wrapped, domain=self.domain, locale=locale)
diff --git a/src/flaskpp/app/static/css/tailwind_raw.css b/src/flaskpp/app/static/css/tailwind_raw.css
new file mode 100644
index 0000000..54eb724
--- /dev/null
+++ b/src/flaskpp/app/static/css/tailwind_raw.css
@@ -0,0 +1,136 @@
+@import "tailwindcss";
+
+@theme {
+ /* ... */
+}
+
+@layer base {
+ body {
+ @apply bg-slate-400 text-black min-h-screen w-screen;
+ }
+
+ header {
+ @apply sticky top-0 z-50;
+ }
+
+ nav {
+ @apply w-full bg-slate-800 text-white shadow-md;
+ }
+
+ main {
+ @apply flex flex-col items-center justify-center;
+ min-height: 100dvh;
+ }
+
+ footer {
+ @apply w-full mt-auto bg-slate-800 text-white text-sm shadow-md;
+ }
+}
+
+@layer components {
+ .nav-inner-div {
+ @apply mx-auto flex max-w-7xl items-center justify-between px-4 py-3;
+ }
+
+ .nav-brand {
+ @apply text-xl font-semibold tracking-tight transition hover:opacity-90;
+ }
+
+ .nav-collapse-btn {
+ @apply rounded-md border p-2 transition hover:bg-white/10 md:hidden;
+ }
+
+ .nav-collapse-btn-stripe {
+ @apply block h-0.5 w-6 rounded-full bg-white;
+ }
+
+ .nav-collapse {
+ @apply absolute top-full left-0 w-full flex flex-col gap-1 bg-slate-800 px-4 py-3 shadow-md md:static md:flex md:w-auto md:flex-row md:bg-transparent md:p-0 md:shadow-none;
+ }
+
+ .nav-link {
+ @apply block rounded-md px-3 max-md:py-2 transition;
+ }
+
+ .nav-link.active {
+ @apply max-md:bg-white/20 font-semibold;
+ }
+
+ .nav-link.inactive {
+ @apply max-md:hover:bg-white/30 md:hover:font-semibold;
+ }
+
+ .flash-container {
+ @apply fixed top-20 right-4 z-50 w-full max-w-sm space-y-3;
+ }
+
+ .flash {
+ @apply flex items-start justify-between gap-3 rounded-lg border-l-4 px-4 py-3 shadow-md bg-white;
+ }
+
+ .flash-text {
+ @apply text-sm leading-relaxed;
+ }
+
+ .flash.success {
+ @apply border-green-500 text-green-800;
+ }
+
+ .flash.warning {
+ @apply border-yellow-500 text-yellow-800;
+ }
+
+ .flash.danger {
+ @apply border-red-500 text-red-800;
+ }
+
+ .flash.info {
+ @apply border-blue-500 text-blue-800;
+ }
+
+ .flash.default {
+ @apply border-slate-400 text-slate-800;
+ }
+
+ .flash button {
+ @apply ml-2 text-lg leading-none opacity-60 hover:opacity-100 transition;
+ }
+
+ .modal {
+ @apply hidden fixed inset-0 z-50 items-center justify-center bg-black/30 backdrop-blur-sm shadow-md px-4;
+ }
+
+ .modal-body {
+ @apply w-full max-w-lg rounded-xl bg-white shadow-xl ring-1 ring-black/5 animate-[fadeIn_0.2s_ease-out];
+ }
+
+ .modal-header {
+ @apply flex items-center justify-between px-5 py-4 border-b;
+ }
+
+ .modal-header .headline {
+ @apply text-lg font-semibold text-slate-800;
+ }
+
+ .modal-header button {
+ @apply text-slate-400 hover:text-slate-700 transition text-xl leading-none;
+ }
+
+ .modal-content {
+ @apply px-5 py-4 text-sm text-slate-700 leading-relaxed;
+ }
+
+ .modal-footer {
+ @apply flex justify-end gap-2 px-5 py-3 border-t bg-slate-50 rounded-b-xl;
+ }
+
+ .footer-inner-div {
+ @apply max-w-7xl mx-auto px-4 py-3 text-center opacity-80;
+ }
+}
+
+@layer utilities {
+ .wrap-center {
+ @apply flex items-center justify-center;
+ }
+}
\ No newline at end of file
diff --git a/src/flaskpp/app/static/css/themes/triade_green.css b/src/flaskpp/app/static/css/themes/triade_green.css
deleted file mode 100644
index 8724aa3..0000000
--- a/src/flaskpp/app/static/css/themes/triade_green.css
+++ /dev/null
@@ -1,144 +0,0 @@
-/* CREATED BASED ON PALETTON.COM */
-
-:root {
- /* Primary */
- --color-primary-light: #8DCF8A;
- --color-primary: #5AAC56;
- --color-primary-mid: #328A2E;
- --color-primary-dark: #156711;
- --color-primary-darker: #034500;
-
- /* Secondary #1 */
- --color-sec1-light: #FFD0AA;
- --color-sec1: #D4996A;
- --color-sec1-mid: #AA6B39;
- --color-sec1-dark: #804415;
- --color-sec1-darker: #552600;
-
- /* Secondary #2 */
- --color-sec2-light: #CB87AF;
- --color-sec2: #A95486;
- --color-sec2-mid: #872D62;
- --color-sec2-dark: #651142;
- --color-sec2-darker: #440028;
-
- /* Contrast */
- --color-light: #f8f9fa;
- --color-dark: #212529;
-
- /* BOOTSTRAP COLOR OVERRIDES */
-
- /* Primary theme */
- --bs-primary: var(--color-primary);
- --bs-primary-rgb: 90, 172, 86;
-
- --bs-primary-text-emphasis: var(--color-primary-dark);
- --bs-primary-bg-subtle: var(--color-primary-light);
-
- /* Secondary */
- --bs-secondary: var(--color-sec2-mid);
- --bs-secondary-rgb: 135, 45, 98;
-
- --bs-secondary-text-emphasis: var(--color-sec2-dark);
- --bs-secondary-bg-subtle: var(--color-sec2-light);
-
- /* Further Defaults */
- --bs-success: var(--color-primary-mid);
- --bs-warning: var(--color-sec1-light);
- --bs-danger: var(--color-sec2-dark);
- --bs-info: var(--color-sec2-light);
-
- /* Buttons */
- --bs-btn-border-radius: 0.45rem;
- --bs-btn-padding-y: 0.45rem;
- --bs-btn-padding-x: 1rem;
-}
-
-
-/* BOOTSTRAP BUTTON OVERRIDES */
-
-.btn-primary {
- background-color: var(--color-primary);
- border-color: var(--color-primary-mid);
-}
-
-.btn-primary:hover,
-.btn-primary:focus {
- background-color: var(--color-primary-mid);
- border-color: var(--color-primary-dark);
-}
-
-.btn-secondary {
- background-color: var(--color-sec2-mid);
- border-color: var(--color-sec2-dark);
-}
-
-.btn-secondary:hover,
-.btn-secondary:focus {
- background-color: var(--color-sec2-dark);
- border-color: var(--color-sec2-darker);
-}
-
-/* Custom Buttons */
-
-.btn-earth {
- background-color: var(--color-sec1-mid);
- border-color: var(--color-sec1-dark);
- color: #fff;
-}
-
-.btn-earth:hover {
- background-color: var(--color-sec1-dark);
- border-color: var(--color-sec1-darker);
-}
-
-.btn-magenta {
- background-color: var(--color-sec2);
- border-color: var(--color-sec2-dark);
- color: #fff;
-}
-
-.btn-magenta:hover {
- background-color: var(--color-sec2-dark);
- border-color: var(--color-sec2-darker);
-}
-
-
-/* BACKGROUND UTILITY */
-
-.bg-primary-light { background-color: var(--color-primary-light) !important; }
-.bg-primary-dark { background-color: var(--color-primary-darker) !important; }
-.bg-earth { background-color: var(--color-sec1-mid) !important; }
-.bg-earth-dark { background-color: var(--color-sec1-dark) !important; }
-.bg-magenta { background-color: var(--color-sec2-mid) !important; }
-.bg-magenta-dark { background-color: var(--color-sec2-dark) !important; }
-
-
-/* TEXT UTILITY */
-
-.text-primary-dark { color: var(--color-primary-dark) !important; }
-.text-earth { color: var(--color-sec1-mid) !important; }
-.text-magenta { color: var(--color-sec2-mid) !important; }
-.text-magenta-dark { color: var(--color-sec2-dark) !important; }
-
-
-/* GENERAL UI & FURTHER UTILITY */
-
-.form-control:focus {
- border-color: var(--color-primary-mid);
- box-shadow: 0 0 0 0.2rem rgba(90, 172, 86, 0.25);
-}
-
-.card {
- border: 1px solid var(--color-primary-light);
- border-radius: 0.75rem;
-}
-
-.card-header {
- background-color: var(--color-primary-light);
- color: var(--color-primary-dark);
-}
-
-.card-title {
- color: var(--color-primary-mid);
-}
diff --git a/src/flaskpp/app/static/favicon.png b/src/flaskpp/app/static/img/favicon.png
similarity index 100%
rename from src/flaskpp/app/static/favicon.png
rename to src/flaskpp/app/static/img/favicon.png
diff --git a/src/flaskpp/app/static/js/base.js b/src/flaskpp/app/static/js/base.js
index 6508fcc..5d4cd10 100644
--- a/src/flaskpp/app/static/js/base.js
+++ b/src/flaskpp/app/static/js/base.js
@@ -1,31 +1,87 @@
-import { socket, emit } from "/static/js/socket.js";
+import { socket, emit } from "/fpp-static/js/socket.js";
-const flashContainer = document.getElementById('flashContainer');
-const confirmModal = new bootstrap.Modal(document.getElementById('confirmModal'));
+function getFocusable(elem) {
+ return (
+ elem.querySelector(
+ "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
+ )
+ );
+}
+
+function showModal(elem) {
+ elem._trigger = document.activeElement;
+
+ elem.classList.remove("hidden");
+ elem.classList.add("flex");
+ elem.removeAttribute("inert");
+
+ const focusable = getFocusable(elem);
+ focusable?.focus();
+}
+
+function hideModal(elem) {
+ elem.classList.add("hidden");
+ elem.classList.remove("flex");
+ elem.setAttribute("inert", "");
+
+ if (elem._trigger) {
+ elem._trigger.focus();
+ elem._trigger = null;
+ }
+}
+
+function bindModalCloseEvents(modalElem) {
+ modalElem.querySelectorAll("[data-modal-close]").forEach(btn => {
+ btn.addEventListener("click", () => hideModal(modalElem));
+ });
+
+ modalElem.addEventListener("mousedown", (ev) => {
+ if (ev.target === modalElem) hideModal(modalElem);
+ });
+}
+
+document.addEventListener("keydown", ev => {
+ if (ev.key !== "Escape") return;
+
+ const openModal = document.querySelector(".modal:not(.hidden)");
+ if (openModal) hideModal(openModal);
+});
+
+
+const confirmModal = document.getElementById('confirmModal');
+const confirmTitle = document.getElementById('dialogConfirmTitle');
const confirmText = document.getElementById('dialogConfirmText');
+const confirmBody = document.getElementById('dialogConfirmBody');
const confirmBtn = document.getElementById('dialogConfirmBtn');
const dismissBtn = document.getElementById('dialogDismissBtn');
-const infoModal = new bootstrap.Modal(document.getElementById('infoModal'));
+const infoModal = document.getElementById('infoModal');
const infoTitle = document.getElementById('infoModalTitle');
const infoText = document.getElementById('infoModalText');
const infoBody = document.getElementById('infoModalBody');
-export function flash(message, category) {
- flashContainer.innerHTML = `
-
- ${message}
-
-
- `
-}
-
-
-export async function confirmDialog(message, category) {
+export async function confirmDialog(title, message, html, category) {
confirmText.innerHTML = message.replace(/\n/g, "
");
- confirmBtn.className = `btn btn-${category}`;
+ confirmBtn.className =
+ `inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-semibold
+ focus:outline-none focus:ring-2 focus:ring-primary/40 transition text-white
+ ${category === 'danger' ? 'bg-red-600 hover:bg-red-700' : ''}
+ ${category === 'success' ? 'bg-green-600 hover:bg-green-700' : ''}
+ ${category === 'info' ? 'bg-blue-600 hover:bg-blue-700' : ''}
+ ${category === 'warning' ? 'bg-yellow-600 hover:bg-yellow-700' : ''}
+ `;
+
+ if (message) {
+ confirmBody.classList.add('hidden');
+ confirmText.classList.remove('hidden');
+ confirmText.textContent = message;
+ } else {
+ confirmText.classList.add('hidden');
+ confirmBody.classList.remove('hidden');
+ confirmBody.innerHTML = html;
+ }
return new Promise((resolve) => {
function onConfirm() {
@@ -41,29 +97,46 @@ export async function confirmDialog(message, category) {
function cleanup() {
confirmBtn.removeEventListener('click', onConfirm);
dismissBtn.removeEventListener('click', onDismiss);
- confirmModal.hide();
+ hideModal(confirmModal);
}
confirmBtn.addEventListener('click', onConfirm);
dismissBtn.addEventListener('click', onDismiss);
- confirmModal.show();
+ showModal(confirmModal);
});
}
-
export function showInfo(title, message, html) {
infoTitle.textContent = title;
if (message) {
- infoBody.classList.add('d-none');
- infoText.classList.remove('d-none')
+ infoBody.classList.add('hidden');
+ infoText.classList.remove('hidden');
infoText.textContent = message;
} else {
- infoText.classList.add('d-none');
- infoBody.classList.remove('d-none');
+ infoText.classList.add('hidden');
+ infoBody.classList.remove('hidden');
infoBody.innerHTML = html;
}
- infoModal.show();
+
+ showModal(infoModal);
+}
+
+
+const flashContainer = document.getElementById('flashContainer');
+
+export function flash(message, category) {
+ flashContainer.innerHTML = `
+
+
+ ${message}
+
+
+ ×
+
+
+ `
}
@@ -89,7 +162,6 @@ export async function _(key) {
});
}
-
export async function _n(singular, plural, count) {
return new Promise((resolve) => {
emit("_n", {
@@ -133,4 +205,26 @@ socket.on('error', async (message) => {
const errLabel = await _("Error Message:");
showInfo(title, `${errMsg}${errLabel} "${message}".`);
+});
+
+
+window.FPP = {
+ confirmDialog: confirmDialog,
+ showInfo: showInfo,
+ flash: flash,
+ safe_: safe_,
+ _: _,
+ _n: _n,
+ socketHtmlInject: socketHtmlInject,
+ socket: socket,
+ emit: emit
+}
+
+
+document.addEventListener("DOMContentLoaded", () => {
+ document.querySelectorAll(".modal").forEach(modal => {
+ modal.setAttribute("inert", "");
+ hideModal(modal);
+ bindModalCloseEvents(modal);
+ });
});
\ No newline at end of file
diff --git a/src/flaskpp/app/static/js/socket.js b/src/flaskpp/app/static/js/socket.js
index 25aaeac..5c15971 100644
--- a/src/flaskpp/app/static/js/socket.js
+++ b/src/flaskpp/app/static/js/socket.js
@@ -1,4 +1,4 @@
-const socketScript = document.getElementById("socketScript");
+const socketScript = document.getElementById("fppSocketScript");
export function connectSocket() {
const domain = socketScript.dataset.socketDomain;
diff --git a/src/flaskpp/app/templates/404.html b/src/flaskpp/app/templates/404.html
index d9284d7..60d0697 100644
--- a/src/flaskpp/app/templates/404.html
+++ b/src/flaskpp/app/templates/404.html
@@ -1,7 +1,19 @@
{% extends "base_example.html" %}
{% block title %}{{ _('Not found') }}{% endblock %}
{% block content %}
-
{{ _('404 - Page not found') }}
-
{{ _("We are sorry, but the requested page doesn't exist.") }}
-
{{ _('Back Home') }}
+
{% endblock %}
diff --git a/src/flaskpp/app/templates/base_example.html b/src/flaskpp/app/templates/base_example.html
index 74cfcb1..71bfd2e 100644
--- a/src/flaskpp/app/templates/base_example.html
+++ b/src/flaskpp/app/templates/base_example.html
@@ -1,86 +1,109 @@
-
+
-
+
-
-
+ {{ fpp_tailwind }}
-
+
{% block title %}Flask++ App{% endblock %}
-
{% block title %}Flask App{% endblock %}
+ {% if enabled("EXT_SOCKET") %}
+
+
-
-
-
-
-
-
+
+ {% endif %}
{% block head %}{% endblock %}
-
+
+
+
+
+
{{ _('My Flask++ App') }}
-
-
-
-
- {{ _('My new Flask-App') }}
-
+
+
+
+
+
-
-
-
-
-
-
-
+
{% for label, href in NAV.items() %}
{% include "nav_link.html" with context %}
{% endfor %}
-
-
+
- {% include "flashing.html" %}
-
+ {% include "flashing.html" %}
+
+
+ {% block content %}{% endblock %}
+
-
- {% block content %}{% endblock %}
-
-
-
-
- © {{ current_year|default(2025) }}
-
-
-{% include "modals/info_modal.html" %}
-{% include "modals/confirm_modal.html" %}
-
-
+
+
+ {% if enabled("EXT_SOCKET") %}
+ {% include "modals/confirm_modal.html" %}
+ {% include "modals/info_modal.html" %}
+ {% endif %}
+
+
-{% block scripts %}{% endblock %}
+ {% block scripts %}{% endblock %}
diff --git a/src/flaskpp/app/templates/base_modal.html b/src/flaskpp/app/templates/base_modal.html
index 86e6126..dea7c7f 100644
--- a/src/flaskpp/app/templates/base_modal.html
+++ b/src/flaskpp/app/templates/base_modal.html
@@ -1,16 +1,28 @@
-
-
+
+
+
+
+
-
-
- {% block body %}{% endblock %}
-
-
+ {% block body %}{% endblock %}
+
+
+
-
\ No newline at end of file
+
diff --git a/src/flaskpp/app/templates/error.html b/src/flaskpp/app/templates/error.html
index 30cd3f8..e110104 100644
--- a/src/flaskpp/app/templates/error.html
+++ b/src/flaskpp/app/templates/error.html
@@ -1,7 +1,22 @@
{% extends "base_example.html" %}
{% block title %}{{ _('Error') }}{% endblock %}
{% block content %}
-
{{ _('An error occurred') }}
-
{{ _('Something went wrong, please try again later.') }}
-
{{ _('Back Home') }}
+
+
+
+ β οΈ {{ _('An error occurred') }}
+
+
+
+ {{ _('Something went wrong, please try again later.') }}
+
+
+
+ {{ _('Back Home') }}
+
+
+
{% endblock %}
diff --git a/src/flaskpp/app/templates/flashing.html b/src/flaskpp/app/templates/flashing.html
index 5b221af..60c8f6b 100644
--- a/src/flaskpp/app/templates/flashing.html
+++ b/src/flaskpp/app/templates/flashing.html
@@ -1,10 +1,16 @@
-
+
{% with msgs = get_flashed_messages(with_categories=true) %}
{% if msgs %}
{% for cat, msg in msgs %}
-
- {{ msg }}
-
+
+
+ {{ msg }}
+
+
+ ×
+
{% endfor %}
{% endif %}
diff --git a/src/flaskpp/app/templates/modals/confirm_modal.html b/src/flaskpp/app/templates/modals/confirm_modal.html
index 25cdc4c..72ccaed 100644
--- a/src/flaskpp/app/templates/modals/confirm_modal.html
+++ b/src/flaskpp/app/templates/modals/confirm_modal.html
@@ -5,12 +5,36 @@
{% block title %}{{ _("Confirm") }}{% endblock %}
{% block body %}
-
+
+
+
+
+
+
+
+
{% endblock %}
{% block footer %}
-
-
{{ _("No") }}
-
{{ _("Yes") }}
+
+
+ {{ _("No") }}
+
+
+
+ {{ _("Yes") }}
+
{% endblock %}
\ No newline at end of file
diff --git a/src/flaskpp/app/templates/modals/info_modal.html b/src/flaskpp/app/templates/modals/info_modal.html
index 5777175..c64db56 100644
--- a/src/flaskpp/app/templates/modals/info_modal.html
+++ b/src/flaskpp/app/templates/modals/info_modal.html
@@ -2,14 +2,32 @@
{% block id %}infoModal{% endblock %}
-{% block title %}βΉοΈ {{ _("Hint") }} {% endblock %}
+{% block title %}βΉοΈ {{ _("Hint") }}{% endblock %}
{% block body %}
-
-
-
+
+
+
+
+
+
+
+
{% endblock %}
{% block footer %}
-
{{ _("Understood") }}
+
+ {{ _("Understood") }}
+
{% endblock %}
\ No newline at end of file
diff --git a/src/flaskpp/app/templates/nav_link.html b/src/flaskpp/app/templates/nav_link.html
index 76aee52..6b2af2e 100644
--- a/src/flaskpp/app/templates/nav_link.html
+++ b/src/flaskpp/app/templates/nav_link.html
@@ -1,6 +1,4 @@
-
+
+ {{ _(label) }}
+
\ No newline at end of file
diff --git a/src/flaskpp/app/utils/processing.py b/src/flaskpp/app/utils/processing.py
index 8179e56..c7df789 100644
--- a/src/flaskpp/app/utils/processing.py
+++ b/src/flaskpp/app/utils/processing.py
@@ -1,11 +1,12 @@
-from flask import request, render_template
+from flask import request, render_template, url_for
from werkzeug.exceptions import NotFound
+from markupsafe import Markup
-from ..utils.translating import get_locale
-from ..utils.auto_nav import nav_links
-from ..socket import default_handlers, no_handler
-from ...utils import random_code
-from ...utils.debugger import log, exception
+from flaskpp.app.utils.translating import get_locale
+from flaskpp.app.utils.auto_nav import nav_links
+from flaskpp.app.socket import default_handlers, no_handler
+from flaskpp.utils import random_code, enabled
+from flaskpp.utils.debugger import log, exception
handlers = {}
@@ -20,6 +21,10 @@ def _context_processor():
PATH=request.path,
LANG=get_locale(),
NAV=nav_links,
+
+ enabled=enabled,
+ fpp_tailwind=Markup(f"
"),
+ tailwind_main=Markup(f"
"),
)
@@ -66,13 +71,16 @@ def socket_event_handler(fn):
return fn
@socket_event_handler
-def _socket_event_handler(data: dict):
+def _socket_event_handler(sid: str, data: dict):
event = data["event"]
payload = data.get("payload")
- log("request", f"Socket event: {event} - With data: {payload}")
+ log("request", f"Socket event from {sid}: {event} - With data: {payload}")
handler = default_handlers.get(event, no_handler)
- return handler(payload)
+ try:
+ return handler(payload)
+ except Exception as e:
+ return handlers["handle_socket_error"](e)
def handle_socket_error(fn):
diff --git a/src/flaskpp/cli.py b/src/flaskpp/cli.py
index f840022..9bbf886 100644
--- a/src/flaskpp/cli.py
+++ b/src/flaskpp/cli.py
@@ -1,26 +1,116 @@
from pathlib import Path
+from importlib.metadata import version
import typer, os, subprocess, sys
-from .modules.cli import modules_entry
-from .utils.setup import setup
-from .utils.run import run
-from .utils.service_registry import registry_entry
+from flaskpp.modules.cli import modules_entry
+from flaskpp.utils.setup import setup
+from flaskpp.utils.run import run
+from flaskpp.utils.service_registry import registry_entry
+from flaskpp.tailwind import setup_tailwind
+from flaskpp.fpp_node import load_node
+from flaskpp.fpp_node.vite import prepare_vite
+from flaskpp.fpp_node.cli import node_entry
+from flaskpp.tailwind.cli import tailwind_entry
app = typer.Typer(help="Flask++ CLI")
cli_home = Path(__file__).parent
+@app.callback(invoke_without_command=True)
+def main_callback(
+ ctx: typer.Context,
+ version_flag: bool = typer.Option(
+ False, "--version", "-v",
+ help="Show the current version of Flask++.",
+ is_eager=True
+ ),
+ help_flag: bool = typer.Option(
+ False, "--help", "-h",
+ help="Get help about all commands.",
+ is_eager=True
+ )
+):
+ if version_flag:
+ typer.echo(f"Flask++ v{version('flaskpp')}")
+ raise typer.Exit()
+
+ if help_flag:
+ typer.echo(
+ "Usage: \n\t" + typer.style("fpp [args]", bold=True) + "\n"
+ "\t" + typer.style("fpp [command] [args]", bold=True) + "\n"
+ "\t" + typer.style("fpp [subcli] [command] [args]", bold=True) + "\n\n"
+ "Arguments:\n\t-v, --version\t - Show the current version of Flask++.\n"
+ "\t-h, --help\t - Show this help message.\n\n"
+ "Commands:\n\tinit\t\t - Creates the Flask++ basic structure in the current working directory.\n"
+ "\tsetup\t\t - Starts the Flask++ app setup tool. (Can be run multiple times.)\n"
+ "\trun\t\t - The Flask++ native app control. (Using uvicorn.)\n\n"
+ "Sub-CLIs:\n\tmodules\t\t - Manages the modules of Flask++ apps.\n"
+ "\tregistry\t - Manages the app service registry for you. (Requires admin privileges.)\n"
+ "\tnode\t\t - Allows you to run node commands with the standalone node cli. (" + typer.style("fpp node [npm/npx] [args]", bold=True) + ")\n"
+ "\ttailwind\t - Allows you to use the natively integrated tailwind cli.\n"
+ "\t" + typer.style("To use node and tailwind, you need to run ", fg=typer.colors.MAGENTA)
+ + typer.style("fpp init", bold=True, fg=typer.colors.MAGENTA)
+ + typer.style(" at least one time before.", fg=typer.colors.MAGENTA) + "\n\n" +
+ typer.style("fpp run [args]", bold=True) + "\n"
+ "\t-i, --interactive - Starts all your apps in interactive mode and lets you manage them.\n"
+ "\t-a, --app\t - Specify the name of a specific app, if you don't want to run interactive.\n"
+ "\t-p, --port\t - Specify the port on which your app should listen. (Default is 5000.)\n"
+ "\t-d, --debug\t - Run your app in debug mode, to get more detailed tracebacks and log debug messages. (Default is False.)\n"
+ "\t\t\t If FRONTEND_ENGINE is enabled, vite will run in dev mode. Every module runs its own dev server.\n\n\n" +
+ typer.style("fpp modules [command] [args]", bold=True) + "\n"
+ "\tinstall\t\t - Install a specified Flask++ module.\n"
+ "\tcreate\t\t - Automatically create a new module to make things easier.\n\n" +
+ typer.style("fpp modules install [name] [args]", bold=True) + "\n"
+ "\tname\t\t - The name of the module to install.\n"
+ "\t-s, --src\t - Specify the name of a source directory or git remote repository to install.\n"
+ "\t" + typer.style(
+ "If you install from a source, name will be the installed name.\n"
+ "\tIf you only specify the name, the module will be installed from our hub. (Coming soon.)",
+ fg=typer.colors.MAGENTA
+ ) + "\n\n" +
+ typer.style("fpp modules create [name]", bold=True) + "\n"
+ "\tname\t\t - The name of the module you want to create.\n\n\n" +
+ typer.style("fpp registry [command] [args]", bold=True) + "\n"
+ "\tregister\t - Register an app as a system service. (Executed when system boots up.)\n"
+ "\tremove\t\t - Remove your app from system services.\n"
+ "\tstart\t\t - Start your apps system service.\n"
+ "\tstop\t\t - Stop your apps system service.\n\n" +
+ typer.style("fpp registry register [args]", bold=True) + "\n"
+ "\t-a, --app\t - The name of your app, which you want to register as a service.\n"
+ "\t-p, --port\t - The port on which your apps service should run. (Default is 5000.)\n"
+ "\t-d, --debug\t - If your service should run in debug mode.\n\n" +
+ typer.style("fpp registry [remove/start/stop] [name]", bold=True) + "\n"
+ "\tname\t\t - The name of the app (which - of course - also is the service name)."
+ )
+ raise typer.Exit()
+
+ if ctx.invoked_subcommand is None:
+ typer.echo("For further information use: " + typer.style("fpp --help", bold=True))
+ raise typer.Exit()
+
+
@app.command()
def init():
typer.echo(typer.style("Creating default structure...", bold=True))
- root = Path(os.getcwd())
- (root / "templates").mkdir(exist_ok=True)
+ from flaskpp.utils.setup import conf_path
+ conf_path.mkdir(exist_ok=True)
+
+ from flaskpp.modules import module_home
+ module_home.mkdir(exist_ok=True)
+
+ from flaskpp.utils.service_registry import service_path
+ service_path.mkdir(exist_ok=True)
+
+ root = Path.cwd()
+ templates = root / "templates"
+ templates.mkdir(exist_ok=True)
translations = root / "translations"
translations.mkdir(exist_ok=True)
static = root / "static"
static.mkdir(exist_ok=True)
- (static / "css").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:
@@ -35,6 +125,35 @@ def create_app(config_name: str = "default"):
app = create_app().to_asgi()
""")
+ (templates / "index.html").write_text("""
+{% extends "base_example.html" %}
+{# The base template is natively provided by Flask++. #}
+
+{% block title %}{{ _('Home') }}{% endblock %}
+{% block content %}
+
+
{{ _('My new Flask++ Project') }}
+
+ {{ _('This is my brand new, super cool project.') }}
+
+
+
+{% endblock %}
+ """)
+
+ (css / "tailwind_raw.css").write_text("""
+@import "tailwindcss";
+
+@source not "../../.venv";
+@source not "../../venv";
+@source not "../../vite";
+@source not "../../modules";
+
+@theme {
+ /* ... */
+}
+ """)
+
typer.echo(typer.style("Generation default translations...", bold=True))
pot = "messages.pot"
@@ -69,6 +188,11 @@ def create_app(config_name: str = "default"):
"-d", trans
])
+ setup_tailwind()
+
+ load_node()
+ prepare_vite()
+
typer.echo(typer.style("Flask++ project successfully initialized.", fg=typer.colors.GREEN, bold=True))
app.command()(setup)
@@ -78,6 +202,8 @@ def create_app(config_name: str = "default"):
def main():
modules_entry(app)
registry_entry(app)
+ node_entry(app)
+ tailwind_entry(app)
app()
diff --git a/src/flaskpp/fpp_node/__init__.py b/src/flaskpp/fpp_node/__init__.py
new file mode 100644
index 0000000..7878bc6
--- /dev/null
+++ b/src/flaskpp/fpp_node/__init__.py
@@ -0,0 +1,79 @@
+from pathlib import Path
+from tqdm import tqdm
+import os, platform, requests, typer
+
+home = Path(__file__).parent
+node_standalone = {
+ "linux": "https://nodejs.org/dist/v24.11.1/node-v24.11.1-linux-{architecture}.tar.xz",
+ "win": "https://nodejs.org/dist/v24.11.1/node-v24.11.1-win-{architecture}.zip"
+}
+
+
+def _get_node_data():
+ selector = "win" if os.name == "nt" else "linux"
+
+ machine = platform.machine().lower()
+ arch = "x64" if machine == "x86_64" or machine == "amd64" else "arm64"
+
+ return node_standalone[selector].format(architecture=arch), selector
+
+
+def _node_cmd(cmd: str) -> str:
+ if os.name == "nt":
+ return str(home / "node" / f"{cmd}.cmd")
+ return str(home / "node" / "bin" / cmd)
+
+
+def _node_env() -> dict:
+ env = os.environ.copy()
+ if os.name != "nt":
+ node_bin = str(home / "node" / "bin")
+ env["PATH"] = node_bin + os.pathsep + env.get("PATH", "")
+ else:
+ node_dir = str(home / "node")
+ env["PATH"] = node_dir + os.pathsep + env.get("PATH", "")
+ return env
+
+
+def load_node():
+ data = _get_node_data()
+ file_type = "zip" if data[1] == "win" else "tar.xz"
+ dest = home / f"node.{file_type}"
+ bin_folder = home / "node"
+
+ if bin_folder.exists():
+ return
+
+ typer.echo(typer.style(f"Downloading {data[0]}...", bold=True))
+ with requests.get(data[0], stream=True) as r:
+ r.raise_for_status()
+ total = int(r.headers.get("content-length", 0))
+ with open(dest, "wb") as f, tqdm(
+ total=total, unit="B", unit_scale=True, desc=str(dest)
+ ) as bar:
+ for chunk in r.iter_content(chunk_size=8192):
+ f.write(chunk)
+ bar.update(len(chunk))
+
+ if not dest.exists():
+ raise NodeError("Failed to download standalone node bundle.")
+
+ typer.echo(typer.style(f"Extracting node.{file_type}...", bold=True))
+
+ if file_type == "zip":
+ import zipfile
+ with zipfile.ZipFile(dest, "r") as f:
+ f.extractall(home)
+ else:
+ import tarfile
+ with tarfile.open(dest, "r") as f:
+ f.extractall(home)
+
+ extracted_folder = home / data[0].split("/")[-1].removesuffix(f".{file_type}")
+ extracted_folder.rename(bin_folder)
+
+ dest.unlink()
+
+
+class NodeError(Exception):
+ pass
diff --git a/src/flaskpp/fpp_node/cli.py b/src/flaskpp/fpp_node/cli.py
new file mode 100644
index 0000000..612b993
--- /dev/null
+++ b/src/flaskpp/fpp_node/cli.py
@@ -0,0 +1,37 @@
+import typer, subprocess, os
+
+from flaskpp.fpp_node import _node_cmd, NodeError
+
+node = typer.Typer(
+ help="Run node commands using the portable Flask++ node bundle. (Behaves like normal node.)"
+)
+
+
+@node.callback(
+ invoke_without_command=True,
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
+)
+def main(
+ ctx: typer.Context,
+ command: str = typer.Argument(
+ "npm",
+ help="The node command to execute",
+ )
+):
+ args = ctx.args
+
+ result = subprocess.run(
+ [_node_cmd(command), *args],
+ cwd=os.getcwd(),
+ capture_output=True,
+ text=True,
+ )
+
+ if result.returncode != 0:
+ raise NodeError(result.stderr)
+
+ typer.echo(result.stdout)
+
+
+def node_entry(app: typer.Typer):
+ app.add_typer(node, name="node")
diff --git a/src/flaskpp/fpp_node/vite.py b/src/flaskpp/fpp_node/vite.py
new file mode 100644
index 0000000..5f7575f
--- /dev/null
+++ b/src/flaskpp/fpp_node/vite.py
@@ -0,0 +1,326 @@
+from flask import Blueprint, Response, send_from_directory
+from werkzeug.datastructures import Headers
+from markupsafe import Markup
+from dataclasses import dataclass
+from typing import Optional, List, Dict
+from threading import Thread
+from pathlib import Path
+import typer, subprocess, json, re, requests, os
+
+from flaskpp.fpp_node import home, _node_cmd, _node_env
+from flaskpp.utils import enabled, is_port_free
+from flaskpp.utils.debugger import exception
+
+
+@dataclass
+class ManifestChunk:
+ src: Optional[str] = None
+ file: str = ""
+ css: Optional[List[str]] = None
+ assets: Optional[List[str]] = None
+ isEntry: bool = False
+ name: Optional[str] = None
+ isDynamicEntry: bool = False
+ imports: Optional[List[str]] = None
+ dynamicImports: Optional[List[str]] = None
+
+
+Manifest = Dict[str, ManifestChunk]
+
+package_json = """
+{
+ "name": "fpp-vite",
+ "version": "0.0.2",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "typescript": "~5.9.3",
+ "vite": "^7.2.4"
+ },
+ "dependencies": {
+ "@tailwindcss/vite": "^4.1.17",
+ "tailwindcss": "^4.1.17"
+ }
+}
+"""
+
+vite_conf = """
+import {{ defineConfig }} from "vite"
+import tailwindcss from "@tailwindcss/vite"
+
+export default defineConfig({{
+ root: "{root}",
+ build: {{
+ manifest: true,
+ rollupOptions: {{
+ input: "{entry_point}",
+ }},
+ }},
+ plugins: [
+ tailwindcss(),
+ ],
+}})
+"""
+
+ts_conf_template = """
+{{
+ "compilerOptions": {{
+ "target": "ES2023",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ "strict": false,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ "allowJs": true,
+ "checkJs": false
+ }},
+ "include": ["{include}"]
+}}
+"""
+
+vite_main = """
+import "./src/main.css";
+
+const _ = window.FPP?._ ?? (async msg => msg)
+
+const el = document.querySelector('#main')
+if (el) {
+ (async () => {
+ el.innerHTML = `
+
+
${await _("Injected with JavaScript!")}
+
${await _("You can now use Vite module wise.")}
+
+ `
+ })()
+}
+"""
+
+vite_tw = """
+@import "tailwindcss";
+
+@theme {
+ /* ... */
+}
+"""
+
+_ports_in_use = []
+
+
+def prepare_vite():
+ typer.echo(typer.style("Setting up vite...", bold=True))
+ (home / "package.json").write_text(package_json)
+
+ ts_conf_file = home / "tsconfig.json"
+ project_root = Path.cwd().resolve()
+ tsc_path = os.path.normpath(
+ os.path.relpath(project_root, start=home)
+ ) + "/**/vite/src"
+ if not ts_conf_file.exists():
+ ts_conf_file.write_text(ts_conf_template.format(
+ include=tsc_path
+ ))
+ else:
+ ts_conf = json.loads(ts_conf_file.read_text())
+ updated = []
+ for path in ts_conf["include"]:
+ base = Path(home, path.split("/**")[0]).resolve()
+ if base.exists():
+ updated.append(path)
+ if not tsc_path in ts_conf["include"]:
+ updated.append(tsc_path)
+ ts_conf["include"] = sorted(set(updated))
+ ts_conf_file.write_text(json.dumps(ts_conf))
+
+ result = subprocess.run(
+ [_node_cmd("npm"), "install"],
+ cwd=home,
+ env=_node_env()
+ )
+ if result.returncode != 0:
+ typer.echo(typer.style("Failed to setup vite.", fg=typer.colors.RED, bold=True))
+ return
+
+ typer.echo(typer.style("Vite successfully prepared.", fg=typer.colors.GREEN, bold=True))
+
+
+def load_manifest(dist: Path) -> Manifest:
+ manifest_file = dist / ".vite" / "manifest.json"
+ if not manifest_file.exists():
+ raise ViteError("Failed to load Vites manifest.json")
+ raw = json.loads(manifest_file.read_text())
+ manifest: Manifest = {}
+
+ for key, data in raw.items():
+ manifest[key] = ManifestChunk(**data)
+
+ return manifest
+
+
+def resolve_entry(manifest: Manifest, entry: str):
+ if entry not in manifest:
+ raise ViteError(f"'{entry}' not found in Vite manifest.")
+
+ js_files = set()
+ css_files = set()
+
+ visited = set()
+ def collect(chunk_name: str):
+ if chunk_name in visited:
+ return
+
+ chunk = manifest.get(chunk_name)
+ if not chunk:
+ return
+ visited.add(chunk_name)
+
+ js_files.add(chunk.file)
+
+ if chunk.css:
+ css_files.update(chunk.css)
+
+ if chunk.imports:
+ for dep in chunk.imports:
+ collect(dep)
+
+ collect(entry)
+ return list(js_files), list(css_files)
+
+
+class Frontend(Blueprint):
+ from flaskpp import FlaskPP, Module
+ def __init__(self, parent: FlaskPP | Module):
+ super().__init__(f"{parent.safe_name if hasattr(parent, "safe_name") else parent.name}_vite", parent.import_name)
+ prefix = "/vite"
+ self.prefix = f"{parent.url_prefix}{prefix}" if parent.url_prefix is not None else prefix
+ self.url_prefix = self.prefix if isinstance(parent, self.FlaskPP) else prefix
+ self.route("/
")(self.serve)
+
+
+ root = (Path(parent.root_path) / "vite").resolve()
+ root.mkdir(exist_ok=True)
+ public = root / "public"
+ public.mkdir(exist_ok=True)
+ src = root / "src"
+ src.mkdir(exist_ok=True)
+ main = root / "main.js"
+ main_css = src / "main.css"
+ safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", parent.name)
+ conf_name = f"vite.config.{safe_name}.js"
+ (home / conf_name).write_text(vite_conf.format(
+ root=str(root),
+ entry_point=str(main)
+ ))
+ conf_params = ["--config", conf_name]
+ if not main.exists():
+ main.write_text(vite_main)
+ if not main_css.exists():
+ main_css.write_text(vite_tw)
+
+ if enabled("DEBUG_MODE"):
+ self.session = requests.Session()
+ self.port = int(os.getenv("SERVER_PORT", "5000")) if len(_ports_in_use) == 0 else _ports_in_use[-1]
+ self.port += 100
+ while not is_port_free(self.port):
+ self.port += 100
+ _ports_in_use.append(self.port)
+
+ self.server = subprocess.Popen(
+ [_node_cmd("npm"), "run", "dev", "--", "--port", str(self.port), *conf_params],
+ cwd=home,
+ env=_node_env()
+ )
+ else:
+ if any(root.rglob("*.ts")):
+ result = subprocess.run(
+ [_node_cmd("npx"), "tsc"],
+ cwd=home,
+ env=_node_env(),
+ capture_output=True,
+ text=True
+ )
+ try:
+ if result.returncode != 0: raise ViteError("TypeScript checks failed.")
+ except ViteError as e:
+ exception(e, result.stderr or result.stdout)
+
+ self.build = subprocess.Popen(
+ [_node_cmd("npm"), "run", "build", "--", *conf_params],
+ cwd=home,
+ env=_node_env()
+ )
+ self.dist = root / "dist"
+ self.manifest = None
+ self.loader = Thread(target=self._load_manifest)
+ self.loader.start()
+
+ parent.register_blueprint(self)
+
+ def _load_manifest(self):
+ self.build.wait()
+ if self.build.returncode != 0:
+ raise ViteError("Vite build process failed.")
+ self.manifest = load_manifest(self.dist)
+
+ def vite(self, entry: str):
+ if enabled("DEBUG_MODE"):
+ if entry.endswith(".js"):
+ return Markup(f'')
+ elif entry.endswith(".css"):
+ return Markup(f' ')
+ return ""
+
+ js, css = resolve_entry(self.manifest, entry)
+
+ tags = []
+
+ for file in css:
+ tags.append(f' ')
+
+ for file in js:
+ tags.append(f'')
+
+ return Markup("\n".join(tags))
+
+ def serve(self, path) -> Response:
+ if not enabled("DEBUG_MODE"):
+ if self.built and not self.dist.exists():
+ raise ViteError("Missing vite/dist directory.")
+ elif self.loader.is_alive():
+ self.loader.join()
+ elif not self.built:
+ raise ViteError("There was an error while building vite.")
+
+ return send_from_directory(self.dist.resolve(), path)
+
+ if not self.server or self.server.poll() is not None:
+ raise ViteError("Frontend server is not running.")
+ upstream = self.session.get(f"http://localhost:{self.port}/{path}")
+ response = Response(upstream.content, upstream.status_code)
+ response.headers = Headers(upstream.headers)
+ return response
+
+ @property
+ def built(self) -> bool:
+ return not enabled("DEBUG_MODE") and self.build.returncode == 0
+
+
+class ViteError(Exception):
+ pass
diff --git a/src/flaskpp/modules/__init__.py b/src/flaskpp/modules/__init__.py
index 45dcf0f..e1aabb0 100644
--- a/src/flaskpp/modules/__init__.py
+++ b/src/flaskpp/modules/__init__.py
@@ -1,36 +1,16 @@
-from flask import Flask
from jinja2 import ChoiceLoader, PrefixLoader, FileSystemLoader
from pathlib import Path
from importlib import import_module
from configparser import ConfigParser
-from functools import wraps
import os, typer
-from ..utils.debugger import log, exception
+from flaskpp.utils.debugger import log, exception
-home = Path(os.getcwd())
+home = Path.cwd()
module_home = home / "modules"
conf_path = home / "app_configs"
-def require_extensions(*extensions):
- def decorator(func):
- @wraps(func)
- def wrapper(*args, **kwargs):
- for ext in extensions:
- if not isinstance(ext, str):
- log("warn", f"Invalid extension '{ext}'.")
- continue
-
- from ..utils import enabled
- if not enabled(f"EXT_{ext.upper()}"):
- raise RuntimeError(f"Extension '{ext}' is not enabled.")
- return func(*args, **kwargs)
-
- return wrapper
- return decorator
-
-
def generate_modlib(app_name: str):
conf = conf_path / f"{app_name}.conf"
config = ConfigParser()
@@ -88,7 +68,7 @@ def generate_modlib(app_name: str):
config.write(f)
-def register_modules(app: Flask):
+def register_modules(app):
app_name = os.getenv("APP_NAME")
if not app_name:
raise RuntimeError("Missing app name variable: APP_NAME")
@@ -114,18 +94,25 @@ def register_modules(app: Flask):
try:
mod = import_module(f"modules.{mod_name}")
- except ModuleNotFoundError:
- log("error", f"Could not import module '{mod_name}' for app '{app_name}'.")
+ except ModuleNotFoundError as e:
+ exception(e, f"Could not import module '{mod_name}' for app '{app_name}'.")
continue
- register = getattr(mod, "register_module", None)
- if not callable(register):
- log("error", f"Missing 'register_module(app: Flask)' in module '{mod_name}'.")
+ from flaskpp import Module
+ module = getattr(mod, "module", None)
+ if not isinstance(module, Module):
+ log("error", f"Missing 'module: Module' in module '{mod_name}'.")
+ continue
+
+ try:
+ log("info", f"Registering: {module}")
+ except ManifestError as e:
+ exception(e, f"Failed to log {mod_name}.module")
continue
try:
home = os.getenv("HOME_MODULE", "").lower() == mod_name.lower()
- register(app, home)
+ module.enable(app, home)
loader_context[mod_name] = FileSystemLoader(f"modules/{mod_name}/templates")
if home:
primary_loader = loader_context[mod_name]
@@ -143,3 +130,11 @@ def register_modules(app: Flask):
)
app.jinja_loader = ChoiceLoader(loaders)
+
+
+class ModuleError(Exception):
+ pass
+
+
+class ManifestError(ModuleError):
+ pass
diff --git a/src/flaskpp/modules/cli.py b/src/flaskpp/modules/cli.py
index c3fc118..cd60931 100644
--- a/src/flaskpp/modules/cli.py
+++ b/src/flaskpp/modules/cli.py
@@ -2,19 +2,20 @@
from pathlib import Path
import typer, shutil
-from ..modules import module_home
+from flaskpp.utils import prompt_yes_no, sanitize_text
+from flaskpp.modules import module_home, creator_templates
modules = typer.Typer(help="Manage the modules of Flask++ apps.")
@modules.command()
def install(
- module: str,
- src: str = typer.Option(
- None,
- "-s", "--src",
- help="Optional source for your module",
- )
+ module: str,
+ src: str = typer.Option(
+ None,
+ "-s", "--src",
+ help="Optional source for your module",
+ )
):
if not src:
raise NotImplementedError("Module hub is not ready yet.")
@@ -40,5 +41,72 @@ def install(
typer.echo("Failed to clone from source.")
+@modules.command()
+def create(
+ module: str
+):
+ module_dst = module_home / module
+ if module_dst.exists():
+ typer.echo(typer.style(
+ f"There is already a folder names '{module}' in modules.",
+ fg=typer.colors.YELLOW, bold=True
+ ))
+ if not prompt_yes_no("Do you want to overwrite it? (y/N)"):
+ return
+ module_dst.unlink()
+ module_dst.mkdir(exist_ok=True)
+
+ manifest = creator_templates.module_manifest.format(
+ name=sanitize_text(input("Enter the name of your module: ")),
+ description=sanitize_text(input("Describe your module briefly: ")),
+ version=sanitize_text(input("Enter the version of your module: ")),
+ author=sanitize_text(input("Enter your name or nickname: "))
+ )
+ typer.echo(typer.style(f"Writing manifest...", bold=True))
+ (module_dst / "manifest.json").write_text(manifest)
+
+ typer.echo(typer.style(f"Creating basic structure...", bold=True))
+ (module_dst / "handling").mkdir(exist_ok=True)
+
+ static = module_dst / "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)
+
+ templates = module_dst / "templates"
+ templates.mkdir(exist_ok=True)
+
+ (module_dst / "routes.py").write_text(creator_templates.module_routes)
+ (templates / f"index.html").write_text(creator_templates.module_index)
+ (templates / f"vite_index.html").write_text(creator_templates.module_vite_index)
+ (css / "tailwind_raw.css").write_text(creator_templates.tailwind_raw)
+
+ typer.echo(typer.style(f"Setting up requirements...", bold=True))
+
+ required = []
+ for extension in creator_templates.extensions:
+ require = prompt_yes_no(f"Do you want to use {extension} in this module? (y/N) ")
+ 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)
+
+ (module_dst / "__init__.py").write_text(
+ creator_templates.module_init.format(
+ requirements=",\n\t\t".join(required)
+ )
+ )
+
+ typer.echo(typer.style(
+ f"Module '{module}' has been successfully created.",
+ fg=typer.colors.GREEN, bold=True
+ ))
+
+
def modules_entry(app: typer.Typer):
app.add_typer(modules, name="modules")
diff --git a/src/flaskpp/modules/creator_templates.py b/src/flaskpp/modules/creator_templates.py
new file mode 100644
index 0000000..80af0a1
--- /dev/null
+++ b/src/flaskpp/modules/creator_templates.py
@@ -0,0 +1,105 @@
+
+extensions = [
+ "sqlalchemy",
+ "socket",
+ "babel",
+ "fst",
+ "authlib",
+ "mailing",
+ "cache",
+ "api",
+ "jwt_extended"
+]
+
+module_init = """
+from flaskpp import Module
+
+module = Module(
+ __file__,
+ __name__,
+ [
+ {requirements}
+ ]
+)
+
+"""
+
+module_routes = """
+from flask import flash, redirect
+
+from flaskpp import Module
+from flaskpp.app.utils.auto_nav import autonav_route
+from flaskpp.app.utils.translating import t
+from flaskpp.utils import enabled
+
+
+def init_routes(mod: Module):
+ @mod.route("/")
+ def index():
+ return mod.render_template("index.html")
+
+ @autonav_route(mod, "/vite-index", t("Vite Test"))
+ def vite_index():
+ if not enabled("FRONTEND_ENGINE"):
+ flash("Vite is not enabled for this app.", "warning")
+ return redirect("/")
+ return mod.render_template("vite_index.html")
+
+"""
+
+module_index = """
+{% extends "base_example.html" %}
+{# The base template is natively provided by Flask++. #}
+
+{% block title %}{{ _('My Module') }}{% endblock %}
+{% block head %}{{ tailwind }}{% endblock %}
+
+{% block content %}
+
+
{{ _('Welcome!') }}
+
{{ _('This is my wonderful new module.') }}
+
+{% endblock %}
+"""
+
+module_vite_index = """
+{% extends "base_example.html" %}
+
+{% block title %}{{ _('Home') }}{% endblock %}
+{% block head %}{{ vite('main.js') }}{% endblock %}
+"""
+
+module_data_init = """
+from pathlib import Path
+from importlib import import_module
+
+_package = Path(__file__).parent
+
+
+def init_models():
+ from .. import module
+ for file in _package.rglob("*.py"):
+ if file.stem == "__init__":
+ continue
+ import_module(f"{module.import_name}.data.{file.stem}")
+
+"""
+
+tailwind_raw = """
+@import "tailwindcss";
+
+@source not "../../vite";
+
+@theme {
+ /* ... */
+}
+"""
+
+module_manifest = """
+{{
+ "name": "{name}",
+ "description": "{description}",
+ "version": "{version}",
+ "author": "{author}"
+}}
+"""
diff --git a/src/flaskpp/tailwind/__init__.py b/src/flaskpp/tailwind/__init__.py
new file mode 100644
index 0000000..606b268
--- /dev/null
+++ b/src/flaskpp/tailwind/__init__.py
@@ -0,0 +1,89 @@
+from flask import Flask
+from pathlib import Path
+from tqdm import tqdm
+import os, platform, typer, requests, subprocess
+
+home = Path(__file__).parent.resolve()
+tailwind_cli = {
+ "linux": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.17/tailwindcss-linux-{architecture}",
+ "win": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.17/tailwindcss-windows-x64.exe"
+}
+
+
+def _get_cli_data():
+ selector = "win" if os.name == "nt" else "linux"
+
+ machine = platform.machine().lower()
+ arch = "x64" if machine == "x86_64" or machine == "amd64" else "arm64"
+
+ if selector == "linux":
+ return tailwind_cli[selector].format(architecture=arch), selector
+ return tailwind_cli[selector], selector
+
+
+def _tailwind_cmd():
+ if os.name == "nt":
+ return str(home / "tailwind.exe")
+ return str(home / "tailwind")
+
+
+def generate_tailwind_css(app: Flask):
+ out = (home.parent / "app" / "static" / "css" / "tailwind.css")
+
+ if not out.exists():
+ result = subprocess.run(
+ [_tailwind_cmd(),
+ "-i", str(out.parent / "tailwind_raw.css"),
+ "-o", str(out), "--minify"],
+ cwd=home.parent
+ )
+ if result.returncode != 0:
+ raise TailwindError(f"Failed to generate {out}")
+
+ root = Path(app.root_path).resolve()
+
+ for d in root.rglob("static/css"):
+ in_file = d / "tailwind_raw.css"
+ if not in_file.exists():
+ continue
+
+ result = subprocess.run(
+ [_tailwind_cmd(),
+ "-i", str(in_file),
+ "-o", str(d / "tailwind.css"), "--minify"],
+ cwd=d
+ )
+ if result.returncode != 0:
+ raise TailwindError(f"Failed to generate {d / 'tailwind.css'}")
+
+
+def setup_tailwind():
+ data = _get_cli_data()
+ file_type = ".exe" if data[1] == "win" else ""
+ dest = home / f"tailwind{file_type}"
+
+ if dest.exists():
+ return
+
+ typer.echo(typer.style(f"Downloading {data[0]}...", bold=True))
+ with requests.get(data[0], stream=True) as r:
+ r.raise_for_status()
+ total = int(r.headers.get("content-length", 0))
+ with open(dest, "wb") as f, tqdm(
+ total=total, unit="B", unit_scale=True, desc=str(dest)
+ ) as bar:
+ for chunk in r.iter_content(chunk_size=8192):
+ f.write(chunk)
+ bar.update(len(chunk))
+
+ if not dest.exists():
+ raise TailwindError("Failed to load tailwind cli.")
+
+ if os.name != "nt":
+ os.system(f"chmod +x {str(dest)}")
+
+ typer.echo(typer.style(f"Tailwind successfully setup.", fg=typer.colors.GREEN, bold=True))
+
+
+class TailwindError(Exception):
+ pass
diff --git a/src/flaskpp/tailwind/cli.py b/src/flaskpp/tailwind/cli.py
new file mode 100644
index 0000000..dd24d51
--- /dev/null
+++ b/src/flaskpp/tailwind/cli.py
@@ -0,0 +1,33 @@
+import typer, subprocess, os
+
+from flaskpp.tailwind import _tailwind_cmd, TailwindError
+
+tailwind = typer.Typer(
+ help="Use the tailwind cli using the standalone Flask++ integration."
+)
+
+
+@tailwind.callback(
+ invoke_without_command=True,
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
+)
+def main(
+ ctx: typer.Context
+):
+ args = ctx.args
+
+ result = subprocess.run(
+ [_tailwind_cmd(), *args],
+ cwd=os.getcwd(),
+ capture_output=True,
+ text=True,
+ )
+
+ if result.returncode != 0:
+ raise TailwindError(result.stderr)
+
+ typer.echo(result.stdout)
+
+
+def tailwind_entry(app: typer.Typer):
+ app.add_typer(tailwind, name="tailwind")
diff --git a/src/flaskpp/tests/__init__.py b/src/flaskpp/tests/__init__.py
new file mode 100644
index 0000000..2a84d59
--- /dev/null
+++ b/src/flaskpp/tests/__init__.py
@@ -0,0 +1,49 @@
+
+test_config = """
+[core]
+SERVER_NAME = test.local
+SECRET_KEY = supersecret
+
+[database]
+DATABASE_URL = sqlite:///appdata.db
+
+[redis]
+REDIS_URL = redis://redis:6379
+
+[babel]
+SUPPORTED_LOCALES = en;de
+
+[security]
+SECURITY_PASSWORD_SALT = supersecret
+
+[mail]
+MAIL_SERVER =
+MAIL_PORT = 25
+MAIL_USE_TLS = True
+MAIL_USE_SSL = False
+MAIL_USERNAME =
+MAIL_PASSWORD =
+MAIL_DEFAULT_SENDER = noreply@example.com
+
+[jwt]
+JWT_SECRET_KEY = supersecret
+
+[extensions]
+EXT_SQLALCHEMY = 1
+EXT_SOCKET = 0
+EXT_BABEL = 0
+EXT_FST = 0
+EXT_AUTHLIB = 0
+EXT_MAILING = 0
+EXT_CACHE = 0
+EXT_API = 0
+EXT_JWT_EXTENDED = 0
+
+[features]
+FPP_PROCESSING = 1
+
+[dev]
+DB_AUTOUPDATE = 0
+
+[modules]
+"""
diff --git a/src/flaskpp/tests/cli.py b/src/flaskpp/tests/cli.py
new file mode 100644
index 0000000..e7b4d6a
--- /dev/null
+++ b/src/flaskpp/tests/cli.py
@@ -0,0 +1,8 @@
+import typer
+
+tests = typer.Typer(help="Test your Flask++ apps and modules.")
+
+# TODO: Implement test cli features later.
+
+def tests_entry(app: typer.Typer):
+ app.add_typer(tests, name="test")
diff --git a/src/flaskpp/utils/__init__.py b/src/flaskpp/utils/__init__.py
index e08abf5..1be1cc2 100644
--- a/src/flaskpp/utils/__init__.py
+++ b/src/flaskpp/utils/__init__.py
@@ -1,9 +1,30 @@
-import os, string, random
+import os, string, random, socket
def random_code(length: int = 6) -> str:
return ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(length))
+def prompt_yes_no(question: str) -> bool:
+ answer = input(question).lower().strip()
+ if answer in ('y', 'yes', '1'):
+ return True
+ return False
+
+
def enabled(key: str) -> bool:
return os.getenv(key, "false").lower() in ["true", "1", "yes"]
+
+
+def is_port_free(port, host="127.0.0.1") -> bool:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(0.5)
+ try:
+ s.bind((host, port))
+ return True
+ except OSError:
+ return False
+
+
+def sanitize_text(value: str) -> str:
+ return value.encode("utf-8", "ignore").decode("utf-8")
diff --git a/src/flaskpp/utils/run.py b/src/flaskpp/utils/run.py
index b4334b7..4ded72f 100755
--- a/src/flaskpp/utils/run.py
+++ b/src/flaskpp/utils/run.py
@@ -4,7 +4,9 @@
import subprocess, sys, os, signal
import typer
-root_path = Path(os.getcwd())
+from flaskpp.utils import prompt_yes_no
+
+root_path = Path.cwd()
conf_path = root_path / "app_configs"
logs_path = root_path / "logs"
@@ -49,11 +51,6 @@ def _prompt_port(app_name: str, suggested: int) -> tuple[int, int]:
return port, next_default
-def _promt_debug() -> str:
- choice = input("Start app in debug mode? (y/N): ").strip().lower()
- return "1" if choice in {"y", "yes", "1"} else "0"
-
-
def start_app(conf_file: Path, default_port: int, reload: bool = False) -> int:
app_name = conf_file.stem
base_env = _env_from_conf(conf_file)
@@ -66,7 +63,7 @@ def start_app(conf_file: Path, default_port: int, reload: bool = False) -> int:
else:
if args["interactive"]:
port, next_default = _prompt_port(app_name, default_port)
- debug = args["debug"] or _promt_debug()
+ debug = args["debug"] or prompt_yes_no("Start app in debug mode? (y/N): ")
else:
port, next_default = default_port, None
debug = args["debug"]
@@ -136,7 +133,7 @@ def shutdown(signum=None, frame=None):
prefix = "S"
if signum:
prefix = f"Handling signal SIG{'INT' if signum == signal.SIGINT else 'TERM'}, s"
- typer.echo(typer.style(f"\n{prefix}hutting down...", fg=typer.colors.MAGENTA, bold=True))
+ typer.echo(typer.style(f"\n{prefix}hutting down...", fg=typer.colors.YELLOW, bold=True))
for app in apps:
stop_app(app)
typer.echo(typer.style("Thank you for playing the game of life... Bye!", bold=True))
@@ -190,7 +187,7 @@ def interactive_main():
shutdown()
sys.exit(0)
if cmd not in {"1", "2", "3", "4"}:
- typer.echo(typer.style("Invalid option.", fg=typer.colors.YELLOW, bold=True))
+ typer.echo(typer.style("Invalid option.", fg=typer.colors.RED, bold=True))
continue
if not choices:
typer.echo(typer.style(
diff --git a/src/flaskpp/utils/service_registry.py b/src/flaskpp/utils/service_registry.py
index f875ccc..1b9e7ff 100644
--- a/src/flaskpp/utils/service_registry.py
+++ b/src/flaskpp/utils/service_registry.py
@@ -1,10 +1,8 @@
from pathlib import Path
-from colorama import Fore, Style
-import typer, os, sys, ctypes
+import typer, os, sys, ctypes, subprocess, shlex
-home = Path(os.getcwd())
+home = Path.cwd().resolve()
service_path = home / "services"
-service_path.mkdir(exist_ok=True)
registry = typer.Typer(help="Manage OS-level services for Flask++ apps.")
@@ -18,14 +16,17 @@ def _ensure_admin() -> bool:
return os.geteuid() == 0
-def _service_file(app: str):
+def service_file(app: str):
return service_path / (f"{app}.py" if os.name == "nt" else f"{app}.service")
def create_service(app_name: str, port: int, debug: bool):
- entry = f"{sys.executable} -m flaskpp run --app {app_name} --port {port} {'--debug' if debug else ''}"
+ args = [sys.executable, "-m", "flaskpp", "run", "--app", app_name, "--port", str(port)]
+ if debug:
+ args.append("--debug")
if os.name == "nt":
+ entry_list = ", ".join(repr(a) for a in args)
template = f"""
import win32serviceutil, win32service, win32event, servicemanager, subprocess, time
@@ -50,7 +51,10 @@ def SvcDoRun(self):
self.main_loop()
def main_loop(self):
- proc = subprocess.Popen(["cmd", "/C", "{entry}"])
+ proc = subprocess.Popen(
+ [{entry_list}],
+ cwd=r"{str(home)}"
+ )
while self.alive:
if proc.poll() is not None:
raise RuntimeError("Service execution failed.")
@@ -60,31 +64,38 @@ def main_loop(self):
if __name__ == "__main__":
win32serviceutil.HandleCommandLine(AppService)
"""
- out = _service_file(app_name)
+ out = service_file(app_name)
out.write_text(template)
else:
+ exec_start = " ".join(shlex.quote(a) for a in args)
+ user = str(home).split("/")[1] if str(home).startswith("/home/") else "root"
template = f"""
[Unit]
Description={app_name} Service
After=network.target
[Service]
-ExecStart={entry}
+User={user}
+ExecStart={exec_start}
+WorkingDirectory={str(home)}
Type=simple
Restart=on-failure
[Install]
WantedBy=multi-user.target
"""
- out = _service_file(app_name)
+ out = service_file(app_name)
out.write_text(template)
- os.system(f"ln -sf {out} /etc/systemd/system/{app_name}.service")
+ target = Path(f"/etc/systemd/system/{app_name}.service")
+ if target.is_symlink() or target.exists():
+ target.unlink()
+ target.symlink_to(out)
@registry.command()
-def register(app: str,
- port: int = typer.Option(...),
+def register(app: str = typer.Option(..., "--app", "-a"),
+ port: int = typer.Option(5000, "--port", "-p"),
debug: bool = typer.Option(False, "--debug", "-d")):
if not _ensure_admin():
typer.echo(typer.style(
@@ -93,16 +104,23 @@ def register(app: str,
))
raise typer.Exit(1)
+ if not (app and (home / "app_configs" / f"{app}.conf").exists()):
+ typer.echo(typer.style(
+ "You must specify a valid app to register it.",
+ fg=typer.colors.RED, bold=True
+ ))
+ raise typer.Exit(1)
+
create_service(app, port, debug)
if os.name == "nt":
- f = _service_file(app)
- os.system(f"{sys.executable} {f} install")
- os.system(f"{sys.executable} {f} start")
+ f = service_file(app)
+ subprocess.run([sys.executable, str(f), "install"], check=False)
+ subprocess.run([sys.executable, str(f), "start"], check=False)
else:
- os.system("systemctl daemon-reload")
- os.system(f"systemctl enable {app}")
- os.system(f"systemctl start {app}")
+ subprocess.run(["systemctl", "daemon-reload"], check=False)
+ subprocess.run(["systemctl", "enable", app], check=False)
+ subprocess.run(["systemctl", "start", app], check=False)
typer.echo(typer.style(f"Service {app} registered.", fg=typer.colors.GREEN, bold=True))
@@ -110,10 +128,10 @@ def register(app: str,
@registry.command()
def start(app: str):
if os.name == "nt":
- f = _service_file(app)
- os.system(f"{sys.executable} {f} start")
+ f = service_file(app)
+ subprocess.run([sys.executable, str(f), "start"], check=False)
else:
- os.system(f"systemctl start {app}")
+ subprocess.run(["systemctl", "start", app], check=False)
typer.echo(typer.style(f"Service {app} started.", fg=typer.colors.GREEN, bold=True))
@@ -121,10 +139,10 @@ def start(app: str):
@registry.command()
def stop(app: str):
if os.name == "nt":
- f = _service_file(app)
- os.system(f"{sys.executable} {f} stop")
+ f = service_file(app)
+ subprocess.run([sys.executable, str(f), "stop"], check=False)
else:
- os.system(f"systemctl stop {app}")
+ subprocess.run(["systemctl", "stop", app], check=False)
typer.echo(typer.style(f"Service {app} stopped.", fg=typer.colors.YELLOW, bold=True))
@@ -139,15 +157,15 @@ def remove(app: str):
raise typer.Exit(1)
if os.name == "nt":
- f = _service_file(app)
- os.system(f"{sys.executable} {f} stop")
- os.system(f"{sys.executable} {f} remove")
+ f = service_file(app)
+ subprocess.run([sys.executable, str(f), "stop"], check=False)
+ subprocess.run([sys.executable, str(f), "remove"], check=False)
f.unlink(missing_ok=True)
else:
- os.system(f"systemctl stop {app}")
- os.system(f"systemctl disable {app}")
- (_service_file(app)).unlink(missing_ok=True)
- os.system("systemctl daemon-reload")
+ subprocess.run(["systemctl", "stop", app], check=False)
+ subprocess.run(["systemctl", "disable", app], check=False)
+ service_file(app).unlink(missing_ok=True)
+ subprocess.run(["systemctl", "daemon-reload"], check=False)
typer.echo(typer.style(f"Service {app} removed.", fg=typer.colors.RED, bold=True))
diff --git a/src/flaskpp/utils/setup.py b/src/flaskpp/utils/setup.py
index 13baccd..25ab3cd 100644
--- a/src/flaskpp/utils/setup.py
+++ b/src/flaskpp/utils/setup.py
@@ -3,7 +3,8 @@
from configparser import ConfigParser
import typer, os
-from ..modules import generate_modlib
+from flaskpp.utils import prompt_yes_no
+from flaskpp.modules import generate_modlib
counting_map = {
1: "st",
@@ -11,8 +12,7 @@
3: "rd"
}
-conf_path = Path(os.getcwd()) / "app_configs"
-conf_path.mkdir(exist_ok=True)
+conf_path = Path.cwd() / "app_configs"
def base_config():
@@ -54,7 +54,7 @@ def base_config():
"extensions": {
"default_EXT_SQLALCHEMY": 1,
- "EXT_SOCKET": 0,
+ "default_EXT_SOCKET": 1,
"EXT_BABEL": 0,
"EXT_FST": 0,
"EXT_AUTHLIB": 0,
@@ -65,7 +65,8 @@ def base_config():
},
"features": {
- "FPP_PROCESSING": 0,
+ "default_FPP_PROCESSING": 1,
+ "default_FRONTEND_ENGINE": 1,
},
"dev": {
@@ -81,7 +82,7 @@ def welcome():
typer.echo("Thank your for using our little foundation to build")
typer.echo("your new app! We will try our best to get you ready")
typer.echo("within the next two minutes. π Start a timer! ;)\n")
- typer.echo(" " +
+ typer.echo(" " +
typer.style("~ GrowVolution 2025 - MIT License ~", fg=typer.colors.CYAN, bold=True) +
"\n")
typer.echo("---------------------------------------------------")
@@ -141,20 +142,19 @@ def setup_app(app_number):
fg=typer.colors.GREEN, bold=True
) + "\n")
- register_q = input(typer.style(
+ 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").lower().strip()
+ ) + "\n")
- if register_q in ["yes", "y", "1"]:
+ if register_app:
from .service_registry import register
try:
port_input = input("On which port do you want your service to run? (5000): ").strip()
port = int(port_input) if port_input else 5000
except ValueError:
port = 5000
- debug = (input("Do you want your service to run in debug mode? (y/N): ")
- .lower().strip() in ["yes", "y", "1"])
+ debug = prompt_yes_no("Do you want your service to run in debug mode? (y/N): ")
register(app, port, debug)
@@ -173,8 +173,7 @@ def setup():
setup_app(i)
i += 1
- while (input(f"Do you want to create a {i}{counting_map.get(i, 'th')} app? (y/N): ")
- .lower().strip()) in ["yes", "y", "1"]:
+ while prompt_yes_no(f"Do you want to create a {i}{counting_map.get(i, 'th')} app? (y/N): "):
setup_app(i)
i += 1
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..c5ea76e
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,65 @@
+from unittest.mock import patch
+
+from flaskpp import FlaskPP
+from flaskpp.app.config.default import DefaultConfig
+
+
+@patch("flaskpp.generate_tailwind_css")
+@patch("flaskpp.register_modules")
+@patch("flaskpp.init_i18n")
+@patch("flaskpp.handlers")
+def test_flaskpp_basic_init(mock_handlers, mock_i18n, mock_register, mock_generate):
+ mock_handlers.__getitem__.return_value = lambda *a, **k: None
+
+ with patch("flaskpp.enabled", return_value=False):
+ app = FlaskPP(__name__, "DEFAULT")
+
+ assert isinstance(app.config, dict)
+ assert isinstance(app.config["DEBUG"], bool)
+ assert app.blueprints.get("fpp_default") is not None
+
+ mock_i18n.assert_called_once()
+ mock_register.assert_called_once()
+ mock_generate.assert_called_once()
+
+
+@patch("flaskpp.generate_tailwind_css")
+@patch("flaskpp.register_modules")
+@patch("flaskpp.init_i18n")
+def test_flaskpp_proxy_fix(mock_i18n, mock_register, mock_generate):
+ class C(DefaultConfig):
+ PROXY_FIX = True
+ PROXY_COUNT = 1
+
+ with patch("flaskpp.CONFIG_MAP", {"X": C}):
+ with patch("flaskpp.enabled", return_value=False):
+ app = FlaskPP(__name__, "X")
+
+ assert hasattr(app, "wsgi_app")
+
+
+@patch("flaskpp.generate_tailwind_css")
+@patch("flaskpp.register_modules")
+@patch("flaskpp.init_i18n")
+@patch("flaskpp.handlers")
+def test_flaskpp_processing_handlers(mock_handlers, mock_i18n, mock_register, mock_generate):
+ mock_handlers.__getitem__.return_value = lambda *a, **k: None
+
+ def enabled_mock(key):
+ return key == "FPP_PROCESSING"
+
+ with patch("flaskpp.enabled", enabled_mock):
+ app = FlaskPP(__name__, "DEFAULT")
+
+ assert mock_handlers.__getitem__.call_count >= 3
+
+
+@patch("flaskpp.generate_tailwind_css")
+@patch("flaskpp.register_modules")
+@patch("flaskpp.init_i18n")
+def test_flaskpp_asgi(mock_i18n, mock_register, mock_generate):
+ with patch("flaskpp.enabled", return_value=False):
+ app = FlaskPP(__name__, "DEFAULT")
+
+ asgi = app.to_asgi()
+ assert hasattr(asgi, "__call__")
diff --git a/tests/test_cli/__init__.py b/tests/test_cli/__init__.py
new file mode 100644
index 0000000..2e974d0
--- /dev/null
+++ b/tests/test_cli/__init__.py
@@ -0,0 +1,69 @@
+from typer.testing import CliRunner
+from importlib.metadata import version
+from unittest.mock import patch
+from pathlib import Path
+
+from flaskpp.cli import app
+from flaskpp.tests import test_config
+
+runner = CliRunner()
+
+
+def test_fpp_version():
+ result = runner.invoke(app, ["--version"])
+ assert result.exit_code == 0
+ assert version("flaskpp") in result.stdout
+
+
+def test_fpp_help():
+ result = runner.invoke(app, ["--help"])
+ assert result.exit_code == 0
+ assert "Usage" in result.stdout
+
+
+@patch("flaskpp.cli.setup_tailwind")
+@patch("flaskpp.cli.load_node")
+@patch("flaskpp.cli.prepare_vite")
+@patch("flaskpp.cli.subprocess.run")
+def test_fpp_init(mock_run, mock_prepare, mock_load, mock_tailwind):
+ with runner.isolated_filesystem():
+ result = runner.invoke(app, ["init"])
+ assert result.exit_code == 0
+ assert mock_run.call_count == 3
+ assert "Flask++ project successfully initialized." in result.stdout
+
+ mock_prepare.assert_called_once()
+ mock_load.assert_called_once()
+ mock_tailwind.assert_called_once()
+
+
+@patch("flaskpp.utils.setup.prompt_yes_no")
+@patch("flaskpp.utils.setup.input")
+def test_fpp_setup(mock_input, mock_prompt):
+ mock_input.return_value = ""
+ mock_prompt.return_value = False
+
+ with runner.isolated_filesystem():
+ result = runner.invoke(app, ["setup"])
+
+ assert result.exit_code == 0
+ assert "Setup complete." in result.stdout
+ assert mock_input.call_count > 1
+ assert mock_prompt.call_count > 1
+
+
+@patch("flaskpp.utils.run.subprocess.Popen")
+def test_fpp_run(mock_popen):
+ with runner.isolated_filesystem():
+ Path("app_configs").mkdir()
+
+ Path("app_configs/test.conf").write_text(test_config)
+
+ result = runner.invoke(
+ app,
+ ["run", "-a", "test", "-p", "80", "-d"]
+ )
+
+ mock_popen.assert_called_once()
+ assert result.exit_code == 0
+ assert "running on http://0.0.0.0:80" in result.stdout
diff --git a/tests/test_cli/modules.py b/tests/test_cli/modules.py
new file mode 100644
index 0000000..b179c16
--- /dev/null
+++ b/tests/test_cli/modules.py
@@ -0,0 +1,44 @@
+from unittest.mock import patch
+from pathlib import Path
+
+from flaskpp.cli import app
+from . import runner
+
+
+@patch("flaskpp.modules.cli.module_home", new_callable=lambda: Path("modules"))
+@patch("flaskpp.modules.cli.Repo.clone_from")
+def test_modules_install(mock_clone, mock_home):
+ with runner.isolated_filesystem():
+ local_mod = Path("example_module")
+ local_mod.mkdir(parents=True)
+
+ result = runner.invoke(
+ app,
+ ["modules", "install", "example", "--src", "example_module"]
+ )
+ assert result.exit_code == 0
+ assert "Loading module from local path..." in result.stdout
+ assert mock_clone.call_count == 0
+
+ result = runner.invoke(
+ app,
+ ["modules", "install", "i18n", "--src", "https://github.com/GrowVolution/FPP_i18n_module"]
+ )
+ assert result.exit_code == 0
+ assert "Loading module from remote repository..." in result.stdout
+
+ mock_clone.assert_called_once()
+
+
+@patch("flaskpp.modules.cli.module_home", new_callable=lambda: Path("modules"))
+@patch("flaskpp.modules.cli.prompt_yes_no")
+def test_modules_create(mock_prompt, mock_home):
+ mock_prompt.return_value = False
+
+ with runner.isolated_filesystem():
+ result = runner.invoke(app, ["modules", "create", "test"])
+
+ assert result.exit_code == 0
+ assert "successfully created" in result.stdout.lower()
+ assert mock_prompt.called
+ assert Path("modules/test").exists()
diff --git a/tests/test_cli/registry.py b/tests/test_cli/registry.py
new file mode 100644
index 0000000..64c5f0a
--- /dev/null
+++ b/tests/test_cli/registry.py
@@ -0,0 +1,75 @@
+from unittest.mock import patch
+from pathlib import Path
+
+from flaskpp.cli import app
+from flaskpp.utils.service_registry import service_file
+from . import runner
+
+
+@patch("flaskpp.utils.service_registry._ensure_admin", return_value=True)
+@patch("flaskpp.utils.service_registry.subprocess.run")
+def test_registry_register(mock_run, mock_admin):
+ with runner.isolated_filesystem():
+ with patch("flaskpp.utils.service_registry.home", Path.cwd()):
+ with patch("flaskpp.utils.service_registry.service_path", Path("services")):
+ Path("services").mkdir()
+
+ result = runner.invoke(
+ app,
+ ["registry", "register", "testapp", "--port", "5000", "--debug"]
+ )
+ assert result.exit_code == 0
+ assert "Service testapp registered." in result.stdout
+ assert mock_run.call_count >= 3
+ sf = service_file("testapp")
+ assert sf.exists()
+
+
+@patch("flaskpp.utils.service_registry.subprocess.run")
+def test_registry_start(mock_run):
+ with runner.isolated_filesystem():
+ with patch("flaskpp.utils.service_registry.home", Path.cwd()):
+ with patch("flaskpp.utils.service_registry.service_path", Path("services")):
+ Path("services").mkdir()
+ result = runner.invoke(
+ app,
+ ["registry", "start", "testapp"]
+ )
+ assert result.exit_code == 0
+ assert "Service testapp started." in result.stdout
+ mock_run.assert_called_once()
+
+
+@patch("flaskpp.utils.service_registry.subprocess.run")
+def test_registry_stop(mock_run):
+ with runner.isolated_filesystem():
+ with patch("flaskpp.utils.service_registry.home", Path.cwd()):
+ with patch("flaskpp.utils.service_registry.service_path", Path("services")):
+ Path("services").mkdir()
+ result = runner.invoke(
+ app,
+ ["registry", "stop", "testapp"]
+ )
+ assert result.exit_code == 0
+ assert "Service testapp stopped." in result.stdout
+ mock_run.assert_called_once()
+
+
+@patch("flaskpp.utils.service_registry._ensure_admin", return_value=True)
+@patch("flaskpp.utils.service_registry.subprocess.run")
+def test_registry_remove(mock_run, mock_admin):
+ with runner.isolated_filesystem():
+ with patch("flaskpp.utils.service_registry.home", Path.cwd()):
+ with patch("flaskpp.utils.service_registry.service_path", Path("services")):
+ Path("services").mkdir()
+ sf = service_file("testapp")
+ sf.write_text("x")
+
+ result = runner.invoke(
+ app,
+ ["registry", "remove", "testapp"]
+ )
+ assert result.exit_code == 0
+ assert "Service testapp removed." in result.stdout
+ assert mock_run.call_count >= 2
+ assert not sf.exists()
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
new file mode 100644
index 0000000..75f0e56
--- /dev/null
+++ b/tests/test_i18n.py
@@ -0,0 +1,83 @@
+from flask import Flask
+from babel.support import Translations
+from unittest.mock import patch, MagicMock
+
+from flaskpp.app.i18n import (
+ DBMergedTranslations,
+ DBDomain,
+ init_i18n
+)
+
+
+class DummyTranslations(Translations):
+ def gettext(self, message):
+ return f"mo:{message}"
+
+ def ngettext(self, singular, plural, n):
+ return f"mo:{singular if n == 1 else plural}"
+
+
+@patch("flaskpp.app.i18n.I18nMessage")
+def test_dbmerged_gettext_db_hit(mock_model):
+ row = MagicMock()
+ row.text = "db-value"
+ mock_model.query.filter_by.return_value.first.return_value = row
+
+ wrapped = DummyTranslations()
+ dbt = DBMergedTranslations(wrapped, "messages", "en")
+
+ assert dbt.gettext("hello") == "db-value"
+
+
+@patch("flaskpp.app.i18n.I18nMessage")
+def test_dbmerged_gettext_no_db_fallback(mock_model):
+ mock_model.query.filter_by.return_value.first.return_value = None
+
+ wrapped = DummyTranslations()
+ dbt = DBMergedTranslations(wrapped, "messages", "en")
+
+ assert dbt.gettext("hello") == "mo:hello"
+
+
+@patch("flaskpp.app.i18n.I18nMessage")
+def test_dbmerged_ngettext_db_hit(mock_model):
+ row = MagicMock()
+ row.text = "db-plural"
+ mock_model.query.filter_by.return_value.first.return_value = row
+
+ wrapped = DummyTranslations()
+ dbt = DBMergedTranslations(wrapped, "messages", "en")
+
+ assert dbt.ngettext("one", "many", 2) == "db-plural"
+
+
+@patch("flaskpp.app.i18n.I18nMessage")
+def test_dbmerged_ngettext_fallback(mock_model):
+ mock_model.query.filter_by.return_value.first.return_value = None
+
+ wrapped = DummyTranslations()
+ dbt = DBMergedTranslations(wrapped, "messages", "en")
+
+ assert dbt.ngettext("one", "many", 2) == "mo:many"
+
+
+@patch("flaskpp.app.i18n.get_locale", return_value="en")
+@patch("flaskpp.app.i18n.Translations.load")
+def test_dbdomain_returns_dbmerged(mock_load, mock_locale):
+ mock_load.return_value = DummyTranslations()
+
+ domain = DBDomain(domain="messages")
+
+ app = Flask(__name__)
+ with app.app_context():
+ translations = domain.get_translations()
+
+ assert isinstance(translations, DBMergedTranslations)
+
+
+def test_init_i18n_registers_jinja_globals():
+ app = Flask(__name__)
+ init_i18n(app)
+
+ assert "_" in app.jinja_env.globals
+ assert "ngettext" in app.jinja_env.globals