Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain/plain/packages/registry.py: 79%

1 import functools

2 import sys

3 import threading

4 import warnings

5 from collections import Counter, defaultdict

6 from functools import partial

7

8 from plain.exceptions import ImproperlyConfigured, PackageRegistryNotReady

9

10 from .config import PackageConfig

11

12

13 class 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 f"duplicates: {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, " "duplicates: {}".format(

107 ", ".join(duplicates)

108 )

109 )

110

111 self.packages_ready = True

112

113 # Phase 2: import models modules.

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

115 package_config.import_models()

116

117 self.clear_cache()

118

119 self.models_ready = True

120

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

122 for package_config in self.get_package_configs():

123 package_config.ready()

124

125 self.ready = True

126

127 def check_packages_ready(self):

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

129 if not self.packages_ready:

130 from plain.runtime import settings

131

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

133 # INSTALLED_PACKAGES raises a more helpful ImproperlyConfigured

134 # exception.

135 settings.INSTALLED_PACKAGES

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

137

138 def check_models_ready(self):

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

140 if not self.models_ready:

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

142

143 def get_package_configs(self):

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

145 self.check_packages_ready()

146 return self.package_configs.values()

147

148 def get_package_config(self, package_label):

149 """

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

151

152 Raise LookupError if no application exists with this label.

153 """

154 self.check_packages_ready()

155 try:

156 return self.package_configs[package_label]

157 except KeyError:

158 message = f"No installed app with label '{package_label}'."

159 for package_config in self.get_package_configs():

160 if package_config.name == package_label:

161 message += f" Did you mean '{package_config.label}'?"

162 break

163 raise LookupError(message)

164

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

166 @functools.cache

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

168 """

169 Return a list of all installed models.

170

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

172

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

174 an explicit intermediate table,

175 - models that have been swapped out.

176

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

178 """

179 self.check_models_ready()

180

181 result = []

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

183 result.extend(

184 package_config.get_models(include_auto_created, include_swapped)

185 )

186 return result

187

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

189 """

190 Return the model matching the given package_label and model_name.

191

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

193

194 model_name is case-insensitive.

195

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

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

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

199 """

200 if require_ready:

201 self.check_models_ready()

202 else:

203 self.check_packages_ready()

204

205 if model_name is None:

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

207

208 package_config = self.get_package_config(package_label)

209

210 if not require_ready and package_config.models is None:

211 package_config.import_models()

212

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

214

215 def register_model(self, package_label, model):

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

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

218 # call get_package_config().

219 model_name = model._meta.model_name

220 app_models = self.all_models[package_label]

221 if model_name in app_models:

222 if (

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

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

225 ):

226 warnings.warn(

227 f"Model '{package_label}.{model_name}' was already registered. Reloading models is not "

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

229 "related models.",

230 RuntimeWarning,

231 stacklevel=2,

232 )

233 else:

234 raise RuntimeError(

235 f"Conflicting '{model_name}' models in application '{package_label}': {app_models[model_name]} and {model}."

236 )

237 app_models[model_name] = model

238 self.do_pending_operations(model)

239 self.clear_cache()

240

241 def is_installed(self, package_name):

242 """

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

244

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

246 """

247 self.check_packages_ready()

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

249

250 def get_containing_package_config(self, object_name):

251 """

252 Look for an app config containing a given object.

253

254 object_name is the dotted Python path to the object.

255

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

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

258 """

259 self.check_packages_ready()

260 candidates = []

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

262 if object_name.startswith(package_config.name):

263 subpath = object_name.removeprefix(package_config.name)

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

265 candidates.append(package_config)

266 if candidates:

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

268

269 def get_registered_model(self, package_label, model_name):

270 """

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

272 the given package_label.

273

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

275 is being populated.

276 """

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

278 if model is None:

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

280 return model

281

282 @functools.cache

283 def get_swappable_settings_name(self, to_string):

284 """

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

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

287 referred model is not swappable, return None.

288

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

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

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

292 the respective settings attribute over and over again.

293 """

294 to_string = to_string.lower()

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

296 swapped = model._meta.swapped

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

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

299 return model._meta.swappable

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

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

302 return model._meta.swappable

303 return None

304

305 def set_available_packages(self, available):

306 """

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

308

309 available must be an iterable of application names.

310

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

312

313 Primarily used for performance optimization in TransactionTestCase.

314

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

316 """

317 available = set(available)

318 installed = {

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

320 }

321 if not available.issubset(installed):

322 raise ValueError(

323 "Available packages isn't a subset of installed packages, extra packages: {}".format(

324 ", ".join(available - installed)

325 )

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

432 packages = Packages(installed_packages=None)