Coverage for app/backend/src/couchers/config.py: 82%

124 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

1""" 

2A simple config system 

3""" 

4 

5import os 

6import typing 

7from collections.abc import Mapping 

8from typing import Any, Literal 

9 

10from couchers.constants import DB_POOL_SIZE 

11 

12 

13# Not a dataclass. Not all attributes must be initialized. 

14class Config: 

15 """ 

16 Defines strongly-typed application config values, 

17 initializable from matching environment variables, 

18 also supporting weakly-typed dict-like access for backcompat with existing code. 

19 """ 

20 

21 # Whether we're in dev mode 

22 DEV: bool 

23 # Whether we're `api` mode (answering API queries) or `scheduler` (scheduling background jobs), or `worker` 

24 # (servicing background jobs). Can also be set to `all` to do all three simultaneously 

25 ROLE: Literal["api", "scheduler", "worker", "all"] = "all" 

26 # number of bg worker processes, requires worker or all above 

27 BACKGROUND_WORKER_PROCESSES: int = 1 

28 # threads per worker process; in-flight jobs is the product of the two 

29 BACKGROUND_WORKER_THREADS_PER_PROCESS: int = 2 

30 # Version string 

31 VERSION: str = "unknown" 

32 # ISO 8601 timestamp of the deployed commit (CI_COMMIT_TIMESTAMP), empty outside CI builds 

33 COMMIT_TIMESTAMP: str = "" 

34 # Base URL of frontend, e.g. https://couchers.org 

35 BASE_URL: str 

36 # URL of the backend, e.g. https://api.couchers.org 

37 BACKEND_BASE_URL: str 

38 # URL of the console, e.g. https://console.couchers.org 

39 CONSOLE_BASE_URL: str 

40 # URL of the merch shop, e.g. https://shop.couchershq.org 

41 MERCH_SHOP_URL: str 

42 # Used to generate a variety of secrets 

43 SECRET: bytes 

44 # Domain that cookies should set as their domain value 

45 COOKIE_DOMAIN: str 

46 # SQLAlchemy database connection string 

47 DATABASE_CONNECTION_STRING: str 

48 # OpenTelemetry endpoint to send traces to 

49 OPENTELEMETRY_ENDPOINT: str = "" 

50 # Path to a GeoLite2-City.mmdb file for geocoding IPs in user session info 

51 GEOLITE2_CITY_MMDB_FILE_LOCATION: str = "" 

52 GEOLITE2_ASN_MMDB_FILE_LOCATION: str = "" 

53 # Whether to try adding dummy data 

54 ADD_DUMMY_DATA: bool 

55 # Donations (gated at runtime by the `donations_enabled` feature flag) 

56 STRIPE_API_KEY: str 

57 STRIPE_WEBHOOK_SECRET: str 

58 STRIPE_RECURRING_PRODUCT_ID: str 

59 # Strong verification through Iris ID (gated at runtime by the `strong_verification_enabled` feature flag) 

60 IRIS_ID_PUBKEY: str 

61 IRIS_ID_SECRET: str 

62 VERIFICATION_DATA_PUBLIC_KEY: bytes 

63 # Postal verification (MyPostcard API; gated at runtime by the `postal_verification_enabled` feature flag) 

64 MYPOSTCARD_API_KEY: str 

65 MYPOSTCARD_USERNAME: str 

66 MYPOSTCARD_PASSWORD: str 

67 MYPOSTCARD_PRODUCT_CODE: str 

68 MYPOSTCARD_CAMPAIGN_ID: str 

69 # SMS (gated at runtime by the `sms_enabled` feature flag) 

70 SMS_SENDER_ID: str 

71 # Email 

72 ENABLE_EMAIL: bool 

73 # Sender name for outgoing notification emails e.g. "Couchers.org" 

74 NOTIFICATION_EMAIL_SENDER: str 

75 # Sender email, e.g. "notify@couchers.org" 

