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

129 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +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 # OTLP/HTTP traces endpoint to export spans to, e.g. https://traces.example.org/v1/traces 

49 OPENTELEMETRY_ENDPOINT: str = "" 

50 # Bearer token for the traces endpoint; empty for an unauthenticated collector (e.g. in dev) 

51 OPENTELEMETRY_AUTH_TOKEN: str = "" 

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

53 GEOLITE2_CITY_MMDB_FILE_LOCATION: str = "" 

54 GEOLITE2_ASN_MMDB_FILE_LOCATION: str = "" 

55 # Whether to try adding dummy data 

56 ADD_DUMMY_DATA: bool 

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

58 STRIPE_API_KEY: str 

59 STRIPE_WEBHOOK_SECRET: str 

60 STRIPE_RECURRING_PRODUCT_ID: str 

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

62 IRIS_ID_PUBKEY: str 

63 IRIS_ID_SECRET: str 

64 VERIFICATION_DATA_PUBLIC_KEY: bytes 

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

66 MYPOSTCARD_API_KEY: str 

67 MYPOSTCARD_USERNAME: str 

68 MYPOSTCARD_PASSWORD: str 

69 MYPOSTCARD_PRODUCT_CODE: str 

70 MYPOSTCARD_CAMPAIGN_ID: str 

71 # Whether to email users their verification code instead of posting them a postcard. Hands postal 

72 # verification to anyone who asks for it, so only non-prod deployments may set it. 

73 POSTAL_VERIFICATION_BYPASS_POST_AND_EMAIL_CODE_FOR_TESTING: bool = False 

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

75 SMS_SENDER_ID: str 

76 # Email 

77 ENABLE_EMAIL: bool 

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

79 NOTIFICATION_EMAIL_SENDER: str 

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

81 NOTIFICATION_EMAIL_ADDRESS: str 

82 # Sender name for moderation emails users can reply to 

83 MODERATION_EMAIL_SENDER: str 

84 # Sender email for moderation emails, a monitored mailbox users can reply to 

85 MODERATION_EMAIL_ADDRESS: str 

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

87 NOTIFICATION_PREFIX: str = "" 

88 # Address to send emails about reported users 

89 REPORTS_EMAIL_RECIPIENT: str 

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

91 CONTRIBUTOR_FORM_EMAIL_RECIPIENT: str 

92 # Address to moderation notifications 

93 MODS_EMAIL_RECIPIENT: str 

94 # SMTP settings 

95 SMTP_HOST: str 

96 SMTP_PORT: int 

97 SMTP_USERNAME: str 

98 SMTP_PASSWORD: str 

99 # Media server 

100 ENABLE_MEDIA: bool 

101 MEDIA_SERVER_SECRET_KEY: bytes 

102 MEDIA_SERVER_BEARER_TOKEN: str 

103 MEDIA_SERVER_BASE_URL: str 

104 MEDIA_SERVER_UPLOAD_BASE_URL: str 

105 # Bug reporting tool 

106 BUG_TOOL_ENABLED: bool 

107 BUG_TOOL_GITHUB_REPO: str 

108 BUG_TOOL_GITHUB_USERNAME: str 

109 BUG_TOOL_GITHUB_TOKEN: str 

110 # Sentry 

111 SENTRY_ENABLED: bool 

112 SENTRY_URL: str 

113 # Sentry project id the web frontend reports to, used to deep-link bug reports to the 

114 # reporter's errors and session replay 

115 SENTRY_FRONTEND_PROJECT_ID: str 

116 # Push notifications 

117 PUSH_NOTIFICATIONS_ENABLED: bool 

118 PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY: str 

119 PUSH_NOTIFICATIONS_VAPID_SUBJECT: str 

120 # Whether to initiate new activeness probes 

121 ACTIVENESS_PROBES_ENABLED: bool 

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

123 LISTMONK_BASE_URL: str 

124 LISTMONK_API_USERNAME: str 

125 LISTMONK_API_KEY: str 

126 LISTMONK_LIST_ID: int 

127 # Whether we're in test 

128 IN_TEST: bool = False 

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

130 FEATURE_FLAGS_FILE_OVERRIDE_PATH: str = "" 

131 # GrowthBook (feature flags) 

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

133 GROWTHBOOK_CLIENT_KEY: str = "" 

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

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

