Coverage for seedboxsync/__init__.py: 94%

89 statements  

« 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.""" 

8 

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 

32 

33__version__ = version 

34 

35 

36def __handle_http_exception( 

37 e: Exception, 

38) -> tuple[Response, int | None] | tuple[str, int | None]: 

39 """ 

40 Global 404 handler. 

41 

42 Args: 

43 e (Exception): Exception raised while processing the request. 

44 

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) 

51 

52 

53def __gravatar(email: str) -> str: 

54 """ 

55 Generate the Gravatar image URL for a given email address. 

56 

57 Args: 

58 email (str): The target user's email address. 

59 

60 Returns: 

61 str: The fully qualified URL pointing to the user's Gravatar profile image. 

62 """ 

63 return str(Gravatar(email).get_image()) 

64 

65 

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. 

69 

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]. 

73 

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 (). 

79 

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] 

89 

90 if with_categories: 

91 return toasts 

92 

93 return [toast[1] for toast in toasts] 

94 

95 

96def __inject_globals(app: Flask) -> dict[str, Any]: # pyright: ignore [reportUnusedFunction] 

97 """ 

98 Build the global template context variables for the Flask application. 

99 

100 Resolves the current locale and language, retrieves the configured web UI 

101 theme, and exposes application metadata and configuration to all templates. 

102 

103 Args: 

104 app: Flask application instance used to retrieve configuration and 

105 application-specific settings. 

106 

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 } 

130 

131 

132def create_app(injected_config: dict[str, str | bool] | None = None) -> Flask: 

133 """ 

134 Create and configure the SeedboxSync Flask application. 

135 

136 Args: 

137 injected_config (dict[str, str] | None): Optional configuration overrides 

138 used by tests. 

139 

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 ) 

150 

151 # ══════════════════════════════════════════════════════════════════════════════ 

152 # ⚙️ INIT 

153 # ══════════════════════════════════════════════════════════════════════════════ 

154 # Configure logger for Flask and Click 

155 logger.configure_logger(app.logger) 

156 

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 

160 

161 # Initialize the database 

162 database = Database(app) 

163 app.extensions["database"] = database 

164 

165 # Load config 

166 Config(app) 

167 

168 # Initialize Babel 

169 app.config["BABEL_TRANSLATION_DIRECTORIES"] = "front/translations" 

170 babel.init_app(app, locale_selector=get_locale) 

171 

172 # Initialize the cache 

173 cache.init_app(app) 

174 

175 # Initialize the login manager and OAuth 

176 login_manager.init_app(app) 

177 init_oauth2(app) 

178 

179 # Register jinja filter 

180 app.jinja_env.filters["slugify"] = slugify 

181 

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] 

188 

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 ) 

193 

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") 

202 

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()) 

208 

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") 

215 

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} 

221 

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) 

226 

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) 

232 

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) 

237 

238 return app