Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain/plain/packages/config.py: 64%

108 statements  

« prev     ^ index     » next       coverage.py v7.6.9, created at 2024-12-23 11:16 -0600

1import inspect 

2import os 

3from importlib import import_module 

4 

5from plain.exceptions import ImproperlyConfigured 

6from plain.utils.module_loading import import_string, module_has_submodule 

7 

8CONFIG_MODULE_NAME = "config" 

9MODELS_MODULE_NAME = "models" 

10 

11 

12class PackageConfig: 

13 """Class representing a Plain application and its configuration.""" 

14 

15 migrations_module = "migrations" 

16 

17 def __init__(self, package_name, package_module): 

18 # Full Python path to the application e.g. 'plain.staff.admin'. 

19 self.name = package_name 

20 

21 # Root module for the application e.g. <module 'plain.staff.admin' 

22 # from 'staff/__init__.py'>. 

23 self.module = package_module 

24 

25 # Reference to the Packages registry that holds this PackageConfig. Set by the 

26 # registry when it registers the PackageConfig instance. 

27 self.packages = None 

28 

29 # The following attributes could be defined at the class level in a 

30 # subclass, hence the test-and-set pattern. 

31 

32 # Last component of the Python path to the application e.g. 'admin'. 

33 # This value must be unique across a Plain project. 

34 if not hasattr(self, "label"): 

35 self.label = package_name.rpartition(".")[2] 

36 if not self.label.isidentifier(): 

37 raise ImproperlyConfigured( 

38 f"The app label '{self.label}' is not a valid Python identifier." 

39 ) 

40 

41 # Filesystem path to the application directory e.g. 

42 # '/path/to/admin'. 

43 if not hasattr(self, "path"): 

44 self.path = self._path_from_module(package_module) 

45 

46 # Module containing models e.g. <module 'plain.staff.models' 

47 # from 'staff/models.py'>. Set by import_models(). 

48 # None if the application doesn't have a models module. 

49 self.models_module = None 

50 

51 # Mapping of lowercase model names to model classes. Initially set to 

52 # None to prevent accidental access before import_models() runs. 

53 self.models = None 

54 

55 def __repr__(self): 

56 return f"<{self.__class__.__name__}: {self.label}>" 

57 

58 def _path_from_module(self, module): 

59 """Attempt to determine app's filesystem path from its module.""" 

60 # See #21874 for extended discussion of the behavior of this method in 

61 # various cases. 

62 # Convert to list because __path__ may not support indexing. 

63 paths = list(getattr(module, "__path__", [])) 

64 if len(paths) != 1: 

65 filename = getattr(module, "__file__", None) 

66 if filename is not None: 

67 paths = [os.path.dirname(filename)] 

68 else: 

69 # For unknown reasons, sometimes the list returned by __path__ 

70 # contains duplicates that must be removed (#25246). 

71 paths = list(set(paths)) 

72 if len(paths) > 1: 

73 raise ImproperlyConfigured( 

74 f"The app module {module!r} has multiple filesystem locations ({paths!r}); " 

75 "you must configure this app with an PackageConfig subclass " 

76 "with a 'path' class attribute." 

77 ) 

78 elif not paths: 

79 raise ImproperlyConfigured( 

80 f"The app module {module!r} has no filesystem location, " 

81 "you must configure this app with an PackageConfig subclass " 

82 "with a 'path' class attribute." 

83 ) 

84 return paths[0] 

85 

86 @classmethod 

87 def create(cls, entry): 

88 """ 

89 Factory that creates an app config from an entry in INSTALLED_PACKAGES. 

90 """ 

91 # create() eventually returns package_config_class(package_name, package_module). 

92 package_config_class = None 

93 package_name = None 

94 package_module = None 

95 

96 # If import_module succeeds, entry points to the app module. 

97 try: 

98 package_module = import_module(entry) 

99 except Exception: 

100 pass 

101 else: 

102 # If package_module has an packages submodule that defines a single 

103 # PackageConfig subclass, use it automatically. 

104 # To prevent this, an PackageConfig subclass can declare a class 

105 # variable default = False. 

106 # If the packages module defines more than one PackageConfig subclass, 

107 # the default one can declare default = True. 

108 if module_has_submodule(package_module, CONFIG_MODULE_NAME): 

109 mod_path = f"{entry}.{CONFIG_MODULE_NAME}" 

110 mod = import_module(mod_path) 

111 # Check if there's exactly one PackageConfig candidate, 

112 # excluding those that explicitly define default = False. 

113 package_configs = [ 

114 (name, candidate) 

115 for name, candidate in inspect.getmembers(mod, inspect.isclass) 

116 if ( 

117 issubclass(candidate, cls) 

118 and candidate is not cls 

119 and getattr(candidate, "default", True) 

120 ) 

121 ] 

122 if len(package_configs) == 1: 

123 package_config_class = package_configs[0][1] 

