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})"