Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/packages/registry.py: 35%

170 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-16 22:03 -0500

1import functools 

2import sys 

3import threading 

4import warnings 

5from collections import Counter, defaultdict 

6from functools import partial 

7 

8from plain.exceptions import ImproperlyConfigured, PackageRegistryNotReady 

9 

10from .config import PackageConfig 

11 

12 

13class Packages: 

14 """ 

15 A registry that stores the configuration of installed applications. 

16 

17 It also keeps track of models, e.g. to provide reverse relations. 

18 """ 

19 

20 def __init__(self, installed_packages=()): 

21 # installed_packages is set to None when creating the main registry 

22 # because it cannot be populated at that point. Other registries must 

23 # provide a list of installed packages and are populated immediately. 

24 if installed_packages is None and hasattr(sys.modules[__name__], "packages"): 

25 raise RuntimeError("You must supply an installed_packages argument.") 

26 

27 # Mapping of app labels => model names => model classes. Every time a 

28 # model is imported, ModelBase.__new__ calls packages.register_model which 

29 # creates an entry in all_models. All imported models are registered, 

30 # regardless of whether they're defined in an installed application 

31 # and whether the registry has been populated. Since it isn't possible 

32 # to reimport a module safely (it could reexecute initialization code) 

33 # all_models is never overridden or reset. 

34 self.all_models = defaultdict(dict) 

35 

36 # Mapping of labels to PackageConfig instances for installed packages. 

37 self.package_configs = {} 

38 

39 # Stack of package_configs. Used to store the current state in 

40 # set_available_packages and set_installed_packages. 

41 self.stored_package_configs = [] 

42 

43 # Whether the registry is populated. 

44 self.packages_ready = self.models_ready = self.ready = False 

45 

46 # Lock for thread-safe population. 

47 self._lock = threading.RLock() 

48 self.loading = False 

49 

50 # Maps ("package_label", "modelname") tuples to lists of functions to be 

51 # called when the corresponding model is ready. Used by this class's 

52 # `lazy_model_operation()` and `do_pending_operations()` methods. 

53 self._pending_operations = defaultdict(list) 

54 

55 # Populate packages and models, unless it's the main registry. 

56 if installed_packages is not None: 

57 self.populate(installed_packages) 

58 

59 def populate(self, installed_packages=None): 

60 """ 

61 Load application configurations and models. 

62 

63 Import each application module and then each model module. 

64 

65 It is thread-safe and idempotent, but not reentrant. 

66 """ 

67 if self.ready: 

68 return 

69 

70 # populate() might be called by two threads in parallel on servers 

71 # that create threads before initializing the WSGI callable. 

72 with self._lock: 

73 if self.ready: 

74 return 

75 

76 # An RLock prevents other threads from entering this section. The 

77 # compare and set operation below is atomic. 

78 if self.loading: 

79 # Prevent reentrant calls to avoid running PackageConfig.ready() 

80 # methods twice. 

81 raise RuntimeError("populate() isn't reentrant") 

82 self.loading = True 

83 

84 # Phase 1: initialize app configs and import app modules. 

85 for entry in installed_packages: 

86 if isinstance(entry, PackageConfig): 

87 package_config = entry 

88 else: 

89 package_config = PackageConfig.create(entry) 

90 if package_config.label in self.package_configs: 

91 raise ImproperlyConfigured( 

92 "Package labels aren't unique, " 

93 "duplicates: %s" % package_config.label 

94 ) 

95 

96 self.package_configs[package_config.label] = package_config 

97 package_config.packages = self 

98 

99 # Check for duplicate app names. 

100 counts = Counter( 

101 package_config.name for package_config in self.package_configs.values() 

102 ) 

103 duplicates = [name for name, count in counts.most_common() if count > 1] 

104 if duplicates: 

105 raise ImproperlyConfigured( 

106 "Package names aren't unique, " 

107 "duplicates: %s" % ", ".join(duplicates) 

108 ) 

