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
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
1"""
2A simple config system
3"""
5import os
6import typing
7from collections.abc import Mapping
8from typing import Any, Literal
10from couchers.constants import DB_POOL_SIZE
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 """
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
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)
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)
185 def copy(self) -> Config:
186 copy = Config()
187 copy.copy_from(self)
188 return copy
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")
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 )
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")
207 if self.DEV and self.COOKIE_PARTITIONED:
208 raise Exception("COOKIE_PARTITIONED requires Secure cookies, which are only set outside DEV")
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")
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")
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")
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")
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")
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")
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")
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
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}")
298 self.__setattr__(var_name, attr_value)
300 # Weakly typed dict-like interface using env var names for backcompat.
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}.")
307 try:
308 return self.__getattribute__(key)
309 except AttributeError:
310 raise KeyError(f"Config key undefined and has no default: {key}.") from None
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}.")
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)}")
325 self.__setattr__(key, value)
327 def __delitem__(self, key: str) -> None:
328 """Weakly-typed indexer access using env var names for backcompat."""
329 self.__delattr__(key)
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
339config = Config()
340config.load_from_env(os.environ)