Coverage for seedboxsync/__init__.py: 94%
86 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 07:37 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 07:37 +0000
1#
2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr>
3#
4# For the full copyright and license information, please view the LICENSE
5# file that was distributed with this source code.
6#
7"""The SeedboxSync main package."""
9from collections.abc import Callable, Iterable
10from datetime import datetime
11from pathlib import Path
12from typing import Any
13from flask import Response, flash, g, request, send_from_directory, session
14from flask_babel import format_datetime, get_locale as get_babel_locale
15from humanize import i18n as humanize_i18n
16from libgravatar import Gravatar
17from slugify import slugify
18from werkzeug.middleware.proxy_fix import ProxyFix
19from seedboxsync.__version__ import (
20 __api_path_version__ as api_path_version,
21 __api_version__ as api_version,
22 __version__ as version,
23)
24from seedboxsync.core import Config, Database, Flask, logger
25from seedboxsync.front.apis import register_api_blueprint
26from seedboxsync.front.apis.core import error as error_api
27from seedboxsync.front.babel import babel, get_locale
28from seedboxsync.front.cache import cache
29from seedboxsync.front.login_manager import login_manager
30from seedboxsync.front.oauth2 import init_oauth2
31from seedboxsync.front.views import bp_auth, bp_frontend, bp_settings, error as error_front
33__version__ = version
36def __handle_http_exception(
37 e: Exception,
38) -> tuple[Response, int | None] | tuple[str, int | None]:
39 """
40 Global 404 handler.
42 Args:
43 e (Exception): Exception raised while processing the request.
45 Returns:
46 tuple[Response, int | None] | tuple[str, int | None]: JSON for /api routes, else return frontend template.
47 """
48 if request.path.startswith(f"/api/{api_path_version}") or request.blueprint == "api":
49 return error_api.error(e)
50 return error_front.error(e)
53def __gravatar(email: str) -> str:
54 """
55 Generate the Gravatar image URL for a given email address.
57 Args:
58 email (str): The target user's email address.
60 Returns:
61 str: The fully qualified URL pointing to the user's Gravatar profile image.
62 """
63 return str(Gravatar(email).get_image())
66def __get_toasted_messages(with_categories: bool = False, category_filter: Iterable[str] = ()) -> list[str] | list[tuple[str, str]]:
67 """
68 Retrieve and clear pending toast notifications from the Flask session.
70 Acts as a specialized alternative to Flask's ``get_flashed_messages()`` specifically
71 tailored for toast notifications, pulling messages from ``g`` or removing them
72 from the user session[cite: 1, 5].
74 Args:
75 with_categories (bool, optional): If True, returns tuples of ``(category, message)``.
76 If False, returns only the message strings. Defaults to False.
77 category_filter (Iterable[str], optional): An iterable of category names used to
78 filter the returned toasts[cite: 1]. Defaults to ().
80 Returns:
81 list[str] | list[tuple[str, str]]: A list of toast message strings if ``with_categories``
82 is False, or a list of ``(category, message)`` tuples if ``with_categories`` is True.
83 """
84 toasts = getattr(g, "_toasts", None)
85 if toasts is None:
86 toasts = g._toasts = session.pop("_toasts", [])
87 if category_filter:
88 toasts = [toast for toast in toasts if toast[0] in category_filter]
90 if with_categories:
91 return toasts
93 return [toast[1] for toast in toasts]
96def __inject_globals(app: Flask) -> dict[str, Any]: # pyright: ignore [reportUnusedFunction]
97 """
98 Build the global template context variables for the Flask application.
100 Resolves the current locale and language, retrieves the configured web UI
101 theme, and exposes application metadata and configuration to all templates.
103 Args:
104 app: Flask application instance used to retrieve configuration and
105 application-specific settings.
107 Returns:
108 A dictionary containing the API version, current language and locale,
109 SeedboxSync configuration, selected UI theme, and application version.
110 """
111 locale = str(get_babel_locale() or app.config.get("BABEL_DEFAULT_LOCALE", "en_US"))
112 lang = locale.split("_")[0].split("-")[0]
113 theme = app.config.get(Config.CONFIG_NAMESPACE + "WEBUI_THEME", "auto")
114 stats_bar_y_limit = app.config.get(Config.CONFIG_NAMESPACE + "WEBUI_STATS_BAR_Y_LIMIT", "10")
115 stats_bar_y_limit = "999999" if stats_bar_y_limit == "0" else stats_bar_y_limit
116 return {
117 "api_version": api_version,
118 "lang": lang,
119 "locale": locale,
120 "seedboxsync_config": app.seedboxsync_config,
121 "theme": theme,
122 "version": version,
123 "stats_bar_y_limit": stats_bar_y_limit,
124 }
127def create_app(injected_config: dict[str, str | bool] | None = None) -> Flask:
128 """
129 Create and configure the SeedboxSync Flask application.
131 Args:
132 injected_config (dict[str, str] | None): Optional configuration overrides
133 used by tests.
135 Returns:
136 Flask: Configured application instance.
137 """
138 # Create and configure the app
139 app = Flask(
140 __name__,
141 template_folder="front/templates",
142 static_folder="front/static",
143 instance_relative_config=True,
144 )
146 # ══════════════════════════════════════════════════════════════════════════════
147 # ⚙️ INIT
148 # ══════════════════════════════════════════════════════════════════════════════
149 # Configure logger for Flask and Click
150 logger.configure_logger(app.logger)
152 # Load test config
153 if injected_config is not None:
154 app.config.from_mapping(injected_config) # load the test config if passed in
156 # Initialize the database
157 database = Database(app)
158 app.extensions["database"] = database
160 # Load config
161 Config(app)
163 # Initialize Babel
164 app.config["BABEL_TRANSLATION_DIRECTORIES"] = "front/translations"
165 babel.init_app(app, locale_selector=get_locale)
167 # Initialize the cache
168 cache.init_app(app)
170 # Initialize the login manager and OAuth
171 login_manager.init_app(app)
172 init_oauth2(app)
174 # Register jinja filter
175 app.jinja_env.filters["slugify"] = slugify
177 # Register blueprint and error handler
178 app.register_blueprint(bp_auth)
179 app.register_blueprint(bp_frontend)
180 app.register_blueprint(bp_settings)
181 register_api_blueprint(app)
182 app.register_error_handler(Exception, __handle_http_exception) # type: ignore[arg-type]
184 # Set up ProxyFix middleware to handle reverse proxy headers
185 app.wsgi_app = ProxyFix( # type: ignore[method-assign]
186 app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1
187 )
189 # ══════════════════════════════════════════════════════════════════════════════
190 # ⚙️ FUNCTIONS
191 # ══════════════════════════════════════════════════════════════════════════════
192 # Routes
193 @app.route("/favicon.ico")
194 def favicon() -> Response: # pyright: ignore [reportUnusedFunction]
195 """Serve the favicon from the static directory."""
196 return send_from_directory(Path(app.root_path, "front/static"), "favicon.png", mimetype="image/png")
198 # Before Request
199 @app.before_request
200 def init_once() -> None: # pyright: ignore [reportUnusedFunction]
201 """Initialize humanize for each request."""
202 humanize_i18n.activate(get_locale())
204 @app.before_request
205 def check_init_error() -> None: # pyright: ignore [reportUnusedFunction]
206 """Display initialization errors as flash messages if any."""
207 init_error = app.config.pop("INIT_ERROR", None)
208 if init_error:
209 flash(init_error, "danger")
211 # Context processor
212 @app.context_processor
213 def inject_formatters() -> dict[str, Callable[[datetime], str]]: # pyright: ignore [reportUnusedFunction]
214 """Inject custom formatters into the template context."""
215 return {"format_datetime": format_datetime}
217 @app.context_processor
218 def inject_globals() -> dict[str, Any]: # pyright: ignore [reportUnusedFunction]
219 """Inject global variables into the template context."""
220 return __inject_globals(app)
222 # Template global
223 @app.template_global()
224 def gravatar(email: str) -> str: # pyright: ignore [reportUnusedFunction]
225 """Return the Gravatar image URL for an email address."""
226 return __gravatar(email)
228 @app.template_global()
229 def get_toasted_messages(with_categories: bool = False, category_filter: Iterable[str] = ()) -> list[str] | list[tuple[str, str]]:
230 """Like a flash but for toast."""
231 return __get_toasted_messages(with_categories, category_filter)
233 return app