109 

110 self.packages_ready = True 

111 

112 # Phase 2: import models modules. 

113 for package_config in self.package_configs.values(): 

114 package_config.import_models() 

115 

116 self.clear_cache() 

117 

118 self.models_ready = True 

119 

120 # Phase 3: run ready() methods of app configs. 

121 for package_config in self.get_package_configs(): 

122 package_config.ready() 

123 

124 self.ready = True 

125 

126 def check_packages_ready(self): 

127 """Raise an exception if all packages haven't been imported yet.""" 

128 if not self.packages_ready: 

129 from plain.runtime import settings 

130 

131 # If "not ready" is due to unconfigured settings, accessing 

132 # INSTALLED_PACKAGES raises a more helpful ImproperlyConfigured 

133 # exception. 

134 settings.INSTALLED_PACKAGES 

135 raise PackageRegistryNotReady("Packages aren't loaded yet.") 

136 

137 def check_models_ready(self): 

138 """Raise an exception if all models haven't been imported yet.""" 

139 if not self.models_ready: 

140 raise PackageRegistryNotReady("Models aren't loaded yet.") 

141 

142 def get_package_configs(self): 

143 """Import applications and return an iterable of app configs.""" 

144 self.check_packages_ready() 

145 return self.package_configs.values() 

146 

147 def get_package_config(self, package_label): 

148 """ 

149 Import applications and returns an app config for the given label. 

150 

151 Raise LookupError if no application exists with this label. 

152 """ 

153 self.check_packages_ready() 

154 try: 

155 return self.package_configs[package_label] 

156 except KeyError: 

157 message = "No installed app with label '%s'." % package_label 

158 for package_config in self.get_package_configs(): 

159 if package_config.name == package_label: 

160 message += " Did you mean '%s'?" % package_config.label 

161 break 

162 raise LookupError(message) 

163 

164 # This method is performance-critical at least for Plain's test suite. 

165 @functools.cache 

166 def get_models(self, include_auto_created=False, include_swapped=False): 

167 """ 

168 Return a list of all installed models. 

169 

170 By default, the following models aren't included: 

171 

172 - auto-created models for many-to-many relations without 

173 an explicit intermediate table, 

174 - models that have been swapped out. 

175 

176 Set the corresponding keyword argument to True to include such models. 

177 """ 

178 self.check_models_ready() 

179 

180 result = [] 

181 for package_config in self.package_configs.values(): 

182 result.extend( 

183 package_config.get_models(include_auto_created, include_swapped) 

184 ) 

185 return result 

186 

187 def get_model(self, package_label, model_name=None, require_ready=True): 

188 """ 

189 Return the model matching the given package_label and model_name. 

190 

191 As a shortcut, package_label may be in the form <package_label>.<model_name>. 

192 

193 model_name is case-insensitive. 

194 

195 Raise LookupError if no application exists with this label, or no 

196 model exists with this name in the application. Raise ValueError if 

197 called with a single argument that doesn't contain exactly one dot. 

198 """ 

199 if require_ready: 

200 self.check_models_ready() 

201 else: 

202 self.check_packages_ready() 

203 

204 if model_name is None: 

205 package_label, model_name = package_label.split(".") 

206 

207 package_config = self.get_package_config(package_label) 

208 

209 if not require_ready and package_config.models is None: 

210 package_config.import_models() 

211 

212 return package_config.get_model(model_name, require_ready=require_ready) 

213 

214 def register_model(self, package_label, model): 

215 # Since this method is called when models are imported, it cannot 

216 # perform imports because of the risk of import loops. It mustn't 

217 # call get_package_config(). 

218 model_name = model._meta.model_name 

219 app_models = self.all_models[package_label] 

220 if model_name in app_models: 

221 if ( 

222 model.__name__ == app_models[model_name].__name__ 

223 and model.__module__ == app_models[model_name].__module__ 

224 ): 

