Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain-models/plain/models/preflight.py: 16%
119 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
1import inspect
2import types
3from collections import defaultdict
4from itertools import chain
6from plain.packages import packages
7from plain.preflight import Error, Warning, register
8from plain.runtime import settings
11@register
12def check_database_backends(databases=None, **kwargs):
13 if databases is None:
14 return []
16 from plain.models.db import connections
18 issues = []
19 for alias in databases:
20 conn = connections[alias]
21 issues.extend(conn.validation.check(**kwargs))
22 return issues
25@register
26def check_all_models(package_configs=None, **kwargs):
27 db_table_models = defaultdict(list)
28 indexes = defaultdict(list)
29 constraints = defaultdict(list)
30 errors = []
31 if package_configs is None:
32 models = packages.get_models()
33 else:
34 models = chain.from_iterable(
35 package_config.get_models() for package_config in package_configs
36 )
37 for model in models:
38 if model._meta.managed:
39 db_table_models[model._meta.db_table].append(model._meta.label)
40 if not inspect.ismethod(model.check):
41 errors.append(
42 Error(
43 f"The '{model.__name__}.check()' class method is currently overridden by {model.check!r}.",
44 obj=model,
45 id="models.E020",
46 )
47 )
48 else:
49 errors.extend(model.check(**kwargs))
50 for model_index in model._meta.indexes:
51 indexes[model_index.name].append(model._meta.label)
52 for model_constraint in model._meta.constraints:
53 constraints[model_constraint.name].append(model._meta.label)
54 if settings.DATABASE_ROUTERS:
55 error_class, error_id = Warning, "models.W035"
56 error_hint = (
57 "You have configured settings.DATABASE_ROUTERS. Verify that %s "
58 "are correctly routed to separate databases."
59 )
60 else:
61 error_class, error_id = Error, "models.E028"
62 error_hint = None
63 for db_table, model_labels in db_table_models.items():
64 if len(model_labels) != 1:
65 model_labels_str = ", ".join(model_labels)
66 errors.append(
67 error_class(
68 f"db_table '{db_table}' is used by multiple models: {model_labels_str}.",
69 obj=db_table,
70 hint=(error_hint % model_labels_str) if error_hint else None,
71 id=error_id,
72 )
73 )
74 for index_name, model_labels in indexes.items():
75 if len(model_labels) > 1:
76 model_labels = set(model_labels)
77 errors.append(
78 Error(
79 "index name '{}' is not unique {} {}.".format(
80 index_name,
81 "for model" if len(model_labels) == 1 else "among models:",
82 ", ".join(sorted(model_labels)),
83 ),
84 id="models.E029" if len(model_labels) == 1 else "models.E030",
85 ),
86 )
87 for constraint_name, model_labels in constraints.items():
88 if len(model_labels) > 1:
89 model_labels = set(model_labels)
90 errors.append(
91 Error(
92 "constraint name '{}' is not unique {} {}.".format(
93 constraint_name,
94 "for model" if len(model_labels) == 1 else "among models:",
95 ", ".join(sorted(model_labels)),
96 ),
97 id="models.E031" if len(model_labels) == 1 else "models.E032",
98 ),
99 )
100 return errors
103def _check_lazy_references(packages, ignore=None):
104 """
105 Ensure all lazy (i.e. string) model references have been resolved.
107 Lazy references are used in various places throughout Plain, primarily in
108 related fields and model signals. Identify those common cases and provide
109 more helpful error messages for them.
111 The ignore parameter is used by StatePackages to exclude swappable models from
112 this check.
113 """
114 pending_models = set(packages._pending_operations) - (ignore or set())
116 # Short circuit if there aren't any errors.
117 if not pending_models:
118 return []
120 from plain.models import signals
122 model_signals = {
123 signal: name
124 for name, signal in vars(signals).items()
125 if isinstance(signal, signals.ModelSignal)
126 }
128 def extract_operation(obj):
129 """
130 Take a callable found in Packages._pending_operations and identify the
131 original callable passed to Packages.lazy_model_operation(). If that
132 callable was a partial, return the inner, non-partial function and
133 any arguments and keyword arguments that were supplied with it.
135 obj is a callback defined locally in Packages.lazy_model_operation() and
136 annotated there with a `func` attribute so as to imitate a partial.
137 """
138 operation, args, keywords = obj, [], {}
139 while hasattr(operation, "func"):
140 args.extend(getattr(operation, "args", []))
141 keywords.update(getattr(operation, "keywords", {}))
142 operation = operation.func
143 return operation, args, keywords
145 def app_model_error(model_key):
146 try:
147 packages.get_package_config(model_key[0])
148 model_error = "app '{}' doesn't provide model '{}'".format(*model_key)
149 except LookupError:
150 model_error = f"app '{model_key[0]}' isn't installed"
151 return model_error
153 # Here are several functions which return CheckMessage instances for the
154 # most common usages of lazy operations throughout Plain. These functions
155 # take the model that was being waited on as an (package_label, modelname)
156 # pair, the original lazy function, and its positional and keyword args as
157 # determined by extract_operation().
159 def field_error(model_key, func, args, keywords):
160 error_msg = (
161 "The field %(field)s was declared with a lazy reference "
162 "to '%(model)s', but %(model_error)s."
163 )
164 params = {
165 "model": ".".join(model_key),
166 "field": keywords["field"],
167 "model_error": app_model_error(model_key),
168 }
169 return Error(error_msg % params, obj=keywords["field"], id="fields.E307")
171 def signal_connect_error(model_key, func, args, keywords):
172 error_msg = (
173 "%(receiver)s was connected to the '%(signal)s' signal with a "
174 "lazy reference to the sender '%(model)s', but %(model_error)s."
175 )
176 receiver = args[0]
177 # The receiver is either a function or an instance of class
178 # defining a `__call__` method.
179 if isinstance(receiver, types.FunctionType):
180 description = f"The function '{receiver.__name__}'"
181 elif isinstance(receiver, types.MethodType):
182 description = f"Bound method '{receiver.__self__.__class__.__name__}.{receiver.__name__}'"
183 else:
184 description = f"An instance of class '{receiver.__class__.__name__}'"
185 signal_name = model_signals.get(func.__self__, "unknown")
186 params = {
187 "model": ".".join(model_key),
188 "receiver": description,
189 "signal": signal_name,
190 "model_error": app_model_error(model_key),
191 }
192 return Error(error_msg % params, obj=receiver.__module__, id="signals.E001")
194 def default_error(model_key, func, args, keywords):
195 error_msg = (
196 "%(op)s contains a lazy reference to %(model)s, but %(model_error)s."
197 )
198 params = {
199 "op": func,
200 "model": ".".join(model_key),
201 "model_error": app_model_error(model_key),
202 }
203 return Error(error_msg % params, obj=func, id="models.E022")
205 # Maps common uses of lazy operations to corresponding error functions
206 # defined above. If a key maps to None, no error will be produced.
207 # default_error() will be used for usages that don't appear in this dict.
208 known_lazy = {
209 ("plain.models.fields.related", "resolve_related_class"): field_error,
210 ("plain.models.fields.related", "set_managed"): None,
211 ("plain.signals.dispatch.dispatcher", "connect"): signal_connect_error,
212 }
214 def build_error(model_key, func, args, keywords):
215 key = (func.__module__, func.__name__)
216 error_fn = known_lazy.get(key, default_error)
217 return error_fn(model_key, func, args, keywords) if error_fn else None
219 return sorted(
220 filter(
221 None,
222 (
223 build_error(model_key, *extract_operation(func))
224 for model_key in pending_models
225 for func in packages._pending_operations[model_key]
226 ),
227 ),
228 key=lambda error: error.msg,
229 )
232@register
233def check_lazy_references(package_configs=None, **kwargs):
234 return _check_lazy_references(packages)
237@register
238def check_database_tables(package_configs, **kwargs):
239 from plain.models.db import connection
241 databases = kwargs.get("databases", None)
242 if not databases:
243 return []
245 errors = []
247 for database in databases:
248 db_tables = connection.introspection.table_names()
249 model_tables = connection.introspection.plain_table_names()
251 unknown_tables = set(db_tables) - set(model_tables)
252 unknown_tables.discard("plainmigrations") # Know this could be there
253 if unknown_tables:
254 table_names = ", ".join(unknown_tables)
255 specific_hint = f'echo "DROP TABLE IF EXISTS {unknown_tables.pop()}" | plain models db-shell'
256 errors.append(
257 Warning(
258 f"Unknown tables in {database} database: {table_names}",
259 hint=(
260 "Tables may be from packages/models that have been uninstalled. "
261 "Make sure you have a backup and delete the tables manually "
262 f"(ex. `{specific_hint}`)."
263 ),
264 id="plain.models.W001",
265 )
266 )
268 return errors