Coverage for slidge/main.py: 36%

133 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-10 12:26 +0000

1__help__ = """ 

2Slidge can be configured via CLI args, environment variables and/or INI files. 

3 

4To use env vars, use this convention: ``--home-dir`` becomes ``HOME_DIR``. 

5 

6Everything in ``/etc/slidge/conf.d/*`` is automatically used. 

7To use a plugin-specific INI file, put it in another dir, 

8and launch slidge with ``-c /path/to/plugin-specific.conf``. 

9Use the long version of the CLI arg without the double dash prefix inside this 

10INI file, eg ``debug=true``. 

11 

12An example configuration file is available at 

13https://codeberg.org/slidge/slidge/src/branch/main/dev/confs/slidge-example.ini 

14""" 

15 

16import asyncio 

17import importlib 

18import inspect 

19import logging 

20import logging.config 

21import os 

22import signal 

23import sys 

24from pathlib import Path 

25from types import ModuleType 

26 

27import configargparse 

28 

29import slidge 

30from slidge.core import config 

31from slidge.core.gateway import BaseGateway 

32from slidge.db import SlidgeStore 

33from slidge.db.avatar import avatar_cache 

34from slidge.db.meta import get_engine 

35from slidge.migration import migrate 

36from slidge.util.conf import ConfigModule 

37 

38log = logging.getLogger(__name__) 

39 

40 

41def find_gateway_class(legacy_module: ModuleType) -> type[BaseGateway]: 

42 """ 

43 Find the unique :class:`.BaseGateway` subclass exposed at the top level of 

44 a legacy module. 

45 """ 

46 gateway_classes = { 

47 value 

48 for value in vars(legacy_module).values() 

49 if isinstance(value, type) 

50 and issubclass(value, BaseGateway) 

51 and value is not BaseGateway 

52 } 

53 if not gateway_classes: 

54 raise RuntimeError( 

55 f"No BaseGateway subclass found in '{legacy_module.__name__}'." 

56 " Legacy modules must expose their gateway class at the top" 

57 " level, e.g. with 'from .gateway import Gateway' in their" 

58 " __init__.py." 

59 ) 

60 if len(gateway_classes) > 1: 

61 raise RuntimeError( 

62 f"Several BaseGateway subclasses found in '{legacy_module.__name__}':" 

63 f" {sorted(c.__name__ for c in gateway_classes)}. Only expose one" 

64 " at the top level of the legacy module." 

65 ) 

66 return gateway_classes.pop() 

67 

68 

69class MainConfig(ConfigModule): 

70 def update_dynamic_defaults(self, args: configargparse.Namespace) -> None: 

71 # force=True is needed in case we call a logger before this is reached, 

72 # or basicConfig has no effect 

73 if args.log_config: 

74 logging.config.fileConfig(args.log_config) 

75 else: 

76 logging.basicConfig( 

77 level=args.loglevel, 

78 filename=args.log_file, 

79 force=True, 

80 format=args.log_format, 

81 ) 

82 

83 if args.home_dir is None: 

84 args.home_dir = Path("/var/lib/slidge") / str(args.jid) 

85 

86 if args.db_url is None: 

87 args.db_url = f"sqlite:///{args.home_dir}/slidge.sqlite" 

88 

89 

90class SigTermInterrupt(Exception): 

91 pass 

92 

93 

94def entrypoint(module_name: str) -> None: 

95 """ 

96 Entrypoint to be used in ``__main__.py`` of 

97 :term:`legacy modules <Legacy Module>`. 

98 

99 :param module_name: An importable :term:`Legacy Module`. 

100 """ 

101 main(module_name) 

102 

103 

104def get_configurator(from_entrypoint: bool = False) -> MainConfig: 

105 p = configargparse.ArgumentParser( 

106 default_config_files=[ 

107 f"{p}/*" 

108 for p in os.getenv("SLIDGE_CONF_DIR", "/etc/slidge/conf.d/").split(":") 

109 ], 

110 description=__help__, 

111 ) 

112 p.add_argument( 

113 "-c", 

114 "--config", 

115 help="Path to a INI config file.", 

116 env_var="SLIDGE_CONFIG", 

117 is_config_file=True, 

118 ) 

119 p.add_argument( 

120 "--log-config", 

121 help="Path to a INI config file to personalise logging output. Refer to " 

122 "<https://docs.python.org/3/library/logging.config.html#configuration-file-format> " 

123 "for details.", 

124 ) 

125 p.add_argument( 

126 "-q", 

127 "--quiet", 

128 help="Quiet mode. Only logs WARNINGs and above. (unused if --log-config is specified)", 

129 action="store_const", 

130 dest="loglevel", 

131 const=logging.WARNING, 

132 default=logging.INFO, 

133 env_var="SLIDGE_QUIET", 

134 ) 

135 p.add_argument( 

136 "-d", 

137 "--debug", 

138 "--verbose", 

139 help="Verbose mode. Enable DEBUG logging. (unused if --log-config is specified)", 

140 action="store_const", 

141 dest="loglevel", 

142 const=logging.DEBUG, 

143 env_var="SLIDGE_DEBUG", 

144 ) 

145 p.add_argument( 

146 "--version", 

147 action="version", 

148 version=f"%(prog)s {slidge.__version__}", 

149 ) 

