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
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +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 # 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
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)
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)
180 def copy(self) -> Config:
181 copy = Config()
182 copy.copy_from(self)
183 return copy
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")
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 )
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")
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")
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")
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")
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")
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")
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")
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
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}")
282 self.__setattr__(var_name, attr_value)
284 # Weakly typed dict-like interface using env var names for backcompat.
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}.")
291 try:
292 return self.__getattribute__(key)
293 except AttributeError:
294 raise KeyError(f"Config key undefined and has no default: {key}.") from None
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}.")
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)}")
309 self.__setattr__(key, value)
311 def __delitem__(self, key: str) -> None:
312 """Weakly-typed indexer access using env var names for backcompat."""
313 self.__delattr__(key)
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
323config = Config()
324config.load_from_env(os.environ)