124 else: 

125 # Check if there's exactly one PackageConfig subclass, 

126 # among those that explicitly define default = True. 

127 package_configs = [ 

128 (name, candidate) 

129 for name, candidate in package_configs 

130 if getattr(candidate, "default", False) 

131 ] 

132 if len(package_configs) > 1: 

133 candidates = [repr(name) for name, _ in package_configs] 

134 raise RuntimeError( 

135 "{!r} declares more than one default PackageConfig: " 

136 "{}.".format(mod_path, ", ".join(candidates)) 

137 ) 

138 elif len(package_configs) == 1: 

139 package_config_class = package_configs[0][1] 

140 

141 # Use the default app config class if we didn't find anything. 

142 if package_config_class is None: 

143 package_config_class = cls 

144 package_name = entry 

145 

146 # If import_string succeeds, entry is an app config class. 

147 if package_config_class is None: 

148 try: 

149 package_config_class = import_string(entry) 

150 except Exception: 

151 pass 

152 # If both import_module and import_string failed, it means that entry 

153 # doesn't have a valid value. 

154 if package_module is None and package_config_class is None: 

155 # If the last component of entry starts with an uppercase letter, 

156 # then it was likely intended to be an app config class; if not, 

157 # an app module. Provide a nice error message in both cases. 

158 mod_path, _, cls_name = entry.rpartition(".") 

159 if mod_path and cls_name[0].isupper(): 

160 # We could simply re-trigger the string import exception, but 

161 # we're going the extra mile and providing a better error 

162 # message for typos in INSTALLED_PACKAGES. 

163 # This may raise ImportError, which is the best exception 

164 # possible if the module at mod_path cannot be imported. 

165 mod = import_module(mod_path) 

166 candidates = [ 

167 repr(name) 

168 for name, candidate in inspect.getmembers(mod, inspect.isclass) 

169 if issubclass(candidate, cls) and candidate is not cls 

170 ] 

171 msg = f"Module '{mod_path}' does not contain a '{cls_name}' class." 

172 if candidates: 

173 msg += " Choices are: {}.".format(", ".join(candidates)) 

174 raise ImportError(msg) 

175 else: 

176 # Re-trigger the module import exception. 

177 import_module(entry) 

178 

179 # Check for obvious errors. (This check prevents duck typing, but 

180 # it could be removed if it became a problem in practice.) 

181 if not issubclass(package_config_class, PackageConfig): 

182 raise ImproperlyConfigured(f"'{entry}' isn't a subclass of PackageConfig.") 

183 

184 # Obtain package name here rather than in PackageClass.__init__ to keep 

185 # all error checking for entries in INSTALLED_PACKAGES in one place. 

186 if package_name is None: 

187 try: 

188 package_name = package_config_class.name 

189 except AttributeError: 

190 raise ImproperlyConfigured(f"'{entry}' must supply a name attribute.") 

191 

192 # Ensure package_name points to a valid module. 

193 try: 

194 package_module = import_module(package_name) 

195 except ImportError: 

196 raise ImproperlyConfigured( 

197 f"Cannot import '{package_name}'. Check that '{package_config_class.__module__}.{package_config_class.__qualname__}.name' is correct." 

198 ) 

199 

200 # Entry is a path to an app config class. 

201 return package_config_class(package_name, package_module) 

202 

203 def get_model(self, model_name, require_ready=True): 

204 """ 

205 Return the model with the given case-insensitive model_name. 

206 

207 Raise LookupError if no model exists with this name. 

208 """ 

209 if require_ready: 

210 self.packages.check_models_ready() 

211 else: 

212 self.packages.check_packages_ready() 

213 try: 

214 return self.models[model_name.lower()] 

215 except KeyError: 

216 raise LookupError( 

217 f"Package '{self.label}' doesn't have a '{model_name}' model." 

218 ) 

219 

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

221 """ 

222 Return an iterable of models. 

223 

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

225 

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

227 an explicit intermediate table, 

228 - models that have been swapped out. 

229 

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

231 Keyword arguments aren't documented; they're a private API. 

232 """ 

233 self.packages.check_models_ready() 

234 for model in self.models.values(): 

235 if model._meta.auto_created and not include_auto_created: 

236 continue 

237 if model._meta.swapped and not include_swapped: 

238 continue 

239 yield model 

240 

241 def import_models(self): 

242 # Dictionary of models for this app, primarily maintained in the 

243 # 'all_models' attribute of the Packages this PackageConfig is attached to. 

244 self.models = self.packages.all_models[self.label] 

245 

246 if module_has_submodule(self.module, MODELS_MODULE_NAME): 

247 models_module_name = f"{self.name}.{MODELS_MODULE_NAME}" 

248 self.models_module = import_module(models_module_name) 

249 

250 def ready(self): 

251 """ 

252 Override this method in subclasses to run code when Plain starts. 

253 """