Coverage for seedboxsync/__init__.py: 94%
87 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 17:10 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 17:10 +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_admin, bp_auth, bp_frontend, 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
159 logger.add_database_log_handler(app.logger) # Ad DB log handler
161 # Load config
162 Config(app)
164 # Initialize Babel
165 app.config["BABEL_TRANSLATION_DIRECTORIES"] = "front/translations"
166 babel.init_app(app, locale_selector=get_locale)
168 # Initialize the cache
169 cache.init_app(app)
171 # Initialize the login manager and OAuth
172 login_manager.init_app(app)
173 init_oauth2(app)
175 # Register jinja filter
176 app.jinja_env.filters["slugify"] = slugify
178 # Register blueprint and error handler
179 app.register_blueprint(bp_auth)
180 app.register_blueprint(bp_frontend)
181 app.register_blueprint(bp_admin)
182 register_api_blueprint(app)
183 app.register_error_handler(Exception, __handle_http_exception) # type: ignore[arg-type]
185 # Set up ProxyFix middleware to handle reverse proxy headers
186 app.wsgi_app = ProxyFix( # type: ignore[method-assign]
187 app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1
188 )
190 # ══════════════════════════════════════════════════════════════════════════════
191 # ⚙️ FUNCTIONS
192 # ══════════════════════════════════════════════════════════════════════════════
193 # Routes
194 @app.route("/favicon.ico")
195 def favicon() -> Response: # pyright: ignore [reportUnusedFunction]
196 """Serve the favicon from the static directory."""
197 return send_from_directory(Path(app.root_path, "front/static"), "favicon.png", mimetype="image/png")
199 # Before Request
200 @app.before_request
201 def init_once() -> None: # pyright: ignore [reportUnusedFunction]
202 """Initialize humanize for each request."""
203 humanize_i18n.activate(get_locale())
205 @app.before_request
206 def check_init_error() -> None: # pyright: ignore [reportUnusedFunction]
207 """Display initialization errors as flash messages if any."""
208 init_error = app.config.pop("INIT_ERROR", None)
209 if init_error:
210 flash(init_error, "danger")
212 # Context processor
213 @app.context_processor
214 def inject_formatters() -> dict[str, Callable[[datetime], str]]: # pyright: ignore [reportUnusedFunction]
215 """Inject custom formatters into the template context."""
216 return {"format_datetime": format_datetime}
218 @app.context_processor
219 def inject_globals() -> dict[str, Any]: # pyright: ignore [reportUnusedFunction]
220 """Inject global variables into the template context."""
221 return __inject_globals(app)
223 # Template global
224 @app.template_global()
225 def gravatar(email: str) -> str: # pyright: ignore [reportUnusedFunction]
226 """Return the Gravatar image URL for an email address."""
227 return __gravatar(email)
229 @app.template_global()
230 def get_toasted_messages(with_categories: bool = False, category_filter: Iterable[str] = ()) -> list[str] | list[tuple[str, str]]:
231 """Like a flash but for toast."""
232 return __get_toasted_messages(with_categories, category_filter)
234 return app