Coverage for app/backend/src/couchers/config.py: 82%
124 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +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 # OpenTelemetry endpoint to send traces to
49 OPENTELEMETRY_ENDPOINT: str = ""
50 # Path to a GeoLite2-City.mmdb file for geocoding IPs in user session info
51 GEOLITE2_CITY_MMDB_FILE_LOCATION: str = ""
52 GEOLITE2_ASN_MMDB_FILE_LOCATION: str = ""
53 # Whether to try adding dummy data
54 ADD_DUMMY_DATA: bool
55 # Donations (gated at runtime by the `donations_enabled` feature flag)
56 STRIPE_API_KEY: str
57 STRIPE_WEBHOOK_SECRET: str
58 STRIPE_RECURRING_PRODUCT_ID: str
59 # Strong verification through Iris ID (gated at runtime by the `strong_verification_enabled` feature flag)
60 IRIS_ID_PUBKEY: str
61 IRIS_ID_SECRET: str
62 VERIFICATION_DATA_PUBLIC_KEY: bytes
63 # Postal verification (MyPostcard API; gated at runtime by the `postal_verification_enabled` feature flag)
64 MYPOSTCARD_API_KEY: str
65 MYPOSTCARD_USERNAME: str
66 MYPOSTCARD_PASSWORD: str
67 MYPOSTCARD_PRODUCT_CODE: str
68 MYPOSTCARD_CAMPAIGN_ID: str
69 # SMS (gated at runtime by the `sms_enabled` feature flag)
70 SMS_SENDER_ID: str
71 # Email
72 ENABLE_EMAIL: bool
73 # Sender name for outgoing notification emails e.g. "Couchers.org"
74 NOTIFICATION_EMAIL_SENDER: str
75 # Sender email, e.g. "notify@couchers.org"
76 NOTIFICATION_EMAIL_ADDRESS: str
77 # An optional prefix for email subject, e.g. [STAGING]
78 NOTIFICATION_PREFIX: str = ""
79 # Address to send emails about reported users
80 REPORTS_EMAIL_RECIPIENT: str
81 # Address to send contributor forms when users sign up/fill the form
82 CONTRIBUTOR_FORM_EMAIL_RECIPIENT: str
83 # Address to moderation notifications
84 MODS_EMAIL_RECIPIENT: str
85 # SMTP settings
86 SMTP_HOST: str
87 SMTP_PORT: int
88 SMTP_USERNAME: str
89 SMTP_PASSWORD: str
90 # Media server
91 ENABLE_MEDIA: bool
92 MEDIA_SERVER_SECRET_KEY: bytes
93 MEDIA_SERVER_BEARER_TOKEN: str
94 MEDIA_SERVER_BASE_URL: str
95 MEDIA_SERVER_UPLOAD_BASE_URL: str
96 # Bug reporting tool
97 BUG_TOOL_ENABLED: bool
98 BUG_TOOL_GITHUB_REPO: str
99 BUG_TOOL_GITHUB_USERNAME: str
100 BUG_TOOL_GITHUB_TOKEN: str
101 # Sentry
102 SENTRY_ENABLED: bool
103 SENTRY_URL: str
104 # Push notifications
105 PUSH_NOTIFICATIONS_ENABLED: bool
106 PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY: str
107 PUSH_NOTIFICATIONS_VAPID_SUBJECT: str
108 # Whether to initiate new activeness probes
109 ACTIVENESS_PROBES_ENABLED: bool
110 # Listmonk (mailing list, gated at runtime by the `listmonk_enabled` feature flag)
111 LISTMONK_BASE_URL: str
112 LISTMONK_API_USERNAME: str
113 LISTMONK_API_KEY: str
114 LISTMONK_LIST_ID: int
115 # Whether we're in test
116 IN_TEST: bool = False
117 # Dev-only override file; when set, flags are read from it instead of GrowthBook.
118 FEATURE_FLAGS_FILE_OVERRIDE_PATH: str = ""
119 # GrowthBook (feature flags)
120 GROWTHBOOK_API_HOST: str = "https://cdn.growthbook.io"
121 GROWTHBOOK_CLIENT_KEY: str = ""
122 # Disk path for the last-known-good feature payload, used as a cold-start fallback when GrowthBook
123 # is unreachable, so we never start on in-code defaults.
124 GROWTHBOOK_CACHE_PATH: str = ""
125 # Continuous profiling (Pyroscope). Profiling is gated at runtime by the `profiling_enabled` feature
126 # flag; PYROSCOPE_ENABLED is the per-deployment master switch.
127 PYROSCOPE_ENABLED: bool
128 PYROSCOPE_SERVER: str
129 PYROSCOPE_AUTH_TOKEN: str
130 # Moderation auto-approval deadline in seconds (0 to disable auto-approval)
131 MODERATION_AUTO_APPROVE_DEADLINE_SECONDS: int
132 # User ID of the bot user for automated moderation actions
133 MODERATION_BOT_USER_ID: int
134 # Enable development APIs (e.g., SendDevPushNotification)
135 ENABLE_DEV_APIS: bool
136 # Slack notifications
137 SLACK_ENABLED: bool
138 SLACK_BOT_TOKEN: str
139 SLACK_DONATIONS_CHANNEL: str
140 SLACK_MERCH_CHANNEL: str
142 def __init__(self) -> None:
143 # Initialize instance attributes with default values from class attributes.
144 for var_name in Config.__annotations__.keys():
145 try:
146 default_value = getattr(Config, var_name)
147 except AttributeError:
148 continue
149 self.__setattr__(var_name, default_value)
151 def copy_from(self, other: Config) -> None:
152 for var_name in Config.__annotations__.keys():
153 try:
154 attr_value = other.__getattribute__(var_name)
155 except AttributeError:
156 try:
157 self.__delattr__(var_name)
158 except AttributeError:
159 pass
160 continue
161 self.__setattr__(var_name, attr_value)
163 def copy(self) -> Config:
164 copy = Config()
165 copy.copy_from(self)
166 return copy
168 def check(self) -> None:
169 """Checks that the config is valid, i.e., all required values are set to valid values."""
170 for attr_name in Config.__annotations__.keys():
171 if not hasattr(self, attr_name): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 raise ValueError(f"Config value {attr_name} not set")
174 # each worker thread can hold two connections at once
175 if 2 * self.BACKGROUND_WORKER_THREADS_PER_PROCESS > DB_POOL_SIZE:
176 raise Exception(
177 f"BACKGROUND_WORKER_THREADS_PER_PROCESS ({self.BACKGROUND_WORKER_THREADS_PER_PROCESS}) must not "
178 f"exceed half of DB_POOL_SIZE ({DB_POOL_SIZE // 2}), or worker threads could exhaust the DB "
179 "connection pool"
180 )
182 if not self.DEV:
183 # checks for prod
184 if "https" not in self.BASE_URL: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 raise Exception("Production site must be over HTTPS")
186 if not self.ENABLE_EMAIL: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 raise Exception("Production site must have email enabled")
188 if self.IN_TEST: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 raise Exception("IN_TEST while not DEV")
191 # Donations are gated at runtime by the `donations_enabled` feature flag, which can be flipped on
192 # remotely at any time, so prod must always have Stripe credentials present so the feature can run.
193 if not self.STRIPE_API_KEY or not self.STRIPE_WEBHOOK_SECRET or not self.STRIPE_RECURRING_PRODUCT_ID: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 raise Exception("Stripe credentials must be configured in production")
196 # Listmonk is gated at runtime by the `listmonk_enabled` feature flag, which can be flipped on
197 # remotely at any time, so prod must always have the Listmonk credentials present.
198 if ( 198 ↛ 204line 198 didn't jump to line 204 because the condition on line 198 was never true
199 not self.LISTMONK_BASE_URL
200 or not self.LISTMONK_API_USERNAME
201 or not self.LISTMONK_API_KEY
202 or not self.LISTMONK_LIST_ID
203 ):
204 raise Exception("Listmonk credentials must be configured in production")
206 # The following features are gated at runtime by feature flags (`strong_verification_enabled`,
207 # `postal_verification_enabled`), which can be flipped on remotely at any time, so prod must
208 # always have their credentials present.
209 if not self.IRIS_ID_PUBKEY or not self.IRIS_ID_SECRET or not self.VERIFICATION_DATA_PUBLIC_KEY: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 raise Exception("Iris ID credentials must be configured in production")
211 if ( 211 ↛ 218line 211 didn't jump to line 218 because the condition on line 211 was never true
212 not self.MYPOSTCARD_API_KEY
213 or not self.MYPOSTCARD_USERNAME
214 or not self.MYPOSTCARD_PASSWORD
215 or not self.MYPOSTCARD_PRODUCT_CODE
216 or not self.MYPOSTCARD_CAMPAIGN_ID
217 ):
218 raise Exception("MyPostcard API credentials must be configured in production")
220 if self.FEATURE_FLAGS_FILE_OVERRIDE_PATH: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 raise Exception("FEATURE_FLAGS_FILE_OVERRIDE_PATH is dev-only and must not be set in production")
223 if not self.FEATURE_FLAGS_FILE_OVERRIDE_PATH:
224 if not self.GROWTHBOOK_CLIENT_KEY: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 raise Exception("No GrowthBook client key configured")
226 if not self.GROWTHBOOK_CACHE_PATH: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true
227 raise Exception("No GrowthBook cache path configured")
229 if self.PYROSCOPE_ENABLED: 229 ↛ exitline 229 didn't return from function 'check' because the condition on line 229 was always true
230 if not self.PYROSCOPE_SERVER or not self.PYROSCOPE_AUTH_TOKEN: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true
231 raise Exception("No Pyroscope server or auth token but profiling enabled")
233 def load_from_env(self, env: Mapping[str, str]) -> None:
234 """Populates this config object from environment variables."""
235 for var_name, var_type in Config.__annotations__.items():
236 env_value = env.get(var_name)
237 if env_value is None:
238 continue
240 attr_value: Any
241 if var_type is str:
242 attr_value = env_value
243 elif var_type is int:
244 if not env_value.isdigit():
245 raise ValueError(f"Invalid int for {var_name}")
246 attr_value = int(env_value)
247 elif var_type is bool:
248 # 1 is true, 0 is false, everything else is illegal
249 if env_value not in ("0", "1"):
250 raise ValueError(f'Invalid bool for {var_name}, need "0" or "1"')
251 attr_value = env_value == "1"
252 elif var_type is bytes:
253 # decode from hex
254 attr_value = bytes.fromhex(env_value)
255 # mypy erroneously reports an error below (https://github.com/python/mypy/issues/15630)
256 elif typing.get_origin(var_type) is Literal: # type: ignore[comparison-overlap] 256 ↛ 263line 256 didn't jump to line 263 because the condition on line 256 was always true
257 # list of allowed string values
258 options = typing.get_args(var_type)
259 if env_value not in options:
260 raise ValueError(f"Invalid value for {var_name}, need one of {', '.join(options)}")
261 attr_value = env_value
262 else:
263 raise ValueError(f"Unsupported config type {var_type} for {var_name}")
265 self.__setattr__(var_name, attr_value)
267 # Weakly typed dict-like interface using env var names for backcompat.
269 def __getitem__(self, key: str) -> Any:
270 """Weakly-typed indexer access using env var names for backcompat."""
271 if Config.__annotations__.get(key) is None: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 raise KeyError(f"No such config key: {key}.")
274 try:
275 return self.__getattribute__(key)
276 except AttributeError:
277 raise KeyError(f"Config key undefined and has no default: {key}.") from None
279 def __setitem__(self, key: str, value: Any) -> None:
280 """Weakly-typed indexer access using env var names for backcompat."""
281 var_type = Config.__annotations__.get(key)
282 if var_type is None:
283 raise KeyError(f"No such config key: {key}.")
285 if typing.get_origin(var_type) is Literal: # type: ignore[comparison-overlap] 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true
286 options = typing.get_args(var_type)
287 if value not in options:
288 raise ValueError(f"Invalid value for {key}, need one of {', '.join(options)}")
289 elif not isinstance(value, var_type):
290 raise TypeError(f"Invalid type for {key}: expected {var_type}, got {type(value)}")
292 self.__setattr__(key, value)
294 def __delitem__(self, key: str) -> None:
295 """Weakly-typed indexer access using env var names for backcompat."""
296 self.__delattr__(key)
298 def get(self, key: str, default: Any = None) -> Any:
299 """Weakly-typed indexer access using env var names for backcompat."""
300 try:
301 return self.__getitem__(key)
302 except KeyError:
303 return default
306config = Config()
307config.load_from_env(os.environ)