Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/packages/config.py: 68%
108 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-17 17:45 -0500
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-17 17:45 -0500
1import inspect
2import os
3from importlib import import_module
5from plain.exceptions import ImproperlyConfigured
6from plain.utils.module_loading import import_string, module_has_submodule
8CONFIG_MODULE_NAME = "config"
9MODELS_MODULE_NAME = "models"
12class PackageConfig:
13 """Class representing a Plain application and its configuration."""
15 migrations_module = "migrations"
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
21 # Root module for the application e.g. <module 'plain.staff.admin'
22 # from 'staff/__init__.py'>.
23 self.module = package_module
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
29 # The following attributes could be defined at the class level in a
30 # subclass, hence the test-and-set pattern.
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 "The app label '%s' is not a valid Python identifier." % self.label
39 )
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)
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
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
55 def __repr__(self):
56 return f"<{self.__class__.__name__}: {self.label}>"
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 "The app module {!r} has multiple filesystem locations ({!r}); "
75 "you must configure this app with an PackageConfig subclass "
76 "with a 'path' class attribute.".format(module, paths)
77 )
78 elif not paths:
79 raise ImproperlyConfigured(
80 "The app module %r has no filesystem location, "
81 "you must configure this app with an PackageConfig subclass "
82 "with a 'path' class attribute." % module
83 )
84 return paths[0]
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
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]
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
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: %s." % ", ".join(candidates)
174 raise ImportError(msg)
175 else:
176 # Re-trigger the module import exception.
177 import_module(entry)
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(
183 "'%s' isn't a subclass of PackageConfig." % entry
184 )
186 # Obtain package name here rather than in PackageClass.__init__ to keep
187 # all error checking for entries in INSTALLED_PACKAGES in one place.
188 if package_name is None:
189 try:
190 package_name = package_config_class.name
191 except AttributeError:
192 raise ImproperlyConfigured("'%s' must supply a name attribute." % entry)
194 # Ensure package_name points to a valid module.
195 try:
196 package_module = import_module(package_name)
197 except ImportError:
198 raise ImproperlyConfigured(
199 "Cannot import '{}'. Check that '{}.{}.name' is correct.".format(
200 package_name,
201 package_config_class.__module__,
202 package_config_class.__qualname__,
203 )
204 )
206 # Entry is a path to an app config class.
207 return package_config_class(package_name, package_module)
209 def get_model(self, model_name, require_ready=True):
210 """
211 Return the model with the given case-insensitive model_name.
213 Raise LookupError if no model exists with this name.
214 """
215 if require_ready:
216 self.packages.check_models_ready()
217 else:
218 self.packages.check_packages_ready()
219 try:
220 return self.models[model_name.lower()]
221 except KeyError:
222 raise LookupError(
223 f"Package '{self.label}' doesn't have a '{model_name}' model."
224 )
226 def get_models(self, include_auto_created=False, include_swapped=False):
227 """
228 Return an iterable of models.
230 By default, the following models aren't included:
232 - auto-created models for many-to-many relations without
233 an explicit intermediate table,
234 - models that have been swapped out.
236 Set the corresponding keyword argument to True to include such models.
237 Keyword arguments aren't documented; they're a private API.
238 """
239 self.packages.check_models_ready()
240 for model in self.models.values():
241 if model._meta.auto_created and not include_auto_created:
242 continue
243 if model._meta.swapped and not include_swapped:
244 continue
245 yield model
247 def import_models(self):
248 # Dictionary of models for this app, primarily maintained in the
249 # 'all_models' attribute of the Packages this PackageConfig is attached to.
250 self.models = self.packages.all_models[self.label]
252 if module_has_submodule(self.module, MODELS_MODULE_NAME):
253 models_module_name = f"{self.name}.{MODELS_MODULE_NAME}"
254 self.models_module = import_module(models_module_name)
256 def ready(self):
257 """
258 Override this method in subclasses to run code when Plain starts.
259 """