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