Coverage for seedboxsync/__init__.py: 94%
89 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 17:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 17:14 +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 doughnut_legend_position = app.config.get(Config.CONFIG_NAMESPACE + "WEBUI_DOUGHNUT_LEGEND", "hidden")
115 doughnut_legend_display = doughnut_legend_position != "hidden"
116 doughnut_legend_position = doughnut_legend_position if doughnut_legend_position != "hidden" else "top"
117 doughnut_legend_limit = app.config.get(Config.CONFIG_NAMESPACE + "WEBUI_DOUGHNUT_LEGEND_LIMIT", "0")
118 doughnut_legend_limit = "999999" if doughnut_legend_limit == "0" else doughnut_legend_limit
119 return {
120 "api_version": api_version,
121 "lang": lang,
122 "locale": locale,
123 "seedboxsync_config": app.seedboxsync_config,
124 "theme": theme,
125 "version": version,
126 "doughnut_legend_position": doughnut_legend_position,
127 "doughnut_legend_display": doughnut_legend_display,
128 "doughnut_legend_limit": doughnut_legend_limit,
129 }
132def create_app(injected_config: dict[str, str | bool] | None = None) -> Flask:
133 """
134 Create and configure the SeedboxSync Flask application.
136 Args:
137 injected_config (dict[str, str] | None): Optional configuration overrides
138 used by tests.
140 Returns:
141 Flask: Configured application instance.
142 """
143 # Create and configure the app
144 app = Flask(
145 __name__,
146 template_folder="front/templates",
147 static_folder="front/static",
148 instance_relative_config=True,
149 )
151 # ══════════════════════════════════════════════════════════════════════════════
152 # ⚙️ INIT
153 # ══════════════════════════════════════════════════════════════════════════════
154 # Configure logger for Flask and Click
155 logger.configure_logger(app.logger)
157 # Load test config
158 if injected_config is not None:
159 app.config.from_mapping(injected_config) # load the test config if passed in
161 # Initialize the database
162 database = Database(app)
163 app.extensions["database"] = database
165 # Load config
166 Config(app)
168 # Initialize Babel
169 app.config["BABEL_TRANSLATION_DIRECTORIES"] = "front/translations"
170 babel.init_app(app, locale_selector=get_locale)
172 # Initialize the cache
173 cache.init_app(app)
175 # Initialize the login manager and OAuth
176 login_manager.init_app(app)
177 init_oauth2(app)
179 # Register jinja filter
180 app.jinja_env.filters["slugify"] = slugify
182 # Register blueprint and error handler
183 app.register_blueprint(bp_auth)
184 app.register_blueprint(bp_frontend)
185 app.register_blueprint(bp_settings)
186 register_api_blueprint(app)
187 app.register_error_handler(Exception, __handle_http_exception) # type: ignore[arg-type]
189 # Set up ProxyFix middleware to handle reverse proxy headers
190 app.wsgi_app = ProxyFix( # type: ignore[method-assign]
191 app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1
192 )
194 # ══════════════════════════════════════════════════════════════════════════════
195 # ⚙️ FUNCTIONS
196 # ══════════════════════════════════════════════════════════════════════════════
197 # Routes
198 @app.route("/favicon.ico")
199 def favicon() -> Response: # pyright: ignore [reportUnusedFunction]
200 """Serve the favicon from the static directory."""
201 return send_from_directory(Path(app.root_path, "front/static"), "favicon.png", mimetype="image/png")
203 # Before Request
204 @app.before_request
205 def init_once() -> None: # pyright: ignore [reportUnusedFunction]
206 """Initialize humanize for each request."""
207 humanize_i18n.activate(get_locale())
209 @app.before_request
210 def check_init_error() -> None: # pyright: ignore [reportUnusedFunction]
211 """Display initialization errors as flash messages if any."""
212 init_error = app.config.pop("INIT_ERROR", None)
213 if init_error:
214 flash(init_error, "danger")
216 # Context processor
217 @app.context_processor
218 def inject_formatters() -> dict[str, Callable[[datetime], str]]: # pyright: ignore [reportUnusedFunction]
219 """Inject custom formatters into the template context."""
220 return {"format_datetime": format_datetime}
222 @app.context_processor
223 def inject_globals() -> dict[str, Any]: # pyright: ignore [reportUnusedFunction]
224 """Inject global variables into the template context."""
225 return __inject_globals(app)
227 # Template global
228 @app.template_global()
229 def gravatar(email: str) -> str: # pyright: ignore [reportUnusedFunction]
230 """Return the Gravatar image URL for an email address."""
231 return __gravatar(email)
233 @app.template_global()
234 def get_toasted_messages(with_categories: bool = False, category_filter: Iterable[str] = ()) -> list[str] | list[tuple[str, str]]:
235 """Like a flash but for toast."""
236 return __get_toasted_messages(with_categories, category_filter)
238 return app