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

1 import inspect

2 import os

3 from importlib import import_module

4

5 from plain.exceptions import ImproperlyConfigured

6 from plain.utils.module_loading import import_string, module_has_submodule

7

8 CONFIG_MODULE_NAME = "config"

9 MODELS_MODULE_NAME = "models"

10

11

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