Coverage for slidge/main.py: 35%

130 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-22 03:37 +0000

1""" 

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 get_configurator(from_entrypoint: bool = False) -> MainConfig: 

95 p = configargparse.ArgumentParser( 

96 default_config_files=[ 

97 f"{p}/*" 

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

99 ], 

100 description=__doc__, 

101 ) 

102 p.add_argument( 

103 "-c", 

104 "--config", 

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

106 env_var="SLIDGE_CONFIG", 

107 is_config_file=True, 

108 ) 

109 p.add_argument( 

110 "--log-config", 

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

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

113 "for details.", 

114 ) 

115 p.add_argument( 

116 "-q", 

117 "--quiet", 

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

119 action="store_const", 

120 dest="loglevel", 

121 const=logging.WARNING, 

122 default=logging.INFO, 

123 env_var="SLIDGE_QUIET", 

124 ) 

125 p.add_argument( 

126 "-d", 

127 "--debug", 

128 "--verbose", 

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

130 action="store_const", 

131 dest="loglevel", 

132 const=logging.DEBUG, 

133 env_var="SLIDGE_DEBUG", 

134 ) 

135 p.add_argument( 

136 "--version", 

137 action="version", 

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

139 ) 

140 configurator = MainConfig( 

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

142 ) 

143 return configurator 

144 

145 

146def get_parser() -> configargparse.ArgumentParser: 

147 return get_configurator().parser 

148 

149 

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

151 configurator = get_configurator(from_entrypoint) 

152 _args, unknown_argv = configurator.set_conf() 

153 

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

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

156 os.makedirs(h) 

157 

158 config.UPLOAD_REQUESTER = config.UPLOAD_REQUESTER or config.JID.bare 

159 

160 return unknown_argv 

161 

162 

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

164 log.info("Caught SIGTERM") 

165 raise SigTermInterrupt 

166 

167 

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

169 from_entrypoint = module_name is not None 

170 signal.signal(signal.SIGTERM, handle_sigterm) 

171 

172 unknown_argv = configure(from_entrypoint) 

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

174 

175 if module_name is not None: 

176 config.LEGACY_MODULE = module_name 

177 

178 legacy_module = importlib.import_module(config.LEGACY_MODULE) 

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

180 log.info( 

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

182 config.LEGACY_MODULE, 

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

184 ) 

185 

186 if plugin_config_obj := getattr( 

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

188 ): 

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

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

191 # now the dynamic defaults are set. 

192 if inspect.ismodule(plugin_config_obj): 

193 importlib.reload(plugin_config_obj) 

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

195 ConfigModule.ENV_VAR_PREFIX += ( 

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

197 ) 

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

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

200 

201 if unknown_argv: 

202 log.error( 

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

204 ) 

205 

206 migrate() 

207 

208 gw_cls = find_gateway_class(legacy_module) 

209 store = SlidgeStore( 

210 get_engine( 

211 config.DB_URL, 

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

213 ) 

214 ) 

215 BaseGateway.store = store 

216 gateway = gw_cls() 

217 avatar_cache.store = gateway.store.avatars 

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

219 

220 gateway.add_event_handler("disconnected", _on_disconnected) 

221 gateway.add_event_handler("stream_error", _on_stream_error) 

222 gateway.connect() 

223 return_code = 0 

224 try: 

225 gateway.loop.run_forever() 

226 except KeyboardInterrupt: 

227 log.debug("Received SIGINT") 

228 except SigTermInterrupt: 

229 log.debug("Received SIGTERM") 

230 except SystemExit as e: 

231 return_code = e.code # type: ignore 

232 log.debug("Exit called") 

233 except Exception: 

234 return_code = 2 

235 log.exception("Exception in __main__") 

236 finally: 

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

238 try: 

239 gateway.del_event_handler("disconnected", _on_disconnected) 

240 if gateway.is_connected(): 

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

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

243 gateway.disconnect() 

244 gateway.loop.run_until_complete(gateway.disconnected) 

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

246 else: 

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

248 except BaseException: 

249 log.exception("Error during shutdown") 

250 finally: 

251 try: 

252 avatar_cache.close() 

253 except BaseException: 

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

255 try: 

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

257 except BaseException: 

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

259 

260 if gateway.has_crashed and return_code != 0: 

261 return_code = 3 

262 

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

264 sys.exit(return_code) 

265 

266 

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

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

269 sys.exit(10) 

270 

271 

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

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