76 NOTIFICATION_EMAIL_ADDRESS: str 

77 # An optional prefix for email subject, e.g. [STAGING] 

78 NOTIFICATION_PREFIX: str = "" 

79 # Address to send emails about reported users 

80 REPORTS_EMAIL_RECIPIENT: str 

81 # Address to send contributor forms when users sign up/fill the form 

82 CONTRIBUTOR_FORM_EMAIL_RECIPIENT: str 

83 # Address to moderation notifications 

84 MODS_EMAIL_RECIPIENT: str 

85 # SMTP settings 

86 SMTP_HOST: str 

87 SMTP_PORT: int 

88 SMTP_USERNAME: str 

89 SMTP_PASSWORD: str 

90 # Media server 

91 ENABLE_MEDIA: bool 

92 MEDIA_SERVER_SECRET_KEY: bytes 

93 MEDIA_SERVER_BEARER_TOKEN: str 

94 MEDIA_SERVER_BASE_URL: str 

95 MEDIA_SERVER_UPLOAD_BASE_URL: str 

96 # Bug reporting tool 

97 BUG_TOOL_ENABLED: bool 

98 BUG_TOOL_GITHUB_REPO: str 

99 BUG_TOOL_GITHUB_USERNAME: str 

100 BUG_TOOL_GITHUB_TOKEN: str 

101 # Sentry 

102 SENTRY_ENABLED: bool 

103 SENTRY_URL: str 

104 # Push notifications 

105 PUSH_NOTIFICATIONS_ENABLED: bool 

106 PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY: str 

107 PUSH_NOTIFICATIONS_VAPID_SUBJECT: str 

108 # Whether to initiate new activeness probes 

109 ACTIVENESS_PROBES_ENABLED: bool 

110 # Listmonk (mailing list, gated at runtime by the `listmonk_enabled` feature flag) 

111 LISTMONK_BASE_URL: str 

112 LISTMONK_API_USERNAME: str 

113 LISTMONK_API_KEY: str 

114 LISTMONK_LIST_ID: int 

115 # Whether we're in test 

116 IN_TEST: bool = False 

117 # Dev-only override file; when set, flags are read from it instead of GrowthBook. 

118 FEATURE_FLAGS_FILE_OVERRIDE_PATH: str = "" 

119 # GrowthBook (feature flags) 

120 GROWTHBOOK_API_HOST: str = "https://cdn.growthbook.io" 

121 GROWTHBOOK_CLIENT_KEY: str = "" 

122 # Disk path for the last-known-good feature payload, used as a cold-start fallback when GrowthBook 

123 # is unreachable, so we never start on in-code defaults. 

124 GROWTHBOOK_CACHE_PATH: str = "" 

125 # Continuous profiling (Pyroscope). Profiling is gated at runtime by the `profiling_enabled` feature 

126 # flag; PYROSCOPE_ENABLED is the per-deployment master switch. 

127 PYROSCOPE_ENABLED: bool 

128 PYROSCOPE_SERVER: str 

129 PYROSCOPE_AUTH_TOKEN: str 

130 # Moderation auto-approval deadline in seconds (0 to disable auto-approval) 

131 MODERATION_AUTO_APPROVE_DEADLINE_SECONDS: int 

132 # User ID of the bot user for automated moderation actions 

133 MODERATION_BOT_USER_ID: int 

134 # Enable development APIs (e.g., SendDevPushNotification) 

135 ENABLE_DEV_APIS: bool 

136 # Slack notifications 

137 SLACK_ENABLED: bool 

138 SLACK_BOT_TOKEN: str 

139 SLACK_DONATIONS_CHANNEL: str 

140 SLACK_MERCH_CHANNEL: str 

141 

142 def __init__(self) -> None: 

143 # Initialize instance attributes with default values from class attributes. 

144 for var_name in Config.__annotations__.keys(): 

145 try: 

146 default_value = getattr(Config, var_name) 

147 except AttributeError: 

148 continue 

