Coverage for seedboxsync/__init__.py: 94%

86 statements  

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

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 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 } 

125 

126 

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

128 """ 

129 Create and configure the SeedboxSync Flask application. 

130 

131 Args: 

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

133 used by tests. 

134 

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 ) 

145 

146 # ══════════════════════════════════════════════════════════════════════════════ 

147 # ⚙️ INIT 

148 # ══════════════════════════════════════════════════════════════════════════════ 

149 # Configure logger for Flask and Click 

150 logger.configure_logger(app.logger) 

151 

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 

155 

156 # Initialize the database 

157 database = Database(app) 

158 app.extensions["database"] = database 

159 

160 # Load config 

161 Config(app) 

162 

163 # Initialize Babel 

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

165 babel.init_app(app, locale_selector=get_locale) 

166 

167 # Initialize the cache 

168 cache.init_app(app) 

169 

170 # Initialize the login manager and OAuth 

171 login_manager.init_app(app) 

172 init_oauth2(app) 

173 

174 # Register jinja filter 

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

176 

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] 

183 

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 ) 

188 

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

197 

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

203 

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

210 

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} 

216 

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) 

221 

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) 

227 

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) 

232 

233 return app