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

120 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +0000

1""" 

2A simple config system 

3""" 

4 

5import os 

6import typing 

7from collections.abc import Mapping 

8from typing import Any, Literal 

9 

10 

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

12class Config: 

13 """ 

14 Defines strongly-typed application config values, 

15 initializable from matching environment variables, 

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

17 """ 

18 

19 # Whether we're in dev mode 

20 DEV: bool 

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

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

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

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

25 BACKGROUND_WORKER_COUNT: int = 2 

26 # Version string 

27 VERSION: str = "unknown" 

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

29 COMMIT_TIMESTAMP: str = "" 

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

31 BASE_URL: str 

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

33 BACKEND_BASE_URL: str 

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

35 CONSOLE_BASE_URL: str 

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

37 MERCH_SHOP_URL: str 

38 # Used to generate a variety of secrets 

39 SECRET: bytes 

40 # Domain that cookies should set as their domain value 

41 COOKIE_DOMAIN: str 

42 # SQLAlchemy database connection string 

43 DATABASE_CONNECTION_STRING: str 

44 # OpenTelemetry endpoint to send traces to 

45 OPENTELEMETRY_ENDPOINT: str = "" 

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

47 GEOLITE2_CITY_MMDB_FILE_LOCATION: str = "" 

48 GEOLITE2_ASN_MMDB_FILE_LOCATION: str = "" 

49 # Whether to try adding dummy data 

50 ADD_DUMMY_DATA: bool 

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

52 STRIPE_API_KEY: str 

53 STRIPE_WEBHOOK_SECRET: str 

54 STRIPE_RECURRING_PRODUCT_ID: str 

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

56 IRIS_ID_PUBKEY: str 

57 IRIS_ID_SECRET: str 

58 VERIFICATION_DATA_PUBLIC_KEY: bytes 

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

60 MYPOSTCARD_API_KEY: str 

61 MYPOSTCARD_USERNAME: str 

62 MYPOSTCARD_PASSWORD: str 

63 MYPOSTCARD_PRODUCT_CODE: str 

64 MYPOSTCARD_CAMPAIGN_ID: str 

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

66 SMS_SENDER_ID: str 

67 # Email 

68 ENABLE_EMAIL: bool 

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

70 NOTIFICATION_EMAIL_SENDER: str 

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

72 NOTIFICATION_EMAIL_ADDRESS: str 

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

74 NOTIFICATION_PREFIX: str = "" 

75 # Address to send emails about reported users 

76 REPORTS_EMAIL_RECIPIENT: str 

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

78 CONTRIBUTOR_FORM_EMAIL_RECIPIENT: str 

79 # Address to moderation notifications 

80 MODS_EMAIL_RECIPIENT: str 

81 # SMTP settings 

82 SMTP_HOST: str 

83 SMTP_PORT: int 

84 SMTP_USERNAME: str 

85 SMTP_PASSWORD: str 

86 # Media server 

87 ENABLE_MEDIA: bool 

88 MEDIA_SERVER_SECRET_KEY: bytes 

89 MEDIA_SERVER_BEARER_TOKEN: str 

90 MEDIA_SERVER_BASE_URL: str 

91 MEDIA_SERVER_UPLOAD_BASE_URL: str 

92 # Bug reporting tool 

93 BUG_TOOL_ENABLED: bool 

94 BUG_TOOL_GITHUB_REPO: str 

95 BUG_TOOL_GITHUB_USERNAME: str 

96 BUG_TOOL_GITHUB_TOKEN: str 

97 # Sentry 

98 SENTRY_ENABLED: bool 

99 SENTRY_URL: str 

100 # Push notifications 

101 PUSH_NOTIFICATIONS_ENABLED: bool 

102 PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY: str 

103 PUSH_NOTIFICATIONS_VAPID_SUBJECT: str 

104 # Whether to initiate new activeness probes 

105 ACTIVENESS_PROBES_ENABLED: bool 

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

107 LISTMONK_BASE_URL: str 

108 LISTMONK_API_USERNAME: str 

109 LISTMONK_API_KEY: str 

