v0.153.0
  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})"