225 warnings.warn( 

226 "Model '{}.{}' was already registered. Reloading models is not " 

227 "advised as it can lead to inconsistencies, most notably with " 

228 "related models.".format(package_label, model_name), 

229 RuntimeWarning, 

230 stacklevel=2, 

231 ) 

232 else: 

233 raise RuntimeError( 

234 "Conflicting '{}' models in application '{}': {} and {}.".format( 

235 model_name, package_label, app_models[model_name], model 

236 ) 

237 ) 

238 app_models[model_name] = model 

239 self.do_pending_operations(model) 

240 self.clear_cache() 

241 

242 def is_installed(self, package_name): 

243 """ 

244 Check whether an application with this name exists in the registry. 

245 

246 package_name is the full name of the app e.g. 'plain.staff'. 

247 """ 

248 self.check_packages_ready() 

249 return any(ac.name == package_name for ac in self.package_configs.values()) 

250 

251 def get_containing_package_config(self, object_name): 

252 """ 

253 Look for an app config containing a given object. 

254 

255 object_name is the dotted Python path to the object. 

256 

257 Return the app config for the inner application in case of nesting. 

258 Return None if the object isn't in any registered app config. 

259 """ 

260 self.check_packages_ready() 

261 candidates = [] 

262 for package_config in self.package_configs.values(): 

263 if object_name.startswith(package_config.name): 

264 subpath = object_name.removeprefix(package_config.name) 

265 if subpath == "" or subpath[0] == ".": 

266 candidates.append(package_config) 

267 if candidates: 

268 return sorted(candidates, key=lambda ac: -len(ac.name))[0] 

269 

270 def get_registered_model(self, package_label, model_name): 

271 """ 

272 Similar to get_model(), but doesn't require that an app exists with 

273 the given package_label. 

274 

275 It's safe to call this method at import time, even while the registry 

276 is being populated. 

277 """ 

278 model = self.all_models[package_label].get(model_name.lower()) 

279 if model is None: 

280 raise LookupError(f"Model '{package_label}.{model_name}' not registered.") 

281 return model 

282 

283 @functools.cache 

284 def get_swappable_settings_name(self, to_string): 

285 """ 

286 For a given model string (e.g. "auth.User"), return the name of the 

287 corresponding settings name if it refers to a swappable model. If the 

288 referred model is not swappable, return None. 

289 

290 This method is decorated with @functools.cache because it's performance 

291 critical when it comes to migrations. Since the swappable settings don't 

292 change after Plain has loaded the settings, there is no reason to get 

293 the respective settings attribute over and over again. 

294 """ 

295 to_string = to_string.lower() 

296 for model in self.get_models(include_swapped=True): 

297 swapped = model._meta.swapped 

298 # Is this model swapped out for the model given by to_string? 

299 if swapped and swapped.lower() == to_string: 

300 return model._meta.swappable 

301 # Is this model swappable and the one given by to_string? 

302 if model._meta.swappable and model._meta.label_lower == to_string: 

303 return model._meta.swappable 

304 return None 

305 

306 def set_available_packages(self, available): 

307 """ 

308 Restrict the set of installed packages used by get_package_config[s]. 

309 

310 available must be an iterable of application names. 

311 

312 set_available_packages() must be balanced with unset_available_packages(). 

313 

314 Primarily used for performance optimization in TransactionTestCase. 

315 

316 This method is safe in the sense that it doesn't trigger any imports. 

317 """ 

318 available = set(available) 

319 installed = { 

320 package_config.name for package_config in self.get_package_configs() 

321 } 

322 if not available.issubset(installed): 

323 raise ValueError( 

324 "Available packages isn't a subset of installed packages, extra packages: %s" 

325 % ", ".join(available - installed) 

326 ) 

327 

328 self.stored_package_configs.append(self.package_configs) 

329 self.package_configs = { 

330 label: package_config 

331 for label, package_config in self.package_configs.items() 

332 if package_config.name in available 

333 } 

