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

135 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +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 # SameSite attribute for cookies 

47 COOKIE_SAMESITE: Literal["Strict", "Lax", "None"] = "Lax" 

48 # Whether to set the Partitioned attribute (CHIPS) on cookies 

49 COOKIE_PARTITIONED: bool = False 

50 # SQLAlchemy database connection string 

51 DATABASE_CONNECTION_STRING: str 

52 # OTLP/HTTP traces endpoint to export spans to, e.g. https://traces.example.org/v1/traces 

53 OPENTELEMETRY_ENDPOINT: str = "" 

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

55 OPENTELEMETRY_AUTH_TOKEN: str = "" 

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

57 GEOLITE2_CITY_MMDB_FILE_LOCATION: str = "" 

58 GEOLITE2_ASN_MMDB_FILE_LOCATION: str = "" 

59 # Whether to try adding dummy data 

60 ADD_DUMMY_DATA: bool 

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

62 STRIPE_API_KEY: str 

63 STRIPE_WEBHOOK_SECRET: str 

64 STRIPE_RECURRING_PRODUCT_ID: str 

65 STRIPE_YEARLY_RECURRING_PRODUCT_ID: str 

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

67 IRIS_ID_PUBKEY: str 

68 IRIS_ID_SECRET: str 

69 VERIFICATION_DATA_PUBLIC_KEY: bytes 

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

71 MYPOSTCARD_API_KEY: str 

72 MYPOSTCARD_USERNAME: str 

73 MYPOSTCARD_PASSWORD: str 

74 MYPOSTCARD_PRODUCT_CODE: str 

75 MYPOSTCARD_CAMPAIGN_ID: str 

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

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

78 POSTAL_VERIFICATION_BYPASS_POST_AND_EMAIL_CODE_FOR_TESTING: bool = False 

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

80 SMS_SENDER_ID: str 

81 # Email 

82 ENABLE_EMAIL: bool 

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

84 NOTIFICATION_EMAIL_SENDER: str 

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

86 NOTIFICATION_EMAIL_ADDRESS: str 

87 # Sender name for moderation emails users can reply to 

88 MODERATION_EMAIL_SENDER: str 

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

90 MODERATION_EMAIL_ADDRESS: str 

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

92 NOTIFICATION_PREFIX: str = "" 

93 # Address to send emails about reported users 

94 REPORTS_EMAIL_RECIPIENT: str 

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

96 CONTRIBUTOR_FORM_EMAIL_RECIPIENT: str 

97 # Address to moderation notifications 

98 MODS_EMAIL_RECIPIENT: str 

99 # SMTP settings 

100 SMTP_HOST: str 

101 SMTP_PORT: int 

102 SMTP_USERNAME: str 

103 SMTP_PASSWORD: str 

104 # Media server 

105 ENABLE_MEDIA: bool 

106 MEDIA_SERVER_SECRET_KEY: bytes 

107 MEDIA_SERVER_BEARER_TOKEN: str 

108 MEDIA_SERVER_BASE_URL: str 

109 MEDIA_SERVER_UPLOAD_BASE_URL: str 

110 # Bug reporting tool 

111 BUG_TOOL_ENABLED: bool 

112 BUG_TOOL_GITHUB_REPO: str 

113 BUG_TOOL_GITHUB_USERNAME: str 

114 BUG_TOOL_GITHUB_TOKEN: str 

115 # Sentry 

116 SENTRY_ENABLED: bool 

117 SENTRY_URL: str 

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

119 # reporter's errors and session replay 

120 SENTRY_FRONTEND_PROJECT_ID: str 

121 # Push notifications 

122 PUSH_NOTIFICATIONS_ENABLED: bool 

123 PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY: str 

124 PUSH_NOTIFICATIONS_VAPID_SUBJECT: str 

125 # Whether to initiate new activeness probes 

126 ACTIVENESS_PROBES_ENABLED: bool 

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

128 LISTMONK_BASE_URL: str 

129 LISTMONK_API_USERNAME: str 

130 LISTMONK_API_KEY: str 

131 LISTMONK_LIST_ID: int 

132 # Whether we're in test 

133 IN_TEST: bool = False 

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

135 FEATURE_FLAGS_FILE_OVERRIDE_PATH: str = "" 

136 # GrowthBook (feature flags) 

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

138 GROWTHBOOK_CLIENT_KEY: str = "" 

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

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

141 GROWTHBOOK_CACHE_PATH: str = "" 

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

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

144 PYROSCOPE_ENABLED: bool 

145 PYROSCOPE_SERVER: str 

146 PYROSCOPE_AUTH_TOKEN: str 

147 # Default moderation auto-approval deadline in seconds, overridable per user (0 to disable auto-approval entirely) 