149 self.__setattr__(var_name, default_value) 

150 

151 def copy_from(self, other: Config) -> None: 

152 for var_name in Config.__annotations__.keys(): 

153 try: 

154 attr_value = other.__getattribute__(var_name) 

155 except AttributeError: 

156 try: 

157 self.__delattr__(var_name) 

158 except AttributeError: 

159 pass 

160 continue 

161 self.__setattr__(var_name, attr_value) 

162 

163 def copy(self) -> Config: 

164 copy = Config() 

165 copy.copy_from(self) 

166 return copy 

167 

168 def check(self) -> None: 

169 """Checks that the config is valid, i.e., all required values are set to valid values.""" 

170 for attr_name in Config.__annotations__.keys(): 

171 if not hasattr(self, attr_name): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 raise ValueError(f"Config value {attr_name} not set") 

173 

174 # each worker thread can hold two connections at once 

175 if 2 * self.BACKGROUND_WORKER_THREADS_PER_PROCESS > DB_POOL_SIZE: 

176 raise Exception( 

177 f"BACKGROUND_WORKER_THREADS_PER_PROCESS ({self.BACKGROUND_WORKER_THREADS_PER_PROCESS}) must not " 

178 f"exceed half of DB_POOL_SIZE ({DB_POOL_SIZE // 2}), or worker threads could exhaust the DB " 

179 "connection pool" 

180 ) 

181 

182 if not self.DEV: 

183 # checks for prod 

184 if "https" not in self.BASE_URL: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 raise Exception("Production site must be over HTTPS") 

186 if not self.ENABLE_EMAIL: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 raise Exception("Production site must have email enabled") 

188 if self.IN_TEST: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true

189 raise Exception("IN_TEST while not DEV") 

190 

191 # Donations are gated at runtime by the `donations_enabled` feature flag, which can be flipped on 

192 # remotely at any time, so prod must always have Stripe credentials present so the feature can run. 

193 if not self.STRIPE_API_KEY or not self.STRIPE_WEBHOOK_SECRET or not self.STRIPE_RECURRING_PRODUCT_ID: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true

194 raise Exception("Stripe credentials must be configured in production") 

195 

196 # Listmonk is gated at runtime by the `listmonk_enabled` feature flag, which can be flipped on 

197 # remotely at any time, so prod must always have the Listmonk credentials present. 

198 if ( 198 ↛ 204line 198 didn't jump to line 204 because the condition on line 198 was never true

199 not self.LISTMONK_BASE_URL 

200 or not self.LISTMONK_API_USERNAME 

201 or not self.LISTMONK_API_KEY 

202 or not self.LISTMONK_LIST_ID 

203 ): 

204 raise Exception("Listmonk credentials must be configured in production") 

205 

206 # The following features are gated at runtime by feature flags (`strong_verification_enabled`, 

207 # `postal_verification_enabled`), which can be flipped on remotely at any time, so prod must 

208 # always have their credentials present. 

209 if not self.IRIS_ID_PUBKEY or not self.IRIS_ID_SECRET or not self.VERIFICATION_DATA_PUBLIC_KEY: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true

210 raise Exception("Iris ID credentials must be configured in production") 

211 if ( 211 ↛ 218line 211 didn't jump to line 218 because the condition on line 211 was never true

212 not self.MYPOSTCARD_API_KEY 

213 or not self.MYPOSTCARD_USERNAME 

214 or not self.MYPOSTCARD_PASSWORD 

215 or not self.MYPOSTCARD_PRODUCT_CODE 

216 or not self.MYPOSTCARD_CAMPAIGN_ID 

217 ): 

218 raise Exception("MyPostcard API credentials must be configured in production") 

219 

220 if self.FEATURE_FLAGS_FILE_OVERRIDE_PATH: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 raise Exception("FEATURE_FLAGS_FILE_OVERRIDE_PATH is dev-only and must not be set in production") 

222 

