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
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1"""
2A simple config system
3"""
5import os
6import typing
7from collections.abc import Mapping
8from typing import Any, Literal
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 """
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
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)
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)
159 def copy(self) -> Config:
160 copy = Config()
161 copy.copy_from(self)
162 return copy
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")
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")
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")
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")
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")
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")
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")
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")
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
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}")
253 self.__setattr__(var_name, attr_value)
255 # Weakly typed dict-like interface using env var names for backcompat.
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}.")
262 try:
263 return self.__getattribute__(key)
264 except AttributeError:
265 raise KeyError(f"Config key undefined and has no default: {key}.") from None
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}.")
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)}")
280 self.__setattr__(key, value)
282 def __delitem__(self, key: str) -> None:
283 """Weakly-typed indexer access using env var names for backcompat."""
284 self.__delattr__(key)
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
294config = Config()
295config.load_from_env(os.environ)