django-postgres-denormalize 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ """
2
+ System checks that look at the project as a whole rather than at one field.
3
+
4
+ The field-level checks (a lookup path that does not resolve, a nullable hop, a target that differs from the path) are
5
+ part of each field's own ``check()``. The checks here are registered under the ``postgres_denormalize`` tag:
6
+
7
+ - ``E301``: pgtrigger neither writes triggers into migrations nor installs them after ``migrate``, so the triggers that
8
+ this package derives would never reach the database.
9
+ - ``W302``: a lookup path crosses a model of an installed package, so that model gets triggers, and
10
+ ``makemigrations`` writes them into the package's migrations. A hop that points at a proxy model in one of the
11
+ project's own apps moves the triggers into that app.
12
+ - ``W303``/``E303``: a database that stores copies runs transactions below SERIALIZABLE. A leaf write that runs at the
13
+ same time as an update of its ancestor can then commit a copy that differs from its lookup path.
14
+ - ``W101``/``E101``: a foreign key whose target the model can also reach through another chain of foreign keys. The
15
+ column then stores a copy that no trigger keeps equal to its source. Declare it as a ``DenormalizedForeignKey``, or
16
+ list it in ``REDUNDANT_COPY_CHECK['ALLOWED']`` with the reason why the two may differ.
17
+ - ``E102``: an ``ALLOWED`` entry that does not name such a foreign key, so the entry can be removed.
18
+ - ``E103``: an ``ALLOWED`` entry with an empty reason, which cannot be told apart from an oversight.
19
+ - ``W201``/``E201``: a ``Meta.ordering`` that walks a lookup path that the model also stores a copy of. Every query
20
+ with the default ordering then joins the tables that the copy is meant to avoid, and an index on the copy cannot
21
+ serve it.
22
+ - ``E401``: an ``EXCLUDE_MODELS`` entry that names no installed model, so it excludes nothing.
23
+ - ``E402``: a ``REDUNDANT_COPY_CHECK['APPS']`` entry that names no installed app, so it checks nothing.
24
+
25
+ ``REDUNDANT_COPY_CHECK['LEVEL']``, ``ORDERING_CHECK_LEVEL`` and ``ISOLATION_CHECK_LEVEL`` choose between the warning,
26
+ the error, and ``'off'``.
27
+ """
28
+
29
+ import site
30
+ import sysconfig
31
+ from collections import deque
32
+ from pathlib import Path
33
+
34
+ from django.apps import apps
35
+ from django.core import checks
36
+ from django.core.exceptions import FieldDoesNotExist
37
+ from django.db import connections, router
38
+ from django.db.backends.postgresql.psycopg_any import IsolationLevel
39
+ from pgtrigger import features
40
+
41
+ from postgres_denormalize import conf
42
+ from postgres_denormalize.chains import can_cross, is_forward_hop
43
+ from postgres_denormalize.fields import DenormalizedForeignKey, get_denormalized_fields
44
+ from postgres_denormalize.triggers import owners_of, resolvable_fields
45
+
46
+ TAG = 'postgres_denormalize'
47
+
48
+
49
+ @checks.register(TAG)
50
+ def check_triggers_reach_the_database(app_configs=None, **kwargs):
51
+ if features.migrations() or features.install_on_migrate():
52
+ return []
53
+ return [
54
+ checks.Error(
55
+ 'pgtrigger neither writes triggers into migrations nor installs them after migrate, so the triggers that '
56
+ 'postgres_denormalize derives never reach the database.',
57
+ hint='Leave PGTRIGGER_MIGRATIONS on (the default), or turn PGTRIGGER_INSTALL_ON_MIGRATE on.',
58
+ id='postgres_denormalize.E301',
59
+ )
60
+ ]
61
+
62
+
63
+ @checks.register(TAG)
64
+ def check_excluded_models_exist(app_configs=None, **kwargs):
65
+ installed = {model._meta.label_lower for model in apps.get_models()}
66
+ return [
67
+ checks.Error(
68
+ f'{label} is listed in EXCLUDE_MODELS, but no installed model has that label, '
69
+ 'so the entry excludes nothing.',
70
+ hint="Use the model's label, 'app_label.ModelName', or remove the entry.",
71
+ id='postgres_denormalize.E401',
72
+ )
73
+ for label in sorted(conf.get_setting('EXCLUDE_MODELS'))
74
+ if label.lower() not in installed
75
+ ]
76
+
77
+
78
+ @checks.register(TAG)
79
+ def check_redundant_copies(app_configs=None, **kwargs):
80
+ config = conf.get_setting('REDUNDANT_COPY_CHECK')
81
+ if config['LEVEL'] == 'off':
82
+ return []
83
+
84
+ allowed = config['ALLOWED']
85
+ messages = []
86
+ flagged = set()
87
+ installed_labels = {app_config.label for app_config in apps.get_app_configs()}
88
+ for label in sorted(config['APPS'] or ()):
89
+ if label not in installed_labels:
90
+ messages.append(
91
+ checks.Error(
92
+ f'{label} is listed in REDUNDANT_COPY_CHECK["APPS"], but no installed app has that label, so the '
93
+ 'entry checks nothing.',
94
+ hint='Use the app label, or remove the entry.',
95
+ id='postgres_denormalize.E402',
96
+ )
97
+ )
98
+ checked = _checked_models(app_configs, config['APPS'])
99
+ for model in checked:
100
+ for field in _forward_to_one_fields(model):
101
+ path = alternative_paths(model, field, config['MAX_DEPTH']).get(field.related_model)
102
+ if path is None:
103
+ continue
104
+ label = f'{model._meta.label}.{field.name}'
105
+ flagged.add(label)
106
+ if isinstance(field, DenormalizedForeignKey) or label in allowed:
107
+ continue
108
+ messages.append(
109
+ _warning_or_error(
110
+ config['LEVEL'],
111
+ '101',
112
+ f'{label} points at {field.related_model._meta.label}, which {model._meta.label} also reaches '
113
+ f'through `{path}`, so the column stores a copy that no trigger keeps equal to its source.',
114
+ hint=(
115
+ f"Declare it as DenormalizedForeignKey('{path}', ...), or add '{label}' to "
116
+ f"POSTGRES_DENORMALIZE['REDUNDANT_COPY_CHECK']['ALLOWED'] with the reason the two may differ."
117
+ ),
118
+ obj=field,
119
+ )
120
+ )
121
+
122
+ checked_labels = {model._meta.label for model in checked}
123
+ for label, reason in sorted(allowed.items()):
124
+ if label.rpartition('.')[0] in checked_labels and label not in flagged:
125
+ messages.append(
126
+ checks.Error(
127
+ f'{label} is listed in REDUNDANT_COPY_CHECK["ALLOWED"], but its model cannot reach its target in '
128
+ 'any other way, so the entry is not needed.',
129
+ id='postgres_denormalize.E102',
130
+ )
131
+ )
132
+ if not reason or not str(reason).strip():
133
+ messages.append(
134
+ checks.Error(
135
+ f'{label} is listed in REDUNDANT_COPY_CHECK["ALLOWED"] without a reason.',
136
+ hint='State why the two routes may lead to different rows.',
137
+ id='postgres_denormalize.E103',
138
+ )
139
+ )
140
+ return messages
141
+
142
+
143
+ @checks.register(TAG)
144
+ def check_ordering_uses_copies(app_configs=None, **kwargs):
145
+ level = conf.get_setting('ORDERING_CHECK_LEVEL')
146
+ if level == 'off':
147
+ return []
148
+
149
+ messages = []
150
+ for model in _checked_models(app_configs, None):
151
+ for entry in model._meta.ordering:
152
+ if not isinstance(entry, str):
153
+ continue
154
+ for field in get_denormalized_fields(model):
155
+ if crosses_lookup_path(model, entry.removeprefix('-').removeprefix('?'), field.denormalize_from):
156
+ messages.append(
157
+ _warning_or_error(
158
+ level,
159
+ '201',
160
+ f'{model._meta.label} orders by `{entry}`, which walks `{field.denormalize_from}`, a path '
161
+ f'that its field `{field.name}` stores a copy of.',
162
+ hint=f'Order by `{ordering_through_copy(model, entry, field)}` instead.',
163
+ obj=model,
164
+ )
165
+ )
166
+ return messages
167
+
168
+
169
+ def ordering_through_copy(model, entry, field):
170
+ """
171
+ Return the ordering ``entry`` of ``model`` with the lookup path that ``field`` copies replaced by the field.
172
+
173
+ The direction and the segments after the copied path are kept: with the copy ``tenant`` of ``project__tenant``,
174
+ ``-project__tenant__name`` becomes ``-tenant__name``.
175
+ """
176
+ direction = '-' if entry.startswith('-') else ''
177
+ names = _field_names(model, entry.removeprefix('-'))
178
+ rest = names[len(field.denormalize_from.split('__')) :]
179
+ return direction + '__'.join([field.name, *rest])
180
+
181
+
182
+ @checks.register(TAG)
183
+ def check_owners_outside_the_project(app_configs=None, **kwargs):
184
+ return third_party_owner_warnings(_checked_models(app_configs, None))
185
+
186
+
187
+ def third_party_owner_warnings(leaves):
188
+ """
189
+ Return a W302 warning for every model in a site-packages app that gets cascade and fill triggers for one of
190
+ ``leaves``.
191
+
192
+ pgtrigger adds a trigger to the migration state of the model that it is registered on. ``makemigrations`` would then
193
+ write a migration into the third-party app, inside site-packages, or report a change that never goes away. A hop
194
+ that points at a proxy model in the project's own app moves the triggers, and their migration, into that app.
195
+ """
196
+ installed = _site_packages_directories()
197
+ messages = []
198
+ for leaf in leaves:
199
+ for owner in owners_of(resolvable_fields(leaf)):
200
+ config = apps.get_app_config(owner._meta.app_label)
201
+ if not _is_inside(Path(config.path), installed):
202
+ continue
203
+ messages.append(
204
+ checks.Warning(
205
+ f'{leaf._meta.label} copies from {owner._meta.label}, a model of an installed package, so its '
206
+ f'cascade and fill triggers are registered on {owner._meta.label}, and makemigrations writes them '
207
+ f'into the migrations of {config.label}.',
208
+ hint=(
209
+ f'Declare a proxy model of {owner._meta.label} in one of your own apps, and point the foreign '
210
+ f'key of {leaf._meta.label} at the proxy. The triggers and their migration then belong to that '
211
+ f'app.'
212
+ ),
213
+ obj=leaf,
214
+ id='postgres_denormalize.W302',
215
+ )
216
+ )
217
+ return messages
218
+
219
+
220
+ @checks.register(TAG)
221
+ def check_isolation_level(app_configs=None, **kwargs):
222
+ level = conf.get_setting('ISOLATION_CHECK_LEVEL')
223
+ if level == 'off':
224
+ return []
225
+ leaves = [model for model in apps.get_models() if get_denormalized_fields(model) and not conf.is_excluded(model)]
226
+ messages = []
227
+ for alias in connections:
228
+ connection = connections[alias]
229
+ if connection.vendor != 'postgresql':
230
+ continue
231
+ if not any(router.allow_migrate_model(alias, leaf) for leaf in leaves):
232
+ continue
233
+ isolation = connection.settings_dict.get('OPTIONS', {}).get('isolation_level', IsolationLevel.READ_COMMITTED)
234
+ if isolation == IsolationLevel.SERIALIZABLE:
235
+ continue
236
+ try:
237
+ isolation_name = IsolationLevel(isolation).name.replace('_', ' ')
238
+ except ValueError:
239
+ isolation_name = repr(isolation)
240
+ messages.append(
241
+ _warning_or_error(
242
+ level,
243
+ '303',
244
+ f'The database {alias!r} runs transactions at the isolation level {isolation_name}. Below '
245
+ f'SERIALIZABLE, a write to a leaf row that runs at the same time as an update of its ancestor row can '
246
+ f'commit a copy that differs from its lookup path.',
247
+ hint=(
248
+ f"Set DATABASES[{alias!r}]['OPTIONS']['isolation_level'] to IsolationLevel.SERIALIZABLE, and retry "
249
+ f'transactions that fail with a serialization error. Or set '
250
+ f"POSTGRES_DENORMALIZE['ISOLATION_CHECK_LEVEL'] to 'off', and find such copies with "
251
+ f'drift_condition().'
252
+ ),
253
+ )
254
+ )
255
+ return messages
256
+
257
+
258
+ def crosses_lookup_path(model, path, denormalize_from):
259
+ """
260
+ Say whether ``path``, an ordering of ``model``, walks the whole of ``denormalize_from``.
261
+
262
+ For the lookup path ``project__tenant``, the paths ``project__tenant``, ``project__tenant_id`` and
263
+ ``project__tenant__name`` all walk it, and each of them pays for the join that the copy is meant to avoid.
264
+ ``project`` does not walk it, because it ends before the copied value.
265
+ """
266
+ path = '__'.join(_field_names(model, path))
267
+ return path == denormalize_from or path.startswith(f'{denormalize_from}__')
268
+
269
+
270
+ def _field_names(model, path):
271
+ """
272
+ Return the segments of ``path``, with each segment that names a field replaced by the field's name.
273
+
274
+ A foreign key's column, such as ``tenant_id``, therefore becomes the name of the foreign key, ``tenant``. A column
275
+ whose own name ends in ``_id`` keeps its name. Once a segment names no field, or a field that is not a relation,
276
+ the remaining segments are transforms or lookups, and they are returned as they are.
277
+ """
278
+ segments = path.split('__')
279
+ names = []
280
+ current = model
281
+ for position, segment in enumerate(segments):
282
+ try:
283
+ field = current._meta.get_field(segment)
284
+ except FieldDoesNotExist:
285
+ return [*names, *segments[position:]]
286
+ names.append(field.name)
287
+ if not field.is_relation or isinstance(field.related_model, str):
288
+ return [*names, *segments[position + 1 :]]
289
+ current = field.related_model
290
+ return names
291
+
292
+
293
+ def alternative_paths(model, excluded_first_hop, max_depth):
294
+ """
295
+ Map each model that can be reached from ``model`` in two or more hops to one lookup path that reaches it.
296
+
297
+ The walk never starts with ``excluded_first_hop``, so a field never counts as an alternative route to itself. A
298
+ single hop does not make a field redundant: two foreign keys on the same model that point at the same target are
299
+ siblings, not copies. The walk crosses only the hops that a lookup path may cross (see ``can_cross``), so every
300
+ path that it returns is a path that a ``DenormalizedForeignKey`` accepts.
301
+
302
+ The walk is breadth-first, so each model is first reached, and expanded, at its smallest depth. A depth-first walk
303
+ could first reach a model through a long route and stop expanding it at the depth limit. It would then never
304
+ revisit the model through a shorter route, and miss every model that only the shorter route brings within the limit.
305
+ """
306
+ paths = {}
307
+ visited = {model}
308
+ frontier = deque([(model, 0, '')])
309
+
310
+ while frontier:
311
+ current, depth, prefix = frontier.popleft()
312
+ if depth >= max_depth:
313
+ continue
314
+ for field in current._meta.get_fields():
315
+ if not can_cross(current, field) or (current is model and field is excluded_first_hop):
316
+ continue
317
+ path = f'{prefix}{field.name}'
318
+ if depth >= 1:
319
+ paths.setdefault(field.related_model, path)
320
+ if field.related_model not in visited:
321
+ visited.add(field.related_model)
322
+ frontier.append((field.related_model, depth + 1, f'{path}__'))
323
+
324
+ return paths
325
+
326
+
327
+ def _forward_to_one_fields(model):
328
+ """
329
+ Return the model's own foreign keys and one-to-one fields, which are the only relations that lead to a single row.
330
+
331
+ A field that the model inherits from a concrete parent is not its own: its column is in the parent's table, and the
332
+ check looks at it on the parent.
333
+ """
334
+ return [field for field in model._meta.local_fields if is_forward_hop(field)]
335
+
336
+
337
+ def _checked_models(app_configs, labels):
338
+ """
339
+ Return the models that the project-wide checks look at.
340
+
341
+ ``app_configs`` is the list of apps that ``manage.py check <app_label>`` limits the run to. Without it, ``labels``
342
+ names the apps. Without those, every app that is not installed in a site-packages directory is checked, because the
343
+ project cannot change the models of a third-party app. Proxy models are skipped, because their fields belong to the
344
+ model that they proxy. Excluded models are skipped too.
345
+ """
346
+ if app_configs is not None:
347
+ configs = app_configs
348
+ elif labels is not None:
349
+ # A label that names no installed app is skipped here. check_redundant_copies reports it as E402.
350
+ installed = {config.label: config for config in apps.get_app_configs()}
351
+ configs = [installed[label] for label in labels if label in installed]
352
+ else:
353
+ installed = _site_packages_directories()
354
+ configs = [config for config in apps.get_app_configs() if not _is_inside(Path(config.path), installed)]
355
+
356
+ return [
357
+ model
358
+ for config in configs
359
+ for model in config.get_models()
360
+ if not model._meta.proxy and not conf.is_excluded(model)
361
+ ]
362
+
363
+
364
+ def _site_packages_directories():
365
+ candidates = [*site.getsitepackages(), site.getusersitepackages()]
366
+ candidates.extend(sysconfig.get_paths()[key] for key in ('purelib', 'platlib'))
367
+ return {Path(candidate).resolve() for candidate in candidates}
368
+
369
+
370
+ def _is_inside(path, directories):
371
+ resolved = path.resolve()
372
+ return any(directory == resolved or directory in resolved.parents for directory in directories)
373
+
374
+
375
+ def _warning_or_error(level, number, message, **kwargs):
376
+ if level == 'error':
377
+ return checks.Error(message, id=f'postgres_denormalize.E{number}', **kwargs)
378
+ return checks.Warning(message, id=f'postgres_denormalize.W{number}', **kwargs)
379
+
380
+
381
+ __all__ = ['alternative_paths', 'crosses_lookup_path']
@@ -0,0 +1,170 @@
1
+ """
2
+ The ``POSTGRES_DENORMALIZE`` setting: its defaults, its validation, and the functions through which the rest of the
3
+ package reads it.
4
+
5
+ Every key is optional::
6
+
7
+ POSTGRES_DENORMALIZE = {
8
+ # Models that this package skips: no triggers, no drift filter, no backfill, no project checks. This is for
9
+ # models that have denormalized fields on purpose without keeping them in sync, such as audit tables. A model is
10
+ # excluded when either key says so.
11
+ 'EXCLUDE_MODELS': ['tenancy.TaskAudit'], # model labels, matched case-insensitively
12
+ 'EXCLUDE_MODELS_CALLBACK': 'myproject.audit.is_audit_model', # a callable, or a dotted path to one
13
+ 'REDUNDANT_COPY_CHECK': {
14
+ 'LEVEL': 'warning', # or 'error', or 'off'
15
+ 'APPS': None, # app labels to check; None checks every app outside site-packages
16
+ 'ALLOWED': {'app_label.Model.field': 'why this foreign key may differ from the other route'},
17
+ 'MAX_DEPTH': 4,
18
+ },
19
+ 'ORDERING_CHECK_LEVEL': 'warning', # or 'error', or 'off'
20
+ # The level of the check that each database that stores copies runs transactions at SERIALIZABLE.
21
+ 'ISOLATION_CHECK_LEVEL': 'warning', # or 'error', or 'off'
22
+ # Whether the WHEN condition of every cascade trigger lets ``defer_cascades()`` skip the trigger. Turning it off
23
+ # keeps the triggers compact. Changing it in either direction changes every cascade trigger, so makemigrations
24
+ # writes a migration that recreates all of them.
25
+ 'DEFERRABLE_CASCADES': True,
26
+ }
27
+
28
+ The values are read each time they are needed, not once at start-up, so ``override_settings`` in a test takes effect.
29
+ """
30
+
31
+ from django.conf import settings
32
+ from django.core.exceptions import ImproperlyConfigured
33
+ from django.utils.module_loading import import_string
34
+
35
+ SETTING_NAME = 'POSTGRES_DENORMALIZE'
36
+
37
+ LEVELS = ('off', 'warning', 'error')
38
+
39
+ DEFAULT_REDUNDANT_COPY_CHECK = {
40
+ 'LEVEL': 'warning',
41
+ 'APPS': None,
42
+ 'ALLOWED': {},
43
+ 'MAX_DEPTH': 4,
44
+ }
45
+
46
+ DEFAULTS = {
47
+ 'EXCLUDE_MODELS': (),
48
+ 'EXCLUDE_MODELS_CALLBACK': None,
49
+ 'REDUNDANT_COPY_CHECK': DEFAULT_REDUNDANT_COPY_CHECK,
50
+ 'ORDERING_CHECK_LEVEL': 'warning',
51
+ 'ISOLATION_CHECK_LEVEL': 'warning',
52
+ 'DEFERRABLE_CASCADES': True,
53
+ }
54
+
55
+
56
+ def user_settings():
57
+ """
58
+ Return the project's ``POSTGRES_DENORMALIZE`` dict, empty when the project sets none.
59
+ """
60
+ return getattr(settings, SETTING_NAME, None) or {}
61
+
62
+
63
+ def get_setting(key):
64
+ """
65
+ Return the value of one key, or its default when the project does not set it. ``REDUNDANT_COPY_CHECK`` is merged
66
+ key by key, so a project can set only ``LEVEL``.
67
+ """
68
+ value = user_settings().get(key, DEFAULTS[key])
69
+ if key == 'REDUNDANT_COPY_CHECK':
70
+ return {**DEFAULT_REDUNDANT_COPY_CHECK, **value}
71
+ return value
72
+
73
+
74
+ def excluded_labels():
75
+ """
76
+ Return the model labels in ``EXCLUDE_MODELS``, in lowercase.
77
+ """
78
+ return {label.lower() for label in get_setting('EXCLUDE_MODELS')}
79
+
80
+
81
+ def exclusion_callback():
82
+ """
83
+ Return the callable that ``EXCLUDE_MODELS_CALLBACK`` names, or None. A dotted path is imported.
84
+ """
85
+ callback = get_setting('EXCLUDE_MODELS_CALLBACK')
86
+ if isinstance(callback, str):
87
+ return import_string(callback)
88
+ return callback
89
+
90
+
91
+ def is_excluded(model):
92
+ """
93
+ Say whether this package skips ``model``: either ``EXCLUDE_MODELS`` lists it, or ``EXCLUDE_MODELS_CALLBACK`` returns
94
+ a true value for it. When neither is configured, no model is excluded.
95
+ """
96
+ if model._meta.label_lower in excluded_labels():
97
+ return True
98
+ callback = exclusion_callback()
99
+ return callback is not None and bool(callback(model))
100
+
101
+
102
+ def validate_settings():
103
+ """
104
+ Raise ``ImproperlyConfigured`` for a ``POSTGRES_DENORMALIZE`` setting that this package cannot use.
105
+
106
+ ``PostgresDenormalizeConfig.ready()`` calls this, so a misspelt key or level stops the project from starting and is
107
+ not ignored.
108
+ """
109
+ configured = user_settings()
110
+ if not isinstance(configured, dict):
111
+ raise ImproperlyConfigured(f'{SETTING_NAME} must be a dict, not {type(configured).__name__}.')
112
+
113
+ unknown = sorted(set(configured) - set(DEFAULTS))
114
+ if unknown:
115
+ raise ImproperlyConfigured(f'{SETTING_NAME} has unknown keys: {", ".join(unknown)}.')
116
+
117
+ labels = get_setting('EXCLUDE_MODELS')
118
+ if isinstance(labels, str) or not isinstance(labels, (list, tuple, set, frozenset)):
119
+ raise ImproperlyConfigured(f'{SETTING_NAME}["EXCLUDE_MODELS"] must be a list of model labels.')
120
+ malformed = sorted(label for label in labels if not isinstance(label, str) or label.count('.') != 1)
121
+ if malformed:
122
+ raise ImproperlyConfigured(
123
+ f'{SETTING_NAME}["EXCLUDE_MODELS"] contains {", ".join(map(repr, malformed))}, but each entry must be a '
124
+ f"model label such as 'app_label.ModelName'."
125
+ )
126
+
127
+ callback = get_setting('EXCLUDE_MODELS_CALLBACK')
128
+ if isinstance(callback, str):
129
+ try:
130
+ import_string(callback)
131
+ except ImportError as exception:
132
+ raise ImproperlyConfigured(
133
+ f'{SETTING_NAME}["EXCLUDE_MODELS_CALLBACK"] is {callback!r}, which cannot be imported: {exception}'
134
+ ) from exception
135
+ elif callback is not None and not callable(callback):
136
+ raise ImproperlyConfigured(
137
+ f'{SETTING_NAME}["EXCLUDE_MODELS_CALLBACK"] must be a callable or a dotted path to one.'
138
+ )
139
+
140
+ redundant = configured.get('REDUNDANT_COPY_CHECK', {})
141
+ if not isinstance(redundant, dict):
142
+ raise ImproperlyConfigured(f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"] must be a dict.')
143
+ unknown = sorted(set(redundant) - set(DEFAULT_REDUNDANT_COPY_CHECK))
144
+ if unknown:
145
+ raise ImproperlyConfigured(f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"] has unknown keys: {", ".join(unknown)}.')
146
+
147
+ merged = get_setting('REDUNDANT_COPY_CHECK')
148
+ _validate_level('REDUNDANT_COPY_CHECK"]["LEVEL', merged['LEVEL'])
149
+ if merged['APPS'] is not None and not isinstance(merged['APPS'], (list, tuple, set)):
150
+ raise ImproperlyConfigured(f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"]["APPS"] must be None or a list of labels.')
151
+ if not isinstance(merged['ALLOWED'], dict):
152
+ raise ImproperlyConfigured(f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"]["ALLOWED"] must be a dict.')
153
+ if not isinstance(merged['MAX_DEPTH'], int) or merged['MAX_DEPTH'] < 2:
154
+ raise ImproperlyConfigured(
155
+ f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"]["MAX_DEPTH"] must be an integer of 2 or more.'
156
+ )
157
+
158
+ _validate_level('ORDERING_CHECK_LEVEL', get_setting('ORDERING_CHECK_LEVEL'))
159
+ _validate_level('ISOLATION_CHECK_LEVEL', get_setting('ISOLATION_CHECK_LEVEL'))
160
+
161
+ if not isinstance(get_setting('DEFERRABLE_CASCADES'), bool):
162
+ raise ImproperlyConfigured(f'{SETTING_NAME}["DEFERRABLE_CASCADES"] must be True or False.')
163
+
164
+
165
+ def _validate_level(key, value):
166
+ if value not in LEVELS:
167
+ raise ImproperlyConfigured(f'{SETTING_NAME}["{key}"] is {value!r}; use one of {", ".join(LEVELS)}.')
168
+
169
+
170
+ __all__ = ['DEFAULTS', 'LEVELS', 'get_setting', 'is_excluded', 'validate_settings']