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

1import inspect 

2import types 

3from collections import defaultdict 

4from itertools import chain 

5 

6from plain.packages import packages 

7from plain.preflight import Error, Warning, register 

8from plain.runtime import settings 

9 

10 

11@register 

12def check_database_backends(databases=None, **kwargs): 

13 if databases is None: 

14 return [] 

15 

16 from plain.models.db import connections 

17 

18 issues = [] 

19 for alias in databases: 

20 conn = connections[alias] 

21 issues.extend(conn.validation.check(**kwargs)) 

22 return issues 

23 

24 

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 

101 

102 

103def _check_lazy_references(packages, ignore=None): 

104 """ 

105 Ensure all lazy (i.e. string) model references have been resolved. 

106 

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. 

110 

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()) 

115 

116 # Short circuit if there aren't any errors. 

117 if not pending_models: 

118 return [] 

119 

120 from plain.models import signals 

121 

122 model_signals = { 

123 signal: name 

124 for name, signal in vars(signals).items() 

125 if isinstance(signal, signals.ModelSignal) 

126 } 

127 

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. 

134 

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 

144 

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 

152 

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(). 

158 

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") 

170 

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") 

193 

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") 

204 

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 } 

213 

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 

218 

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 ) 

230 

231 

232@register 

233def check_lazy_references(package_configs=None, **kwargs): 

234 return _check_lazy_references(packages) 

235 

236 

237@register 

238def check_database_tables(package_configs, **kwargs): 

239 from plain.models.db import connection 

240 

241 databases = kwargs.get("databases", None) 

242 if not databases: 

243 return [] 

244 

245 errors = [] 

246 

247 for database in databases: 

248 db_tables = connection.introspection.table_names() 

249 model_tables = connection.introspection.plain_table_names() 

250 

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 ) 

267 

268 return errors