Plain is headed towards 1.0! Subscribe for development updates →

  1import importlib
  2import json
  3import os
  4import time
  5import types
  6import typing
  7from importlib.util import find_spec
  8from pathlib import Path
  9
 10from plain.exceptions import ImproperlyConfigured
 11from plain.packages import PackageConfig
 12
 13ENVIRONMENT_VARIABLE = "PLAIN_SETTINGS_MODULE"
 14ENV_SETTINGS_PREFIX = "PLAIN_"
 15CUSTOM_SETTINGS_PREFIX = "APP_"
 16
 17
 18class Settings:
 19    """
 20    Settings and configuration for Plain.
 21
 22    This class handles loading settings from the module specified by the
 23    PLAIN_SETTINGS_MODULE environment variable, as well as from default settings,
 24    environment variables, and explicit settings in the settings module.
 25
 26    Lazy initialization is implemented to defer loading until settings are first accessed.
 27    """
 28
 29    def __init__(self, settings_module=None):
 30        self._settings_module = settings_module
 31        self._settings = {}
 32        self._errors = []  # Collect configuration errors
 33        self.configured = False
 34
 35    def _setup(self):
 36        if self.configured:
 37            return
 38        else:
 39            self.configured = True
 40
 41        self._settings = {}  # Maps setting names to SettingDefinition instances
 42
 43        # Determine the settings module
 44        if self._settings_module is None:
 45            self._settings_module = os.environ.get(ENVIRONMENT_VARIABLE, "app.settings")
 46
 47        # First load the global settings from plain
 48        self._load_module_settings(
 49            importlib.import_module("plain.runtime.global_settings")
 50        )
 51
 52        # Import the user's settings module
 53        try:
 54            mod = importlib.import_module(self._settings_module)
 55        except ImportError as e:
 56            raise ImproperlyConfigured(
 57                f"Could not import settings '{self._settings_module}': {e}"
 58            )
 59
 60        # Keep a reference to the settings.py module path
 61        self.path = Path(mod.__file__).resolve()
 62
 63        # Load default settings from installed packages
 64        self._load_default_settings(mod)
 65        # Load environment settings
 66        self._load_env_settings()
 67        # Load explicit settings from the settings module
 68        self._load_explicit_settings(mod)
 69        # Check for any required settings that are missing
 70        self._check_required_settings()
 71        # Check for any collected errors
 72        self._raise_errors_if_any()
 73
 74    def _load_module_settings(self, module):
 75        annotations = getattr(module, "__annotations__", {})
 76        settings = dir(module)
 77
 78        for setting in settings:
 79            if setting.isupper():
 80                if setting in self._settings:
 81                    self._errors.append(f"Duplicate setting '{setting}'.")
 82                    continue
 83
 84                setting_value = getattr(module, setting)
 85                self._settings[setting] = SettingDefinition(
 86                    name=setting,
 87                    default_value=setting_value,
 88                    annotation=annotations.get(setting, None),
 89                    module=module,
 90                )
 91
 92        # Store any annotations that didn't have a value (these are required settings)
 93        for setting, annotation in annotations.items():
 94            if setting not in self._settings:
 95                self._settings[setting] = SettingDefinition(
 96                    name=setting,
 97                    default_value=None,
 98                    annotation=annotation,
 99                    module=module,
100                    required=True,
101                )
102
103    def _load_default_settings(self, settings_module):
104        for entry in getattr(settings_module, "INSTALLED_PACKAGES", []):
105            if isinstance(entry, PackageConfig):
106                app_settings = entry.module.default_settings
107            elif find_spec(f"{entry}.default_settings"):
108                app_settings = importlib.import_module(f"{entry}.default_settings")
109            else:
110                continue
111
112            self._load_module_settings(app_settings)
113
114    def _load_env_settings(self):
115        env_settings = {
116            k[len(ENV_SETTINGS_PREFIX) :]: v
117            for k, v in os.environ.items()
118            if k.startswith(ENV_SETTINGS_PREFIX) and k.isupper()
119        }
120        for setting, value in env_settings.items():
121            if setting in self._settings:
122                setting_def = self._settings[setting]
123                try:
124                    parsed_value = _parse_env_value(
125                        value, setting_def.annotation, setting
126                    )
127                    setting_def.set_value(parsed_value, "env")
128                except ImproperlyConfigured as e:
129                    self._errors.append(str(e))
130
131    def _load_explicit_settings(self, settings_module):
132        for setting in dir(settings_module):
133            if setting.isupper():
134                setting_value = getattr(settings_module, setting)
135
136                if setting in self._settings:
137                    setting_def = self._settings[setting]
138                    try:
139                        setting_def.set_value(setting_value, "explicit")
140                    except ImproperlyConfigured as e:
141                        self._errors.append(str(e))
142                        continue
143
144                elif setting.startswith(CUSTOM_SETTINGS_PREFIX):
145                    # Accept custom settings prefixed with '{CUSTOM_SETTINGS_PREFIX}'
146                    setting_def = SettingDefinition(
147                        name=setting,
148                        default_value=None,
149                        annotation=None,
150                        required=False,
151                    )
152                    try:
153                        setting_def.set_value(setting_value, "explicit")
154                    except ImproperlyConfigured as e:
155                        self._errors.append(str(e))
156                        continue
157                    self._settings[setting] = setting_def
158                else:
159                    # Collect unrecognized settings individually
160                    self._errors.append(
161                        f"Unknown setting '{setting}'. Custom settings must start with '{CUSTOM_SETTINGS_PREFIX}'."
162                    )
163
164        if hasattr(time, "tzset") and self.TIME_ZONE:
165            zoneinfo_root = Path("/usr/share/zoneinfo")
166            zone_info_file = zoneinfo_root.joinpath(*self.TIME_ZONE.split("/"))
167            if zoneinfo_root.exists() and not zone_info_file.exists():
168                self._errors.append(
169                    f"Invalid TIME_ZONE setting '{self.TIME_ZONE}'. Timezone file not found."
170                )
171            else:
172                os.environ["TZ"] = self.TIME_ZONE
173                time.tzset()
174
175    def _check_required_settings(self):
176        missing = [k for k, v in self._settings.items() if v.required and not v.is_set]
177        if missing:
178            self._errors.append(f"Missing required setting(s): {', '.join(missing)}.")
179
180    def _raise_errors_if_any(self):
181        if self._errors:
182            errors = ["- " + e for e in self._errors]
183            raise ImproperlyConfigured(
184                "Settings configuration errors:\n" + "\n".join(errors)
185            )
186
187    def __getattr__(self, name):
188        # Avoid recursion by directly returning internal attributes
189        if not name.isupper():
190            return object.__getattribute__(self, name)
191
192        self._setup()
193
194        if name in self._settings:
195            return self._settings[name].value
196        else:
197            raise AttributeError(f"'Settings' object has no attribute '{name}'")
198
199    def __setattr__(self, name, value):
200        # Handle internal attributes without recursion
201        if not name.isupper():
202            object.__setattr__(self, name, value)
203        else:
204            if name in self._settings:
205                self._settings[name].set_value(value, "runtime")
206                self._raise_errors_if_any()
207            else:
208                object.__setattr__(self, name, value)
209
210    def __repr__(self):
211        if not self.configured:
212            return "<Settings [Unevaluated]>"
213        return f'<Settings "{self._settings_module}">'
214
215
216def _parse_env_value(value, annotation, setting_name):
217    if not annotation:
218        raise ImproperlyConfigured(
219            f"{setting_name}: Type hint required to set from environment."
220        )
221
222    if annotation is bool:
223        # Special case for bools
224        return value.lower() in ("true", "1", "yes")
225    elif annotation is str:
226        return value
227    else:
228        # Parse other types using JSON
229        try:
230            return json.loads(value)
231        except json.JSONDecodeError as e:
232            raise ImproperlyConfigured(
233                f"Invalid JSON value for setting '{setting_name}': {e.msg}"
234            ) from e
235
236
237class SettingDefinition:
238    """Store detailed information about settings."""
239
240    def __init__(
241        self, name, default_value=None, annotation=None, module=None, required=False
242    ):
243        self.name = name
244        self.default_value = default_value
245        self.annotation = annotation
246        self.module = module
247        self.required = required
248        self.value = default_value
249        self.source = "default"  # 'default', 'env', 'explicit', or 'runtime'
250        self.is_set = False  # Indicates if the value was set explicitly
251
252    def set_value(self, value, source):
253        self.check_type(value)
254        self.value = value
255        self.source = source
256        self.is_set = True
257
258    def check_type(self, obj):
259        if not self.annotation:
260            return
261
262        if not SettingDefinition._is_instance_of_type(obj, self.annotation):
263            raise ImproperlyConfigured(
264                f"'{self.name}': Expected type {self.annotation}, but got {type(obj)}."
265            )
266
267    @staticmethod
268    def _is_instance_of_type(value, type_hint) -> bool:
269        # Simple types
270        if isinstance(type_hint, type):
271            return isinstance(value, type_hint)
272
273        # Union types
274        if (
275            typing.get_origin(type_hint) is typing.Union
276            or typing.get_origin(type_hint) is types.UnionType
277        ):
278            return any(
279                SettingDefinition._is_instance_of_type(value, arg)
280                for arg in typing.get_args(type_hint)
281            )
282
283        # List types
284        if typing.get_origin(type_hint) is list:
285            return isinstance(value, list) and all(
286                SettingDefinition._is_instance_of_type(
287                    item, typing.get_args(type_hint)[0]
288                )
289                for item in value
290            )
291
292        # Tuple types
293        if typing.get_origin(type_hint) is tuple:
294            return isinstance(value, tuple) and all(
295                SettingDefinition._is_instance_of_type(
296                    item, typing.get_args(type_hint)[i]
297                )
298                for i, item in enumerate(value)
299            )
300
301        raise ValueError(f"Unsupported type hint: {type_hint}")
302
303    def __str__(self):
304        return f"SettingDefinition(name={self.name}, value={self.value}, source={self.source})"