148 MODERATION_AUTO_APPROVE_DEADLINE_SECONDS: int 

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

150 MODERATION_BOT_USER_ID: int 

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

152 ENABLE_DEV_APIS: bool 

153 # Slack notifications 

154 SLACK_ENABLED: bool 

155 SLACK_BOT_TOKEN: str 

156 SLACK_DONATIONS_CHANNEL: str 

157 SLACK_MERCH_CHANNEL: str 

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

159 VALKEY_HOST: str = "" 

160 VALKEY_PORT: int = 6379 

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

162 RATE_LIMIT_IPV6_PREFIX: int = 64 

163 

164 def __init__(self) -> None: 

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

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

167 try: 

168 default_value = getattr(Config, var_name) 

169 except AttributeError: 

170 continue 

171 self.__setattr__(var_name, default_value) 

172 

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

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

175 try: 

176 attr_value = other.__getattribute__(var_name) 

177 except AttributeError: 

178 try: 

179 self.__delattr__(var_name) 

180 except AttributeError: 

181 pass 

182 continue 

183 self.__setattr__(var_name, attr_value) 

184 

185 def copy(self) -> Config: 

186 copy = Config() 

187 copy.copy_from(self) 

188 return copy 

189 

190 def check(self) -> None: 

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

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

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

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

195 

196 # each worker thread can hold two connections at once 

197 if 2 * self.BACKGROUND_WORKER_THREADS_PER_PROCESS > DB_POOL_SIZE: 

198 raise Exception( 

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

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

201 "connection pool" 

202 ) 

203 

204 if self.DEV and self.COOKIE_SAMESITE == "None": 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true

205 raise Exception("COOKIE_SAMESITE=None requires Secure cookies, which are only set outside DEV") 

206 

207 if self.DEV and self.COOKIE_PARTITIONED: 

208 raise Exception("COOKIE_PARTITIONED requires Secure cookies, which are only set outside DEV") 

209 

210 if not self.DEV: 

211 # checks for prod 

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

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

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

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

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

217 raise Exception("IN_TEST while not DEV") 

218 

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

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

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

222 not self.STRIPE_API_KEY 

223 or not self.STRIPE_WEBHOOK_SECRET 

224 or not self.STRIPE_RECURRING_PRODUCT_ID 

225 or not self.STRIPE_YEARLY_RECURRING_PRODUCT_ID 

226 ): 

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

228 

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

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

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

232 not self.LISTMONK_BASE_URL 

233 or not self.LISTMONK_API_USERNAME 

234 or not self.LISTMONK_API_KEY 

235 or not self.LISTMONK_LIST_ID 

236 ): 

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

238 

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

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

241 # always have their credentials present. 

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

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

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

245 not self.MYPOSTCARD_API_KEY 

246 or not self.MYPOSTCARD_USERNAME 

247 or not self.MYPOSTCARD_PASSWORD 

248 or not self.MYPOSTCARD_PRODUCT_CODE 

249 or not self.MYPOSTCARD_CAMPAIGN_ID 

250 ): 

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

252 

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

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

255 

256 if not self.FEATURE_FLAGS_FILE_OVERRIDE_PATH: 

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

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

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

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

261 

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

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

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

265 

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

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

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

269 env_value = env.get(var_name) 

270 if env_value is None: 

271 continue 

272 

273 attr_value: Any 

274 if var_type is str: 

275 attr_value = env_value 

276 elif var_type is int: 

277 if not env_value.isdigit(): 

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

279 attr_value = int(env_value) 

280 elif var_type is bool: 

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

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

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

284 attr_value = env_value == "1" 

285 elif var_type is bytes: 

286 # decode from hex 

287 attr_value = bytes.fromhex(env_value) 

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

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

290 # list of allowed string values 

291 options = typing.get_args(var_type) # type: ignore[unreachable] 

292 if env_value not in options: 

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

294 attr_value = env_value 

295 else: 

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

297 

298 self.__setattr__(var_name, attr_value) 

299 

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

301 

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

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

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

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

306 

307 try: 

308 return self.__getattribute__(key) 

309 except AttributeError: 

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

311 

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

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

314 var_type = Config.__annotations__.get(key) 

315 if var_type is None: 

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

317 

318 if typing.get_origin(var_type) is Literal: # type: ignore[comparison-overlap] 

319 options = typing.get_args(var_type) # type: ignore[unreachable] 

320 if value not in options: 

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

322 elif not isinstance(value, var_type): 

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

324 

325 self.__setattr__(key, value) 

326 

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

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

329 self.__delattr__(key) 

330 

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

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

333 try: 

334 return self.__getitem__(key) 

335 except KeyError: 

336 return default 

337 

338 

339config = Config() 

340config.load_from_env(os.environ)