150 configurator = MainConfig( 

151 config, p, skip_options=("legacy_module",) if from_entrypoint else () 

152 ) 

153 return configurator 

154 

155 

156def get_parser() -> configargparse.ArgumentParser: 

157 return get_configurator().parser 

158 

159 

160def configure(from_entrypoint: bool) -> list[str]: 

161 configurator = get_configurator(from_entrypoint) 

162 _args, unknown_argv = configurator.set_conf() 

163 

164 if not (h := config.HOME_DIR).exists(): 

165 log.info("Creating directory '%s'", h) 

166 os.makedirs(h) 

167 

168 config.UPLOAD_REQUESTER = config.UPLOAD_REQUESTER or config.JID.bare 

169 

170 return unknown_argv 

171 

172 

173def handle_sigterm(_signum: int, _frame: object) -> None: 

174 log.info("Caught SIGTERM") 

175 raise SigTermInterrupt 

176 

177 

178def main(module_name: str | None = None) -> None: 

179 from_entrypoint = module_name is not None 

180 signal.signal(signal.SIGTERM, handle_sigterm) 

181 

182 unknown_argv = configure(from_entrypoint) 

183 log.info("Starting slidge version %s", slidge.__version__) 

184 

185 if module_name is not None: 

186 config.LEGACY_MODULE = module_name 

187 

188 legacy_module = importlib.import_module(config.LEGACY_MODULE) 

189 log.debug("Legacy module: %s", dir(legacy_module)) 

190 log.info( 

191 "Starting legacy module: '%s' version %s", 

192 config.LEGACY_MODULE, 

193 getattr(legacy_module, "__version__", "No version"), 

194 ) 

195 

196 if plugin_config_obj := getattr( 

197 legacy_module, "config", getattr(legacy_module, "Config", None) 

198 ): 

199 # If the legacy module has default parameters that depend on dynamic defaults 

200 # of the slidge main config, it needs to be refreshed at this point, because 

201 # now the dynamic defaults are set. 

202 if inspect.ismodule(plugin_config_obj): 

203 importlib.reload(plugin_config_obj) 

204 log.debug("Found a config object in plugin: %r", plugin_config_obj) 

205 ConfigModule.ENV_VAR_PREFIX += ( 

206 f"_{config.LEGACY_MODULE.split('.')[-1].upper()}_" 

207 ) 

208 log.debug("Env var prefix: %s", ConfigModule.ENV_VAR_PREFIX) 

209 _, unknown_argv = ConfigModule(plugin_config_obj).set_conf(unknown_argv) 

210 

211 if unknown_argv: 

212 log.error( 

213 f"These config options have not been recognized and ignored: {unknown_argv}" 

214 ) 

215 

216 migrate() 

217 

218 gw_cls = find_gateway_class(legacy_module) 

219 store = SlidgeStore( 

220 get_engine( 

221 config.DB_URL, 

222 echo=logging.getLogger().isEnabledFor(level=logging.DEBUG), 

223 ) 

224 ) 

225 BaseGateway.store = store 

226 gateway = gw_cls() 

227 avatar_cache.store = gateway.store.avatars 

228 avatar_cache.set_dir(config.HOME_DIR / "slidge_avatars_v3") 

229 

230 gateway.add_event_handler("disconnected", _on_disconnected) 

231 gateway.add_event_handler("stream_error", _on_stream_error) 

232 gateway.connect() 

233 return_code = 0 

234 try: 

235 gateway.loop.run_forever() 

236 except KeyboardInterrupt: 

237 log.debug("Received SIGINT") 

238 except SigTermInterrupt: 

239 log.debug("Received SIGTERM") 

240 except SystemExit as e: 

241 return_code = e.code # type: ignore 

242 log.debug("Exit called") 

243 except Exception: 

244 return_code = 2 

245 log.exception("Exception in __main__") 

246 finally: 

247 # Cleanup separately to avoid interefering with exit code resolution. 

248 try: 

249 gateway.del_event_handler("disconnected", _on_disconnected) 

250 if gateway.is_connected(): 

251 log.debug("Gateway is connected, cleaning up") 

252 gateway.loop.run_until_complete(asyncio.gather(*gateway.shutdown())) 

253 gateway.disconnect() 

254 gateway.loop.run_until_complete(gateway.disconnected) 

255 log.info("Successful clean shut down") 

256 else: 

257 log.debug("Gateway is not connected, no need to clean up") 

258 except BaseException: 

259 log.exception("Error during shutdown") 

260 finally: 

261 try: 

262 avatar_cache.close() 

263 except BaseException: 

264 log.exception("Error closing avatar cache") 

265 try: 

266 gateway.loop.run_until_complete(gateway.http.close()) 

267 except BaseException: 

268 log.exception("Error closing HTTP client") 

269 

270 if gateway.has_crashed and return_code != 0: 

271 return_code = 3 

272 

273 log.debug("Exiting with code %s", return_code) 

274 sys.exit(return_code) 

275 

276 

277def _on_disconnected(e: BaseException) -> None: 

278 log.error("Disconnected from the XMPP server: '%s'.", e) 

279 sys.exit(10) 

280 

281 

282def _on_stream_error(e: BaseException) -> None: 

283 log.error("Stream error: '%s'.", e)