334 self.clear_cache() 

335 

336 def unset_available_packages(self): 

337 """Cancel a previous call to set_available_packages().""" 

338 self.package_configs = self.stored_package_configs.pop() 

339 self.clear_cache() 

340 

341 def set_installed_packages(self, installed): 

342 """ 

343 Enable a different set of installed packages for get_package_config[s]. 

344 

345 installed must be an iterable in the same format as INSTALLED_PACKAGES. 

346 

347 set_installed_packages() must be balanced with unset_installed_packages(), 

348 even if it exits with an exception. 

349 

350 Primarily used as a receiver of the setting_changed signal in tests. 

351 

352 This method may trigger new imports, which may add new models to the 

353 registry of all imported models. They will stay in the registry even 

354 after unset_installed_packages(). Since it isn't possible to replay 

355 imports safely (e.g. that could lead to registering listeners twice), 

356 models are registered when they're imported and never removed. 

357 """ 

358 if not self.ready: 

359 raise PackageRegistryNotReady("Package registry isn't ready yet.") 

360 self.stored_package_configs.append(self.package_configs) 

361 self.package_configs = {} 

362 self.packages_ready = self.models_ready = self.loading = self.ready = False 

363 self.clear_cache() 

364 self.populate(installed) 

365 

366 def clear_cache(self): 

367 """ 

368 Clear all internal caches, for methods that alter the app registry. 

369 

370 This is mostly used in tests. 

371 """ 

372 # Call expire cache on each model. This will purge 

373 # the relation tree and the fields cache. 

374 self.get_models.cache_clear() 

375 if self.ready: 

376 # Circumvent self.get_models() to prevent that the cache is refilled. 

377 # This particularly prevents that an empty value is cached while cloning. 

378 for package_config in self.package_configs.values(): 

379 for model in package_config.get_models(include_auto_created=True): 

380 model._meta._expire_cache() 

381 

382 def lazy_model_operation(self, function, *model_keys): 

383 """ 

384 Take a function and a number of ("package_label", "modelname") tuples, and 

385 when all the corresponding models have been imported and registered, 

386 call the function with the model classes as its arguments. 

387 

388 The function passed to this method must accept exactly n models as 

389 arguments, where n=len(model_keys). 

390 """ 

391 # Base case: no arguments, just execute the function. 

392 if not model_keys: 

393 function() 

394 # Recursive case: take the head of model_keys, wait for the 

395 # corresponding model class to be imported and registered, then apply 

396 # that argument to the supplied function. Pass the resulting partial 

397 # to lazy_model_operation() along with the remaining model args and 

398 # repeat until all models are loaded and all arguments are applied. 

399 else: 

400 next_model, *more_models = model_keys 

401 

402 # This will be executed after the class corresponding to next_model 

403 # has been imported and registered. The `func` attribute provides 

404 # duck-type compatibility with partials. 

405 def apply_next_model(model): 

406 next_function = partial(apply_next_model.func, model) 

407 self.lazy_model_operation(next_function, *more_models) 

408 

409 apply_next_model.func = function 

410 

411 # If the model has already been imported and registered, partially 

412 # apply it to the function now. If not, add it to the list of 

413 # pending operations for the model, where it will be executed with 

414 # the model class as its sole argument once the model is ready. 

415 try: 

416 model_class = self.get_registered_model(*next_model) 

417 except LookupError: 

418 self._pending_operations[next_model].append(apply_next_model) 

419 else: 

420 apply_next_model(model_class) 

421 

422 def do_pending_operations(self, model): 

423 """ 

424 Take a newly-prepared model and pass it to each function waiting for 

425 it. This is called at the very end of Packages.register_model(). 

426 """ 

427 key = model._meta.package_label, model._meta.model_name 

428 for function in self._pending_operations.pop(key, []): 

429 function(model) 

430 

431 

432packages = Packages(installed_packages=None)