Coverage for /Users/davegaeddert/Development/dropseed/plain/plain-models/plain/models/preflight.py: 13%

119 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-16 22:04 -0500

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 "The '{}.check()' class method is currently overridden by {!r}.".format( 

44 model.__name__, model.check 

45 ), 

46 obj=model, 

47 id="models.E020", 

48 ) 

49 ) 

50 else: 

51 errors.extend(model.check(**kwargs)) 

52 for model_index in model._meta.indexes: 

53 indexes[model_index.name].append(model._meta.label) 

54 for model_constraint in model._meta.constraints: 

55 constraints[model_constraint.name].append(model._meta.label) 

56 if settings.DATABASE_ROUTERS: 

57 error_class, error_id = Warning, "models.W035" 

58 error_hint = ( 

59 "You have configured settings.DATABASE_ROUTERS. Verify that %s " 

60 "are correctly routed to separate databases." 

61 ) 

62 else: 

63 error_class, error_id = Error, "models.E028" 

64 error_hint = None 

65 for db_table, model_labels in db_table_models.items(): 

66 if len(model_labels) != 1: 

67 model_labels_str = ", ".join(model_labels) 

68 errors.append( 

69 error_class( 

70 "db_table '{}' is used by multiple models: {}.".format( 

71 db_table, model_labels_str 

72 ), 

73 obj=db_table, 

74 hint=(error_hint % model_labels_str) if error_hint else None, 

75 id=error_id, 

76 ) 

77 ) 

78 for index_name, model_labels in indexes.items(): 

79 if len(model_labels) > 1: 

80 model_labels = set(model_labels) 

81 errors.append( 

82 Error( 

83 "index name '{}' is not unique {} {}.".format( 

84 index_name, 

85 "for model" if len(model_labels) == 1 else "among models:", 

86 ", ".join(sorted(model_labels)), 

87 ), 

88 id="models.E029" if len(model_labels) == 1 else "models.E030", 

89 ), 

90 ) 

91 for constraint_name, model_labels in constraints.items(): 

92 if len(model_labels) > 1: 

93 model_labels = set(model_labels) 

94 errors.append( 

95 Error( 

96 "constraint name '{}' is not unique {} {}.".format( 

97 constraint_name, 

98 "for model" if len(model_labels) == 1 else "among models:", 

99 ", ".join(sorted(model_labels)), 

100 ), 

101 id="models.E031" if len(model_labels) == 1 else "models.E032", 

102 ), 

103 ) 

104 return errors 

105 

106 

107def _check_lazy_references(packages, ignore=None): 

108 """ 

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

110 

111 Lazy references are used in various places throughout Plain, primarily in 

112 related fields and model signals. Identify those common cases and provide 

113 more helpful error messages for them. 

114 

115 The ignore parameter is used by StatePackages to exclude swappable models from 

116 this check. 

117 """ 

118 pending_models = set(packages._pending_operations) - (ignore or set()) 

119 

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

121 if not pending_models: 

122 return [] 

123 

124 from plain.models import signals 

125 

126 model_signals = { 

127 signal: name 

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

129 if isinstance(signal, signals.ModelSignal) 

130 } 

131 

132 def extract_operation(obj): 

133 """ 

134 Take a callable found in Packages._pending_operations and identify the 

135 original callable passed to Packages.lazy_model_operation(). If that 

136 callable was a partial, return the inner, non-partial function and 

137 any arguments and keyword arguments that were supplied with it. 

138 

139 obj is a callback defined locally in Packages.lazy_model_operation() and 

140 annotated there with a `func` attribute so as to imitate a partial. 

141 """ 

142 operation, args, keywords = obj, [], {} 

143 while hasattr(operation, "func"): 

144 args.extend(getattr(operation, "args", [])) 

145 keywords.update(getattr(operation, "keywords", {})) 

146 operation = operation.func 

147 return operation, args, keywords 

148 

149 def app_model_error(model_key): 

150 try: 

151 packages.get_package_config(model_key[0]) 

152 model_error = "app '{}' doesn't provide model '{}'".format(*model_key) 

153 except LookupError: 

154 model_error = "app '%s' isn't installed" % model_key[0] 

155 return model_error 

156 

157 # Here are several functions which return CheckMessage instances for the 

158 # most common usages of lazy operations throughout Plain. These functions 