110 LISTMONK_LIST_ID: int 

111 # Whether we're in test 

112 IN_TEST: bool = False 

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

114 FEATURE_FLAGS_FILE_OVERRIDE_PATH: str = "" 

115 # GrowthBook (feature flags) 

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

117 GROWTHBOOK_CLIENT_KEY: str = "" 

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

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

120 GROWTHBOOK_CACHE_PATH: str = "" 

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

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

123 PYROSCOPE_ENABLED: bool 

124 PYROSCOPE_SERVER: str 

125 PYROSCOPE_AUTH_TOKEN: str 

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

127 MODERATION_AUTO_APPROVE_DEADLINE_SECONDS: int 

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

129 MODERATION_BOT_USER_ID: int 

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

131 ENABLE_DEV_APIS: bool 

132 # Slack notifications 

133 SLACK_ENABLED: bool 

134 SLACK_BOT_TOKEN: str 

135 SLACK_DONATIONS_CHANNEL: str 

136 SLACK_MERCH_CHANNEL: str 

137 

138 def __init__(self) -> None: 

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

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

141 try: 

142 default_value = getattr(Config, var_name) 

143 except AttributeError: 

144 continue 

145 self.__setattr__(var_name, default_value) 

146 

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

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

149 try: 

150 attr_value = other.__getattribute__(var_name) 

151 except AttributeError: 

152 try: 

153 self.__delattr__(var_name) 

154 except AttributeError: 

155 pass 

156 continue 

157 self.__setattr__(var_name, attr_value) 

158 

159 def copy(self) -> Config: 

160 copy = Config() 

161 copy.copy_from(self) 

162 return copy 

163 

164 def check(self) -> None: 

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

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

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

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

169 

170 if not self.DEV: 

171 # checks for prod 

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

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

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

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

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

177 raise Exception("IN_TEST while not DEV") 

178 

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

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

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

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

183 

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

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

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

187 not self.LISTMONK_BASE_URL 

188 or not self.LISTMONK_API_USERNAME 

189 or not self.LISTMONK_API_KEY 

190 or not self.LISTMONK_LIST_ID 

191 ): 

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

193 

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

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

196 # always have their credentials present. 

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

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

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

200 not self.MYPOSTCARD_API_KEY 

201 or not self.MYPOSTCARD_USERNAME 

202 or not self.MYPOSTCARD_PASSWORD 

203 or not self.MYPOSTCARD_PRODUCT_CODE 

204 or not self.MYPOSTCARD_CAMPAIGN_ID 

205 ): 

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

207 

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

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

210 

211 if not self.FEATURE_FLAGS_FILE_OVERRIDE_PATH: 

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

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

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

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

216 

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

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

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

220 

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

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

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

224 env_value = env.get(var_name) 

225 if env_value is None: 

226 continue 

227 

228 attr_value: Any 

229 if var_type is str: 

230 attr_value = env_value 

231 elif var_type is int: 

232 if not env_value.isdigit(): 

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

234 attr_value = int(env_value) 

235 elif var_type is bool: 

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

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

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

239 attr_value = env_value == "1" 

240 elif var_type is bytes: 

241 # decode from hex 

242 attr_value = bytes.fromhex(env_value) 

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

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

245 # list of allowed string values 

246 options = typing.get_args(var_type) 

247 if env_value not in options: 

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

249 attr_value = env_value 

250 else: 

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

252 

253 self.__setattr__(var_name, attr_value) 

254 

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

256 

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

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

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

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

261 

262 try: 

263 return self.__getattribute__(key) 

264 except AttributeError: 

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

266 

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

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

269 var_type = Config.__annotations__.get(key) 

270 if var_type is None: 

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

272 

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

274 options = typing.get_args(var_type) 

275 if value not in options: 

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

277 elif not isinstance(value, var_type): 

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

279 

280 self.__setattr__(key, value) 

281 

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

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

284 self.__delattr__(key) 

285 

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

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

288 try: 

289 return self.__getitem__(key) 

290 except KeyError: 

291 return default 

292 

293 

294config = Config() 

295config.load_from_env(os.environ)