1from __future__ import annotations
2
3import importlib
4import json
5import os
6import time
7import types
8import typing
9from importlib.util import find_spec
10from pathlib import Path
11
12from plain.exceptions import ImproperlyConfigured
13from plain.packages import PackageConfig
14from plain.runtime.secret import Secret
15
16_ENVIRONMENT_VARIABLE = "PLAIN_SETTINGS_MODULE"
17_DEFAULT_ENV_SETTINGS_PREFIXES = ["PLAIN_"]
18_CUSTOM_SETTINGS_PREFIX = "APP_"
19
20
21class Settings:
22 """
23 Settings and configuration for Plain.
24
25 This class handles loading settings from the module specified by the
26 PLAIN_SETTINGS_MODULE environment variable, as well as from default settings,
27 environment variables, and explicit settings in the settings module.
28
29 Lazy initialization is implemented to defer loading until settings are first accessed.
30 """
31
32 def __init__(self, settings_module: str | None = None):
33 self._settings_module = settings_module
34 self._settings: dict[str, SettingDefinition] = {}
35 self._errors: list[str] = [] # Collect configuration errors
36 self._env_prefixes: list[str] = [] # Configured env prefixes
37 self.configured = False
38
39 def _setup(self) -> None:
40 if self.configured:
41 return
42 else:
43 self.configured = True
44
45 self._settings = {} # Maps setting names to SettingDefinition instances
46
47 # Determine the settings module
48 if self._settings_module is None:
49 self._settings_module = os.environ.get(
50 _ENVIRONMENT_VARIABLE, "app.settings"
51 )
52
53 # First load the global settings from plain
54 self._load_module_settings(
55 importlib.import_module("plain.runtime.global_settings")
56 )
57
58 # Import the user's settings module
59 try:
60 mod = importlib.import_module(self._settings_module)
61 except ImportError as e:
62 raise ImproperlyConfigured(
63 f"Could not import settings '{self._settings_module}': {e}"
64 )
65
66 # Keep a reference to the settings.py module path
67 assert mod.__file__ is not None
68 self.path = Path(mod.__file__).resolve()
69
70 # Get env prefixes from settings module (must be configured in settings.py, not env)
71 self._env_prefixes = getattr(
72 mod, "ENV_SETTINGS_PREFIXES", _DEFAULT_ENV_SETTINGS_PREFIXES
73 )
74
75 # Load default settings from installed packages
76 self._load_default_settings(mod)
77 # Load explicit settings from the settings module
78 self._load_explicit_settings(mod)
79 # Load environment settings (last, so env vars override settings.py)
80 self._load_env_settings()
81 # Apply timezone after all settings are loaded
82 self._apply_timezone()
83 # Check for any required settings that are missing
84 self._check_required_settings()
85 # Check for any collected errors
86 self._raise_errors_if_any()
87
88 def _load_module_settings(self, module: types.ModuleType) -> None:
89 annotations = getattr(module, "__annotations__", {})
90
91 for setting in dir(module):
92 if setting.isupper() and setting not in self._IGNORED_NAMES:
93 if setting in self._settings:
94 self._errors.append(f"Duplicate setting '{setting}'.")
95 continue
96
97 setting_value = getattr(module, setting)
98 self._settings[setting] = SettingDefinition(
99 name=setting,
100 default_value=setting_value,
101 annotation=annotations.get(setting, None),
102 module=module,
103 )
104
105 self._register_annotation_only_settings(module, require_app_prefix=False)
106
107 def _load_default_settings(self, settings_module: types.ModuleType) -> None:
108 for entry in getattr(settings_module, "INSTALLED_PACKAGES", []):
109 if isinstance(entry, PackageConfig):
110 app_settings = entry.module.default_settings
111 elif find_spec(f"{entry}.default_settings"):
112 app_settings = importlib.import_module(f"{entry}.default_settings")
113 else:
114 continue
115
116 self._load_module_settings(app_settings)
117
118 def _load_env_settings(self) -> None:
119 # Collect env settings from all configured prefixes
120 # First prefix wins if same setting appears with multiple prefixes
121 env_settings: dict[
122 str, tuple[str, str]
123 ] = {} # setting_name -> (value, env_var)
124 for prefix in self._env_prefixes:
125 for key, value in os.environ.items():
126 if key.startswith(prefix) and key.isupper():
127 setting_name = key[len(prefix) :]
128 if setting_name and setting_name not in env_settings:
129 env_settings[setting_name] = (value, key)
130
131 for setting, (value, env_var) in env_settings.items():
132 if setting in self._settings:
133 setting_def = self._settings[setting]
134 try:
135 parsed_value = _parse_env_value(
136 value, setting_def.annotation, setting
137 )
138 setting_def.set_value(parsed_value, "env")
139 setting_def.env_var_name = env_var
140 except ImproperlyConfigured as e:
141 self._errors.append(str(e))
142
143 # Uppercase names that are not settings (Python builtins, typing constants, etc.)
144 _IGNORED_NAMES = frozenset({"TYPE_CHECKING"})
145
146 def _load_explicit_settings(self, settings_module: types.ModuleType) -> None:
147 for setting in dir(settings_module):
148 if setting.isupper() and setting not in self._IGNORED_NAMES:
149 setting_value = getattr(settings_module, setting)
150
151 if setting in self._settings:
152 setting_def = self._settings[setting]
153 try:
154 setting_def.set_value(setting_value, "explicit")
155 except ImproperlyConfigured as e:
156 self._errors.append(str(e))
157 continue
158
159 elif setting.startswith(_CUSTOM_SETTINGS_PREFIX):
160 # Accept custom settings prefixed with '{_CUSTOM_SETTINGS_PREFIX}'
161 annotation = _get_annotation(settings_module, setting)
162 setting_def = SettingDefinition(
163 name=setting,
164 default_value=None,
165 annotation=annotation,
166 required=False,
167 )
168 try:
169 setting_def.set_value(setting_value, "explicit")
170 except ImproperlyConfigured as e:
171 self._errors.append(str(e))
172 continue
173 self._settings[setting] = setting_def
174 else:
175 # Collect unrecognized settings individually
176 self._errors.append(
177 f"Unknown setting '{setting}'. Custom settings must start with '{_CUSTOM_SETTINGS_PREFIX}'."
178 )
179
180 # `dir()` skips annotation-only names like `APP_FOO: str`, so they
181 # need their own pass — otherwise PLAIN_APP_FOO would be ignored.
182 self._register_annotation_only_settings(
183 settings_module, require_app_prefix=True
184 )
185
186 def _register_annotation_only_settings(
187 self, module: types.ModuleType, *, require_app_prefix: bool
188 ) -> None:
189 raw_annotations = getattr(module, "__annotations__", {})
190 # Resolve string annotations from `from __future__ import annotations`
191 # so env parsing receives `int` not `'int'`.
192 try:
193 resolved = typing.get_type_hints(module, include_extras=True)
194 except Exception:
195 resolved = {}
196
197 for setting in raw_annotations:
198 if not setting.isupper() or setting in self._IGNORED_NAMES:
199 continue
200 if setting in self._settings:
201 continue
202 if require_app_prefix and not setting.startswith(_CUSTOM_SETTINGS_PREFIX):
203 self._errors.append(
204 f"Unknown setting '{setting}'. Custom settings must start with '{_CUSTOM_SETTINGS_PREFIX}'."
205 )
206 continue
207 self._settings[setting] = SettingDefinition(
208 name=setting,
209 default_value=None,
210 annotation=resolved.get(setting, raw_annotations[setting]),
211 module=module,
212 required=True,
213 )
214
215 def _apply_timezone(self) -> None:
216 if hasattr(time, "tzset") and self.TIME_ZONE:
217 zoneinfo_root = Path("/usr/share/zoneinfo")
218 zone_info_file = zoneinfo_root.joinpath(*self.TIME_ZONE.split("/"))
219 if zoneinfo_root.exists() and not zone_info_file.exists():
220 self._errors.append(
221 f"Invalid TIME_ZONE setting '{self.TIME_ZONE}'. Timezone file not found."
222 )
223 else:
224 os.environ["TZ"] = self.TIME_ZONE
225 time.tzset()
226
227 def _check_required_settings(self) -> None:
228 missing = [k for k, v in self._settings.items() if v.required and not v.is_set]
229 if missing:
230 self._errors.append(f"Missing required setting(s): {', '.join(missing)}.")
231
232 def _raise_errors_if_any(self) -> None:
233 if self._errors:
234 errors = ["- " + e for e in self._errors]
235 raise ImproperlyConfigured(
236 "Settings configuration errors:\n" + "\n".join(errors)
237 )
238
239 def __getattr__(self, name: str) -> typing.Any:
240 # Avoid recursion by directly returning internal attributes
241 if not name.isupper():
242 return object.__getattribute__(self, name)
243
244 self._setup()
245
246 if name in self._settings:
247 return self._settings[name].value
248 else:
249 raise AttributeError(f"'Settings' object has no attribute '{name}'")
250
251 def __setattr__(self, name: str, value: typing.Any) -> None:
252 # Handle internal attributes without recursion
253 if not name.isupper():
254 object.__setattr__(self, name, value)
255 else:
256 if name in self._settings:
257 self._settings[name].set_value(value, "runtime")
258 self._raise_errors_if_any()
259 else:
260 object.__setattr__(self, name, value)
261
262 def __repr__(self) -> str:
263 if not self.configured:
264 return "<Settings [Unevaluated]>"
265 return f'<Settings "{self._settings_module}">'
266
267 def get_settings(
268 self, *, source: str | None = None
269 ) -> list[tuple[str, SettingDefinition]]:
270 """
271 Get settings as a sorted list of (name, definition) tuples.
272
273 Args:
274 source: Filter to settings from a specific source ('default', 'env', 'explicit', 'runtime')
275 """
276 self._setup()
277 result = []
278 for name, defn in sorted(self._settings.items()):
279 if name.startswith("_"):
280 continue
281 if source is not None and defn.source != source:
282 continue
283 result.append((name, defn))
284 return result
285
286 def get_env_settings(self) -> list[tuple[str, SettingDefinition]]:
287 """Get settings that were loaded from environment variables."""
288 return self.get_settings(source="env")
289
290
291def _get_annotation(module: types.ModuleType, setting: str) -> type | None:
292 """Get the resolved type annotation for a setting, handling string annotations."""
293 try:
294 hints = typing.get_type_hints(module, include_extras=True)
295 return hints.get(setting, None)
296 except Exception:
297 return None
298
299
300def _parse_env_value(
301 value: str, annotation: typing.Any, setting_name: str
302) -> typing.Any:
303 if not annotation:
304 raise ImproperlyConfigured(
305 f"{setting_name}: Type hint required to set from environment."
306 )
307
308 # Unwrap Secret[T] to get the inner type
309 if typing.get_origin(annotation) is Secret:
310 if args := typing.get_args(annotation):
311 annotation = args[0]
312
313 # Unwrap `T | None` — empty env string maps to None, otherwise parse as T.
314 # Only the single-arm union case is unwrapped; richer unions fall through.
315 if typing.get_origin(annotation) in (typing.Union, types.UnionType):
316 non_none = tuple(a for a in typing.get_args(annotation) if a is not type(None))
317 if len(non_none) == 1:
318 if value == "":
319 return None
320 annotation = non_none[0]
321
322 if annotation is bool:
323 # Special case for bools
324 return value.lower() in ("true", "1", "yes")
325 elif annotation is str:
326 return value
327 else:
328 # Parse other types using JSON
329 try:
330 return json.loads(value)
331 except json.JSONDecodeError as e:
332 raise ImproperlyConfigured(
333 f"Invalid JSON value for setting '{setting_name}': {e.msg}"
334 ) from e
335
336
337class SettingDefinition:
338 """Store detailed information about settings."""
339
340 def __init__(
341 self,
342 name: str,
343 default_value: typing.Any = None,
344 annotation: type | None = None,
345 module: types.ModuleType | None = None,
346 required: bool = False,
347 ):
348 self.name = name
349 self.default_value = default_value
350 self.annotation = annotation
351 self.module = module
352 self.required = required
353 self.value = default_value
354 self.source = "default" # 'default', 'env', 'explicit', or 'runtime'
355 self.is_set = False # Indicates if the value was set explicitly
356 self.env_var_name: str | None = None # Env var name if loaded from env
357 self.is_secret = self._check_if_secret(annotation)
358
359 @staticmethod
360 def _check_if_secret(annotation: type | None) -> bool:
361 """Check if annotation is Secret[T]."""
362 return annotation is not None and typing.get_origin(annotation) is Secret
363
364 def display_value(self) -> str:
365 """Return value for display, masked if secret."""
366 if self.is_secret and self.value:
367 if isinstance(self.value, dict):
368 return f"{{******** ({len(self.value)} items)}}"
369 if isinstance(self.value, list):
370 return f"[******** ({len(self.value)} items)]"
371 if isinstance(self.value, tuple):
372 return f"(******** ({len(self.value)} items))"
373 return "********"
374 return repr(self.value)
375
376 def set_value(self, value: typing.Any, source: str) -> None:
377 self.check_type(value)
378 self.value = value
379 self.source = source
380 self.is_set = True
381
382 def check_type(self, obj: typing.Any) -> None:
383 if not self.annotation:
384 return
385
386 if not SettingDefinition._is_instance_of_type(obj, self.annotation):
387 raise ImproperlyConfigured(
388 f"'{self.name}': Expected type {self.annotation}, but got {type(obj)}."
389 )
390
391 @staticmethod
392 def _is_instance_of_type(value: typing.Any, type_hint: typing.Any) -> bool:
393 # Simple types
394 if isinstance(type_hint, type):
395 return isinstance(value, type_hint)
396
397 origin = typing.get_origin(type_hint)
398
399 # Secret[T] - check the inner type (Secret is just a marker)
400 if origin is Secret:
401 args = typing.get_args(type_hint)
402 if args:
403 return SettingDefinition._is_instance_of_type(value, args[0])
404 return True
405
406 # Union types
407 if origin is typing.Union or origin is types.UnionType:
408 return any(
409 SettingDefinition._is_instance_of_type(value, arg)
410 for arg in typing.get_args(type_hint)
411 )
412
413 # List types
414 if origin is list:
415 return isinstance(value, list) and all(
416 SettingDefinition._is_instance_of_type(
417 item, typing.get_args(type_hint)[0]
418 )
419 for item in value
420 )
421
422 # Tuple types
423 if origin is tuple:
424 return isinstance(value, tuple) and all(
425 SettingDefinition._is_instance_of_type(
426 item, typing.get_args(type_hint)[i]
427 )
428 for i, item in enumerate(value)
429 )
430
431 raise ValueError(f"Unsupported type hint: {type_hint}")
432
433 def __str__(self) -> str:
434 return f"SettingDefinition(name={self.name}, value={self.value}, source={self.source})"