159 # take the model that was being waited on as an (package_label, modelname) 

160 # pair, the original lazy function, and its positional and keyword args as 

161 # determined by extract_operation(). 

162 

163 def field_error(model_key, func, args, keywords): 

164 error_msg = ( 

165 "The field %(field)s was declared with a lazy reference " 

166 "to '%(model)s', but %(model_error)s." 

167 ) 

168 params = { 

169 "model": ".".join(model_key), 

170 "field": keywords["field"], 

171 "model_error": app_model_error(model_key), 

172 } 

173 return Error(error_msg % params, obj=keywords["field"], id="fields.E307") 

174 

175 def signal_connect_error(model_key, func, args, keywords): 

176 error_msg = ( 

177 "%(receiver)s was connected to the '%(signal)s' signal with a " 

178 "lazy reference to the sender '%(model)s', but %(model_error)s." 

179 ) 

180 receiver = args[0] 

181 # The receiver is either a function or an instance of class 

182 # defining a `__call__` method. 

183 if isinstance(receiver, types.FunctionType): 

184 description = "The function '%s'" % receiver.__name__ 

185 elif isinstance(receiver, types.MethodType): 

186 description = "Bound method '{}.{}'".format( 

187 receiver.__self__.__class__.__name__, 

188 receiver.__name__, 

189 ) 

190 else: 

191 description = "An instance of class '%s'" % receiver.__class__.__name__ 

192 signal_name = model_signals.get(func.__self__, "unknown") 

193 params = { 

194 "model": ".".join(model_key), 

195 "receiver": description, 

196 "signal": signal_name, 

197 "model_error": app_model_error(model_key), 

198 } 

199 return Error(error_msg % params, obj=receiver.__module__, id="signals.E001") 

200 

201 def default_error(model_key, func, args, keywords): 

202 error_msg = ( 

203 "%(op)s contains a lazy reference to %(model)s, but %(model_error)s." 

204 ) 

205 params = { 

206 "op": func, 

207 "model": ".".join(model_key), 

208 "model_error": app_model_error(model_key), 

209 } 

210 return Error(error_msg % params, obj=func, id="models.E022") 

211 

212 # Maps common uses of lazy operations to corresponding error functions 

213 # defined above. If a key maps to None, no error will be produced. 

214 # default_error() will be used for usages that don't appear in this dict. 

215 known_lazy = { 

216 ("plain.models.fields.related", "resolve_related_class"): field_error, 

217 ("plain.models.fields.related", "set_managed"): None, 

218 ("plain.signals.dispatch.dispatcher", "connect"): signal_connect_error, 

219 } 

220 

221 def build_error(model_key, func, args, keywords): 

222 key = (func.__module__, func.__name__) 

223 error_fn = known_lazy.get(key, default_error) 

224 return error_fn(model_key, func, args, keywords) if error_fn else None 

225 

226 return sorted( 

227 filter( 

228 None, 

229 ( 

230 build_error(model_key, *extract_operation(func)) 

231 for model_key in pending_models 

232 for func in packages._pending_operations[model_key] 

233 ), 

234 ), 

235 key=lambda error: error.msg, 

236 ) 

237 

238 

239@register 

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

241 return _check_lazy_references(packages) 

242 

243 

244@register 

245def check_database_tables(package_configs, **kwargs): 

246 from plain.models.db import connection 

247 

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

249 if not databases: 

250 return [] 

251 

252 errors = [] 

253 

254 for database in databases: 

255 db_tables = connection.introspection.table_names() 

256 model_tables = connection.introspection.plain_table_names() 

257 

258 unknown_tables = set(db_tables) - set(model_tables) 

259 unknown_tables.discard("plainmigrations") # Know this could be there 

260 if unknown_tables: 

261 table_names = ", ".join(unknown_tables) 

262 specific_hint = f'echo "DROP TABLE IF EXISTS {unknown_tables.pop()}" | plain models db-shell' 

263 errors.append( 

264 Warning( 

265 f"Unknown tables in {database} database: {table_names}", 

266 hint=( 

267 "Tables may be from packages/models that have been uninstalled. " 

268 "Make sure you have a backup and delete the tables manually " 

269 f"(ex. `{specific_hint}`)." 

270 ), 

271 id="plain.models.W001", 

272 ) 

273 ) 

274 

275 return errors