Coverage for slidge/main.py: 37%

126 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-10 04:45 +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 

38 

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

40 """ 

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

42 a legacy module. 

43 """ 

44 gateway_classes = { 

45 value 

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

47 if isinstance(value, type) 

48 and issubclass(value, BaseGateway) 

49 and value is not BaseGateway 

50 } 

51 if not gateway_classes: 

52 raise RuntimeError( 

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

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

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

56 " __init__.py." 

57 ) 

58 if len(gateway_classes) > 1: 

59 raise RuntimeError( 

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

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

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

63 ) 

64 return gateway_classes.pop() 

65 

66 

67class MainConfig(ConfigModule): 

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

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

70 # or basicConfig has no effect 

71 if args.log_config: 

72 logging.config.fileConfig(args.log_config) 

73 else: 

74 logging.basicConfig( 

75 level=args.loglevel, 

76 filename=args.log_file, 

77 force=True, 

78 format=args.log_format, 

79 ) 

80 

81 if args.home_dir is None: 

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

83 

84 if args.db_url is None: 

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

86 

87 

88class SigTermInterrupt(Exception): 

89 pass 

90 

91 

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

93 p = configargparse.ArgumentParser( 

94 default_config_files=[ 

95 f"{p}/*" 

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

97 ], 

98 description=__doc__, 

99 ) 

100 p.add_argument( 

101 "-c", 

102 "--config", 

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

104 env_var="SLIDGE_CONFIG", 

105 is_config_file=True, 

106 ) 

107 p.add_argument( 

108 "--log-config", 

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

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

111 "for details.", 

112 ) 

113 p.add_argument( 

114 "-q", 

115 "--quiet", 

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

117 action="store_const", 

118 dest="loglevel", 

119 const=logging.WARNING, 

120 default=logging.INFO, 

121 env_var="SLIDGE_QUIET", 

122 ) 

123 p.add_argument( 

124 "-d", 

125 "--debug", 

126 "--verbose", 

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

128 action="store_const", 

129 dest="loglevel", 

130 const=logging.DEBUG, 

131 env_var="SLIDGE_DEBUG", 

132 ) 

133 p.add_argument( 

134 "--version", 

135 action="version", 

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

137 ) 

138 configurator = MainConfig( 

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

140 ) 

141 return configurator 

142 

143 

144def get_parser() -> configargparse.ArgumentParser: 

145 return get_configurator().parser 

146 

147 

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

149 configurator = get_configurator(from_entrypoint) 

150 _args, unknown_argv = configurator.set_conf() 

151 

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

153 logging.info("Creating directory '%s'", h) # noqa: LOG015 

154 os.makedirs(h) 

155 

156 config.UPLOAD_REQUESTER = config.UPLOAD_REQUESTER or config.JID.bare 

157 

158 return unknown_argv 

159 

160 

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

162 logging.info("Caught SIGTERM") # noqa: LOG015 

163 raise SigTermInterrupt 

164 

165 

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

167 from_entrypoint = module_name is not None 

168 signal.signal(signal.SIGTERM, handle_sigterm) 

169 

170 unknown_argv = configure(from_entrypoint) 

171 logging.info("Starting slidge version %s", slidge.__version__) # noqa: LOG015 

172 

173 if module_name is not None: 

174 config.LEGACY_MODULE = module_name 

175 

176 legacy_module = importlib.import_module(config.LEGACY_MODULE) 

177 logging.debug("Legacy module: %s", dir(legacy_module)) # noqa: LOG015 

178 logging.info( # noqa: LOG015 

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

180 config.LEGACY_MODULE, 

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

182 ) 

183 

184 if plugin_config_obj := getattr( 

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

186 ): 

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

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

189 # now the dynamic defaults are set. 

190 if inspect.ismodule(plugin_config_obj): 

191 importlib.reload(plugin_config_obj) 

192 logging.debug("Found a config object in plugin: %r", plugin_config_obj) # noqa: LOG015 

193 ConfigModule.ENV_VAR_PREFIX += ( 

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

195 ) 

196 logging.debug("Env var prefix: %s", ConfigModule.ENV_VAR_PREFIX) # noqa: LOG015 

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

198 

199 if unknown_argv: 

200 logging.error( # noqa: LOG015 

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

202 ) 

203 

204 migrate() 

205 

206 gw_cls = find_gateway_class(legacy_module) 

207 store = SlidgeStore( 

208 get_engine( 

209 config.DB_URL, 

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

211 ) 

212 ) 

213 BaseGateway.store = store 

214 gateway = gw_cls() 

215 avatar_cache.store = gateway.store.avatars 

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

217 

218 gateway.add_event_handler("connection_lost", _on_connection_lost) 

219 gateway.add_event_handler("disconnected", _on_disconnected) 

220 gateway.add_event_handler("stream_error", _on_stream_error) 

221 gateway.connect() 

222 return_code = 0 

223 try: 

224 gateway.loop.run_forever() 

225 except KeyboardInterrupt: 

226 logging.debug("Received SIGINT") # noqa: LOG015 

227 except SigTermInterrupt: 

228 logging.debug("Received SIGTERM") # noqa: LOG015 

229 except SystemExit as e: 

230 return_code = e.code # type: ignore 

231 logging.debug("Exit called") # noqa: LOG015 

232 except Exception: 

233 return_code = 2 

234 logging.exception("Exception in __main__") # noqa: LOG015 

235 finally: 

236 if gateway.has_crashed: 

237 if return_code != 0: 

238 logging.warning("Return code has been set twice. Please report this.") # noqa: LOG015 

239 return_code = 3 

240 if gateway.is_connected(): 

241 logging.debug("Gateway is connected, cleaning up") # noqa: LOG015 

242 gateway.del_event_handler("disconnected", _on_disconnected) 

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

244 gateway.disconnect() 

245 gateway.loop.run_until_complete(gateway.disconnected) 

246 logging.info("Successful clean shut down") # noqa: LOG015 

247 else: 

248 logging.debug("Gateway is not connected, no need to clean up") # noqa: LOG015 

249 avatar_cache.close() 

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

251 logging.debug("Exiting with code %s", return_code) # noqa: LOG015 

252 sys.exit(return_code) 

253 

254 

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

256 logging.error("Disconnected from the XMPP server: '%s'.", e) # noqa: LOG015 

257 sys.exit(10) 

258 

259 

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

261 logging.error("Stream error: '%s'.", e) # noqa: LOG015 

262 

263 

264def _on_connection_lost(*args: object) -> None: 

265 logging.error("Connection lost: '%s'", args) # noqa: LOG015 

266 sys.exit(15)