223 if not self.FEATURE_FLAGS_FILE_OVERRIDE_PATH: 

224 if not self.GROWTHBOOK_CLIENT_KEY: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true

225 raise Exception("No GrowthBook client key configured") 

226 if not self.GROWTHBOOK_CACHE_PATH: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 raise Exception("No GrowthBook cache path configured") 

228 

229 if self.PYROSCOPE_ENABLED: 229 ↛ exitline 229 didn't return from function 'check' because the condition on line 229 was always true

230 if not self.PYROSCOPE_SERVER or not self.PYROSCOPE_AUTH_TOKEN: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true

231 raise Exception("No Pyroscope server or auth token but profiling enabled") 

232 

233 def load_from_env(self, env: Mapping[str, str]) -> None: 

234 """Populates this config object from environment variables.""" 

235 for var_name, var_type in Config.__annotations__.items(): 

236 env_value = env.get(var_name) 

237 if env_value is None: 

238 continue 

239 

240 attr_value: Any 

241 if var_type is str: 

242 attr_value = env_value 

243 elif var_type is int: 

244 if not env_value.isdigit(): 

245 raise ValueError(f"Invalid int for {var_name}") 

246 attr_value = int(env_value) 

247 elif var_type is bool: 

248 # 1 is true, 0 is false, everything else is illegal 

249 if env_value not in ("0", "1"): 

250 raise ValueError(f'Invalid bool for {var_name}, need "0" or "1"') 

251 attr_value = env_value == "1" 

252 elif var_type is bytes: 

253 # decode from hex 

254 attr_value = bytes.fromhex(env_value) 

255 # mypy erroneously reports an error below (https://github.com/python/mypy/issues/15630) 

256 elif typing.get_origin(var_type) is Literal: # type: ignore[comparison-overlap] 256 ↛ 263line 256 didn't jump to line 263 because the condition on line 256 was always true

257 # list of allowed string values 

258 options = typing.get_args(var_type) 

259 if env_value not in options: 

260 raise ValueError(f"Invalid value for {var_name}, need one of {', '.join(options)}") 

261 attr_value = env_value 

262 else: 

263 raise ValueError(f"Unsupported config type {var_type} for {var_name}") 

264 

265 self.__setattr__(var_name, attr_value) 

266 

267 # Weakly typed dict-like interface using env var names for backcompat. 

268 

269 def __getitem__(self, key: str) -> Any: 

270 """Weakly-typed indexer access using env var names for backcompat.""" 

271 if Config.__annotations__.get(key) is None: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true

272 raise KeyError(f"No such config key: {key}.") 

273 

274 try: 

275 return self.__getattribute__(key) 

276 except AttributeError: 

277 raise KeyError(f"Config key undefined and has no default: {key}.") from None 

278 

279 def __setitem__(self, key: str, value: Any) -> None: 

280 """Weakly-typed indexer access using env var names for backcompat.""" 

281 var_type = Config.__annotations__.get(key) 

282 if var_type is None: 

283 raise KeyError(f"No such config key: {key}.") 

284 

285 if typing.get_origin(var_type) is Literal: # type: ignore[comparison-overlap] 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 options = typing.get_args(var_type) 

287 if value not in options: 

288 raise ValueError(f"Invalid value for {key}, need one of {', '.join(options)}") 

289 elif not isinstance(value, var_type): 

290 raise TypeError(f"Invalid type for {key}: expected {var_type}, got {type(value)}") 

291 

292 self.__setattr__(key, value) 

293 

294 def __delitem__(self, key: str) -> None: 

295 """Weakly-typed indexer access using env var names for backcompat.""" 

296 self.__delattr__(key) 

297 

298 def get(self, key: str, default: Any = None) -> Any: 

299 """Weakly-typed indexer access using env var names for backcompat.""" 

300 try: 

301 return self.__getitem__(key) 

302 except KeyError: 

303 return default 

304 

305 

306config = Config() 

307config.load_from_env(os.environ)