Coverage for src / couchers / config.py: 43%
56 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-29 02:10 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-29 02:10 +0000
1"""
2A simple config system
3"""
5import os
6from typing import Any
8CONFIG_T = list[tuple[str, type | list[str]] | tuple[str, type | list[str], str | int]]
10# Allowed config options, as tuples (name, type, default).
11# All fields are required
12CONFIG_OPTIONS: CONFIG_T = [
13 # Whether we're in dev mode
14 ("DEV", bool),
15 # Whether we're `api` mode (answering API queries) or `scheduler` (scheduling background jobs), or `worker`
16 # (servicing background jobs). Can also be set to `all` to do all three simultaneously
17 ("ROLE", ["api", "scheduler", "worker", "all"], "all"),
18 # number of bg worker processes, requires worker or all above
19 ("BACKGROUND_WORKER_COUNT", int, 2),
20 # Version string
21 ("VERSION", str, "unknown"),
22 # Base URL of frontend, e.g. https://couchers.org
23 ("BASE_URL", str),
24 # URL of the backend, e.g. https://api.couchers.org
25 ("BACKEND_BASE_URL", str),
26 # URL of the console, e.g. https://console.couchers.org
27 ("CONSOLE_BASE_URL", str),
28 # URL of the merch shop, e.g. https://shop.couchershq.org
29 ("MERCH_SHOP_URL", str),
30 # Used to generate a variety of secrets
31 ("SECRET", bytes),
32 # Domain that cookies should set as their domain value
33 ("COOKIE_DOMAIN", str),
34 # SQLAlchemy database connection string
35 ("DATABASE_CONNECTION_STRING", str),
36 # OpenTelemetry endpoint to send traces to
37 ("OPENTELEMETRY_ENDPOINT", str, ""),
38 # Path to a GeoLite2-City.mmdb file for geocoding IPs in user session info
39 ("GEOLITE2_CITY_MMDB_FILE_LOCATION", str, ""),
40 ("GEOLITE2_ASN_MMDB_FILE_LOCATION", str, ""),
41 # Whether to try adding dummy data
42 ("ADD_DUMMY_DATA", bool),
43 # Donations
44 ("ENABLE_DONATIONS", bool),
45 ("STRIPE_API_KEY", str),
46 ("STRIPE_WEBHOOK_SECRET", str),
47 ("STRIPE_RECURRING_PRODUCT_ID", str),
48 # Strong verification through Iris ID
49 ("ENABLE_STRONG_VERIFICATION", bool),
50 ("IRIS_ID_PUBKEY", str),
51 ("IRIS_ID_SECRET", str),
52 ("VERIFICATION_DATA_PUBLIC_KEY", bytes),
53 # Postal verification
54 ("ENABLE_POSTAL_VERIFICATION", bool),
55 # SMS
56 ("ENABLE_SMS", bool),
57 ("SMS_SENDER_ID", str),
58 # Email
59 ("ENABLE_EMAIL", bool),
60 # Sender name for outgoing notification emails e.g. "Couchers.org"
61 ("NOTIFICATION_EMAIL_SENDER", str),
62 # Sender email, e.g. "notify@couchers.org"
63 ("NOTIFICATION_EMAIL_ADDRESS", str),
64 # An optional prefix for email subject, e.g. [STAGING]
65 ("NOTIFICATION_PREFIX", str, ""),
66 ("ENABLE_NOTIFICATION_TRANSLATIONS", bool),
67 # Address to send emails about reported users
68 ("REPORTS_EMAIL_RECIPIENT", str),
69 # Address to send contributor forms when users sign up/fill the form
70 ("CONTRIBUTOR_FORM_EMAIL_RECIPIENT", str),
71 # Address to moderation notifications
72 ("MODS_EMAIL_RECIPIENT", str),
73 # SMTP settings
74 ("SMTP_HOST", str),
75 ("SMTP_PORT", int),
76 ("SMTP_USERNAME", str),
77 ("SMTP_PASSWORD", str),
78 # Media server
79 ("ENABLE_MEDIA", bool),
80 ("MEDIA_SERVER_SECRET_KEY", bytes),
81 ("MEDIA_SERVER_BEARER_TOKEN", str),
82 ("MEDIA_SERVER_BASE_URL", str),
83 ("MEDIA_SERVER_UPLOAD_BASE_URL", str),
84 # Bug reporting tool
85 ("BUG_TOOL_ENABLED", bool),
86 ("BUG_TOOL_GITHUB_REPO", str),
87 ("BUG_TOOL_GITHUB_USERNAME", str),
88 ("BUG_TOOL_GITHUB_TOKEN", str),
89 # Sentry
90 ("SENTRY_ENABLED", bool),
91 ("SENTRY_URL", str),
92 # Push notifications
93 ("PUSH_NOTIFICATIONS_ENABLED", bool),
94 ("PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY", str),
95 ("PUSH_NOTIFICATIONS_VAPID_SUBJECT", str),
96 # Whether to initiate new activeness probes
97 ("ACTIVENESS_PROBES_ENABLED", bool),
98 # Listmonk (mailing list)
99 ("LISTMONK_ENABLED", bool),
100 ("LISTMONK_BASE_URL", str),
101 ("LISTMONK_API_USERNAME", str),
102 ("LISTMONK_API_KEY", str),
103 ("LISTMONK_LIST_ID", int),
104 # Google recaptcha antibot
105 ("RECAPTHCA_ENABLED", bool),
106 ("RECAPTHCA_PROJECT_ID", str),
107 ("RECAPTHCA_API_KEY", str),
108 ("RECAPTHCA_SITE_KEY", str),
109 # Whether we're in test
110 ("IN_TEST", bool, "0"),
111 # Experimentation (feature flags via Statsig)
112 ("EXPERIMENTATION_ENABLED", bool, "0"),
113 # When enabled, all feature gates return True (useful for development/testing)
114 ("EXPERIMENTATION_PASS_ALL_GATES", bool, "0"),
115 # Statsig SDK configuration
116 ("STATSIG_SERVER_SECRET_KEY", str, ""),
117 ("STATSIG_ENVIRONMENT", str, "development"),
118 # Moderation auto-approval deadline in seconds (0 to disable auto-approval)
119 ("MODERATION_AUTO_APPROVE_DEADLINE_SECONDS", int),
120 # User ID of the bot user for automated moderation actions
121 ("MODERATION_BOT_USER_ID", int),
122 # Enable development APIs (e.g., SendDevPushNotification)
123 ("ENABLE_DEV_APIS", bool),
124]
127def check_config(cfg: dict[str, Any]) -> None:
128 for name, *_ in CONFIG_OPTIONS:
129 if name not in cfg:
130 raise ValueError(f"Required config value {name} not set")
132 if not cfg["DEV"]:
133 # checks for prod
134 if "https" not in cfg["BASE_URL"]:
135 raise Exception("Production site must be over HTTPS")
136 if not cfg["ENABLE_EMAIL"]:
137 raise Exception("Production site must have email enabled")
138 if not cfg["ENABLE_SMS"]:
139 raise Exception("Production site must have SMS enabled")
140 if cfg["IN_TEST"]:
141 raise Exception("IN_TEST while not DEV")
143 if cfg["ENABLE_DONATIONS"]:
144 if not cfg["STRIPE_API_KEY"] or not cfg["STRIPE_WEBHOOK_SECRET"] or not cfg["STRIPE_RECURRING_PRODUCT_ID"]:
145 raise Exception("No Stripe API key/recurring donation ID but donations enabled")
147 if cfg["ENABLE_STRONG_VERIFICATION"]:
148 if not cfg["IRIS_ID_PUBKEY"] or not cfg["IRIS_ID_SECRET"] or not cfg["VERIFICATION_DATA_PUBLIC_KEY"]:
149 raise Exception("No Iris ID pubkey/secret or verification data pubkey but strong verification enabled")
151 if cfg["EXPERIMENTATION_ENABLED"]:
152 if not cfg["STATSIG_SERVER_SECRET_KEY"]:
153 raise Exception("No Statsig server secret key but experimentation enabled")
156def make_config() -> dict[str, Any]:
157 cfg = {}
159 for config_option in CONFIG_OPTIONS:
160 if len(config_option) == 2:
161 name, type_ = config_option
162 optional = False
163 elif len(config_option) == 3: 163 ↛ 167line 163 didn't jump to line 167 because the condition on line 163 was always true
164 name, type_, default_value = config_option
165 optional = True
166 else:
167 raise ValueError("Invalid CONFIG_OPTIONS")
169 value: str | int | bytes | None = os.getenv(name)
171 if not value:
172 if not optional:
173 # config value not set - will cause a KeyError when trying
174 # to access it.
175 continue
176 else:
177 value = default_value
179 if type_ is bool:
180 # 1 is true, 0 is false, everything else is illegal
181 if value not in ["0", "1"]: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
182 raise ValueError(f'Invalid bool for {name}, need "0" or "1"')
183 value = value == "1"
184 elif type_ is bytes: 184 ↛ 186line 184 didn't jump to line 186 because the condition on line 184 was never true
185 # decode from hex
186 if not isinstance(value, str):
187 raise RuntimeError(type(value))
188 value = bytes.fromhex(value)
189 elif isinstance(type_, list):
190 # list of allowed string values
191 if value not in type_: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true
192 raise ValueError(f"Invalid value for {name}, need one of {', '.join(type_)}")
193 else:
194 value = type_(value)
196 cfg[name] = value
198 return cfg
201config = make_config()