136 GROWTHBOOK_CACHE_PATH: str = "" 

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

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

139 PYROSCOPE_ENABLED: bool 

140 PYROSCOPE_SERVER: str 

141 PYROSCOPE_AUTH_TOKEN: str 

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

143 MODERATION_AUTO_APPROVE_DEADLINE_SECONDS: int 

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

145 MODERATION_BOT_USER_ID: int 

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

147 ENABLE_DEV_APIS: bool 

148 # Slack notifications 

149 SLACK_ENABLED: bool 

150 SLACK_BOT_TOKEN: str 

151 SLACK_DONATIONS_CHANNEL: str 

152 SLACK_MERCH_CHANNEL: str 

153 # an empty host turns rate limiting off entirely, see docs/rate-limit-design.md 

154 VALKEY_HOST: str = "" 

155 VALKEY_PORT: int = 6379 

156 # prefix length per-IP counters are keyed at for IPv6; IPv4 is always /32 

157 RATE_LIMIT_IPV6_PREFIX: int = 64 

158 

159 def __init__(self) -> None: 

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

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

162 try: 

163 default_value = getattr(Config, var_name) 

164 except AttributeError: 

165 continue 

166 self.__setattr__(var_name, default_value) 

167 

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

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

170 try: 

171 attr_value = other.__getattribute__(var_name) 

172 except AttributeError: 

173 try: 

174 self.__delattr__(var_name) 

175 except AttributeError: 

176 pass 

177 continue 

178 self.__setattr__(var_name, attr_value) 

179 

180 def copy(self) -> Config: 

181 copy = Config() 

182 copy.copy_from(self) 

183 return copy 

184 

185 def check(self) -> None: 

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

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

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

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

190 

191 # each worker thread can hold two connections at once 

192 if 2 * self.BACKGROUND_WORKER_THREADS_PER_PROCESS > DB_POOL_SIZE: 

193 raise Exception( 

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

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

196 "connection pool" 

197 ) 

198 

199 if not self.DEV: 

200 # checks for prod 

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

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

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

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

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

206 raise Exception("IN_TEST while not DEV") 

207 

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

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

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

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

212 

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

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

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

216 not self.LISTMONK_BASE_URL 

217 or not self.LISTMONK_API_USERNAME 

218 or not self.LISTMONK_API_KEY 

219 or not self.LISTMONK_LIST_ID 

220 ): 

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

222 

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

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

225 # always have their credentials present. 

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

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

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

229 not self.MYPOSTCARD_API_KEY 

230 or not self.MYPOSTCARD_USERNAME 

231 or not self.MYPOSTCARD_PASSWORD 

232 or not self.MYPOSTCARD_PRODUCT_CODE 

233 or not self.MYPOSTCARD_CAMPAIGN_ID 

234 ): 

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

236 

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

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

239 

240 if not self.FEATURE_FLAGS_FILE_OVERRIDE_PATH: 

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

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

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

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

245 

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

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

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

249 

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

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

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

253 env_value = env.get(var_name) 

254 if env_value is None: 

255 continue 

256 

257 attr_value: Any 

258 if var_type is str: 

259 attr_value = env_value 

260 elif var_type is int: 

261 if not env_value.isdigit(): 

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

263 attr_value = int(env_value) 

264 elif var_type is bool: 

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

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

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

268 attr_value = env_value == "1" 

269 elif var_type is bytes: 

270 # decode from hex 

271 attr_value = bytes.fromhex(env_value) 

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

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

274 # list of allowed string values 

275 options = typing.get_args(var_type) 

276 if env_value not in options: 

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

278 attr_value = env_value 

279 else: 

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

281 

282 self.__setattr__(var_name, attr_value) 

283 

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

285 

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

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

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

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

290 

291 try: 

292 return self.__getattribute__(key) 

293 except AttributeError: 

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

295 

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

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

298 var_type = Config.__annotations__.get(key) 

299 if var_type is None: 

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

301 

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

303 options = typing.get_args(var_type) 

304 if value not in options: 

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

306 elif not isinstance(value, var_type): 

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

308 

309 self.__setattr__(key, value) 

310 

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

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

313 self.__delattr__(key) 

314 

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

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

317 try: 

318 return self.__getitem__(key) 

319 except KeyError: 

320 return default 

321 

322 

323config = Config() 

324config.load_from_env(os.environ)