Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain/plain/runtime/user_settings.py: 24%

170 statements  

« 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 

8 

9from plain.exceptions import ImproperlyConfigured 

10from plain.packages import PackageConfig 

11 

12ENVIRONMENT_VARIABLE = "PLAIN_SETTINGS_MODULE" 

13ENV_SETTINGS_PREFIX = "PLAIN_" 

14CUSTOM_SETTINGS_PREFIX = "APP_" 

15 

16 

17class Settings: 

18 """ 

19 Settings and configuration for Plain. 

20 

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. 

24 

25 Lazy initialization is implemented to defer loading until settings are first accessed. 

26 """ 

27 

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 

33 

34 def _setup(self): 

35 if self.configured: 

36 return 

37 else: 

38 self.configured = True 

39 

40 self._settings = {} # Maps setting names to SettingDefinition instances 

41 

42 # Determine the settings module 

43 if self._settings_module is None: 

44 self._settings_module = os.environ.get(ENVIRONMENT_VARIABLE, "app.settings") 

45 

46 # First load the global settings from plain 

47 self._load_module_settings( 

48 importlib.import_module("plain.runtime.global_settings") 

49 ) 

50 

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 ) 

58 

59 # Keep a reference to the settings.py module path 

60 self.path = Path(mod.__file__).resolve() 

61 

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() 

72 

73 def _load_module_settings(self, module): 

74 annotations = getattr(module, "__annotations__", {}) 

75 settings = dir(module) 

76 

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 

82 

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 ) 

90 

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 ) 

101 

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 

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(value, setting_def.annotation) 

125 setting_def.set_value(parsed_value, "env") 

126 except ImproperlyConfigured as e: 

127 self._errors.append(str(e)) 

128 

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) 

133 

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 

141 

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 ) 

161 

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() 

172 

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

177 

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 ) 

184 

185 def __getattr__(self, name): 

186 # Avoid recursion by directly returning internal attributes 

187 if not name.isupper(): 

188 return object.__getattribute__(self, name) 

189 

190 self._setup() 

191 

192 if name in self._settings: 

193 return self._settings[name].value 

194 else: 

195 raise AttributeError(f"'Settings' object has no attribute '{name}'") 

196 

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) 

207 

208 def __repr__(self): 

209 if not self.configured: 

210 return "<Settings [Unevaluated]>" 

211 return f'<Settings "{self._settings_module}">' 

212 

213 

214def _parse_env_value(value, annotation): 

215 if not annotation: 

216 raise ImproperlyConfigured("Type hint required to set from environment.") 

217 

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 

231 

232 

233class SettingDefinition: 

234 """Store detailed information about settings.""" 

235 

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 

247 

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 

253 

254 def check_type(self, obj): 

255 if not self.annotation: 

256 return 

257 

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 ) 

262 

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) 

268 

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 ) 

278 

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 ) 

287 

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 ) 

296 

297 raise ValueError(f"Unsupported type hint: {type_hint}") 

298 

299 def __str__(self): 

300 return f"SettingDefinition(name={self.name}, value={self.value}, source={self.source})" 

301 

302 

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

308 

309 def __new__(self, value, setting_name): 

310 return str.__new__(self, value) 

311 

312 def __init__(self, value, setting_name): 

313 self.setting_name = setting_name