Coverage for slidge/main.py: 37%

126 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-18 04:30 +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="loglevel=WARNING (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 help="loglevel=DEBUG (unused if --log-config is specified)", 

127 action="store_const", 

128 dest="loglevel", 

129 const=logging.DEBUG, 

130 env_var="SLIDGE_DEBUG", 

131 ) 

132 p.add_argument( 

133 "--version", 

134 action="version", 

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

136 ) 

137 configurator = MainConfig( 

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

139 ) 

140 return configurator 

141 

142 

143def get_parser() -> configargparse.ArgumentParser: 

144 return get_configurator().parser 

145 

146 

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

148 configurator = get_configurator(from_entrypoint) 

149 _args, unknown_argv = configurator.set_conf() 

150 

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

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

153 os.makedirs(h) 

154 

155 config.UPLOAD_REQUESTER = config.UPLOAD_REQUESTER or config.JID.bare 

156 

157 return unknown_argv 

158 

159 

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

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

162 raise SigTermInterrupt 

163 

164 

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

166 from_entrypoint = module_name is not None 

167 signal.signal(signal.SIGTERM, handle_sigterm) 

168 

169 unknown_argv = configure(from_entrypoint) 

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

171 

172 if module_name is not None: 

173 config.LEGACY_MODULE = module_name 

174 

175 legacy_module = importlib.import_module(config.LEGACY_MODULE) 

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

177 logging.info( # noqa: LOG015 

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

179 config.LEGACY_MODULE, 

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

181 ) 

182 

183 if plugin_config_obj := getattr( 

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

185 ): 

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

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

188 # now the dynamic defaults are set. 

189 if inspect.ismodule(plugin_config_obj): 

190 importlib.reload(plugin_config_obj) 

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

192 ConfigModule.ENV_VAR_PREFIX += ( 

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

194 ) 

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

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

197 

198 if unknown_argv: 

199 logging.error( # noqa: LOG015 

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

201 ) 

202 

203 migrate() 

204 

205 gw_cls = find_gateway_class(legacy_module) 

206 store = SlidgeStore( 

207 get_engine( 

208 config.DB_URL, 

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

210 ) 

211 ) 

212 BaseGateway.store = store 

213 gateway = gw_cls() 

214 avatar_cache.store = gateway.store.avatars 

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

216 

217 gateway.add_event_handler("connection_lost", _on_connection_lost) 

218 gateway.add_event_handler("disconnected", _on_disconnected) 

219 gateway.add_event_handler("stream_error", _on_stream_error) 

220 gateway.connect() 

221 return_code = 0 

222 try: 

223 gateway.loop.run_forever() 

224 except KeyboardInterrupt: 

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

226 except SigTermInterrupt: 

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

228 except SystemExit as e: 

229 return_code = e.code # type: ignore 

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

231 except Exception: 

232 return_code = 2 

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

234 finally: 

235 if gateway.has_crashed: 

236 if return_code != 0: 

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

238 return_code = 3 

239 if gateway.is_connected(): 

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

241 gateway.del_event_handler("disconnected", _on_disconnected) 

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

243 gateway.disconnect() 

244 gateway.loop.run_until_complete(gateway.disconnected) 

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

246 else: 

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

248 avatar_cache.close() 

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

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

251 sys.exit(return_code) 

252 

253 

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

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

256 sys.exit(10) 

257 

258 

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

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

261 

262 

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

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

265 sys.exit(15)